feat: jpa, messaging, notification, mongo, graphql 어댑터터 구현체 추가

This commit is contained in:
DongHyeonka
2026-08-15 13:01:58 +09:00
parent ac874e49e6
commit 2f5d2fc219
909 changed files with 62510 additions and 6354 deletions
+85 -20
View File
@@ -33,11 +33,21 @@ leaf). 즉 이 레포에는 두 패턴이 공존한다:
모듈 레코드가 그대로 leaf 명세로 승격될 수 있게 설계해 두었다. 그때까지 모듈 경계는 문서가
아니라 기계가 지킨다:
- `build/GraphQlStableModule` · `build/GraphQlAdvancedModule` 이 모듈 정체성과 허용 의존 edge 를
값으로 선언하고, `build/GraphQlBuildModel` 이 실제 소스 트리를 스캔한다.
- `build/GraphQlModuleBoundaryTest` 가 (a) Stable 패키지의 `...graphql.advanced` import 금지,
(b) `graphql-core-api` 계열의 Spring/GraphQL Java/Reactor/persistence import 금지,
(c) Stable 의존 edge 가 Advanced 모듈을 가리키지 않을 것을 강제한다.
- main 의 `moduleboundary/GraphQlStableModule` · `moduleboundary/GraphQlAdvancedModule` 이 모듈
정체성·purity 등급·허용 의존 edge 를 값으로 선언하고, `moduleboundary/GraphQlModuleBoundary`
"이 패키지의 주인은 누구인가 / 이 edge 는 선언됐는가"를 답한다.
- test 의 `moduleboundary/GraphQlBuildModel` 이 실제 소스 트리를 스캔하고,
`moduleboundary/GraphQlModuleBoundaryTest` 가 (a) Stable 패키지의 `...graphql.advanced` import
금지, (b) `CORE` 등급 모듈의 Spring/GraphQL Java/Reactor/Micrometer/Jakarta import 금지,
(c) 선언되지 않은 cross-module edge 금지, (d) 미등록 패키지 금지, (e) 선언만 있고 소스가 없는
모듈 금지를 강제한다. 각 규칙은 **거부되는 합성 트리(negative fixture)** 를 함께 가진다.
**패키지 이름은 `build` 가 아니라 `moduleboundary` 다.** `src/.gitignore:2` 의 anchor 없는
`build/` 규칙은 Gradle 산출물과 Java 패키지를 구분하지 못해서, 예전에 이 경계 모델 전체를
커밋에서 삼켰다(프로덕션 코드는 계속 import 하고, 작성자 작업본만 컴파일되고, fresh checkout
은 7개 오류로 깨졌다). 레포 전역 `verifyNoIgnoredSourcePackages` 가 이 부류를 막고,
`graphqlStableTest` 의 required-class 검사가 "경계 테스트만 조용히 사라지고 레인은 green" 인
나머지 절반을 막는다.
**새 플랫폼 sub-package 를 추가할 때는 반드시 해당 모듈 레코드에 정체성과 허용 edge 를 먼저
등록한다.** 등록 없이 추가된 패키지는 경계 테스트가 실패시킨다.
@@ -59,13 +69,25 @@ leaf). 즉 이 레포에는 두 패턴이 공존한다:
## Allowed
- `:application-core`, `:domain-core`, `:shared-contract`.
- `spring-boot-starter-graphql`, `spring-boot-starter-web`, `jackson-datatype-jsr310`
(전부 Spring Boot BOM 관리 — 버전 명시 없음).
- test scope 에 한해 실제 HTTP 인증/CORS qualification 용 `spring-boot-starter-security`.
- `compileOnly` 로만 `spring-webflux` — REACTIVE_WEBFLUX 전송 프로파일(`http/webflux/`)을
컴파일하기 위한 것이고, 의도적으로 `runtimeClasspath` 에서 제외한다. MVC 배치에 WebFlux 를
끌어들이지 않기 위함이며 `gradle.lockfile` 이 이 스코프 제한을 고정한다
(`spring-webflux:...=compileClasspath,testCompileClasspath,testRuntimeClasspath`).
- `spring-boot-starter-graphql` (Spring Boot BOM 관리 — 버전 명시 없음).
- test scope 에 한해 `spring-boot-starter-web`(random-port 전송 테스트용),
`spring-boot-starter-security`(HTTP 인증/CORS qualification 용),
`io.micrometer:micrometer-core`(실제 `MeterRegistry` 로 metric label cardinality 를 **측정**).
- `java-test-fixtures` — 계약 스위트·통합 fixture·in-memory 스텁은 `src/testFixtures/java`
소유하고 production jar 에 들어가지 않는다. 모듈 경계 스캐너
(`moduleboundary/GraphQlBuildModel`)는 main 과 testFixtures 를 **함께** 스캔한다: 아티팩트가
갈렸다고 패키지 경계 규칙까지 갈리면, 규칙이 조용히 절반만 남는다.
**서버는 이 leaf 가 고르지 않는다.** production 파일 중 `org.springframework.web`·
`jakarta.servlet`·`org.springframework.http` 을 import 하는 것은 **하나도 없다**. 예전에는
`spring-boot-starter-web` 을 production `implementation` 으로 두어 모든 adopter 의
runtimeClasspath 에 Tomcat 을 올리면서, 동시에 같은 artifact 가 `REACTIVE_WEBFLUX` 실행
프로파일을 표방했다 — leaf 와 함께 servlet 컨테이너가 따라오므로 결코 성립할 수 없는 조합이었다.
이제 서버 선택은 composition root 의 결정이고, `gradle.lockfile` 이 이를 고정한다
(`spring-boot-starter-web`·`spring-webmvc`·`spring-webflux`·`tomcat-embed-*` 전부
`testCompileClasspath,testRuntimeClasspath` 만). `GraphQlRuntimeTransport` 가 실제 실행 중인
서버를 감지해 `backend.graphql.execution-profile` 과 어긋나면 **부팅을 거부**한다.
- `annotationProcessor``spring-boot-configuration-processor``GraphQlPlatformProperties`
`@ConfigurationProperties` 이므로 레포 전역 `verifyConfigurationPropertiesProcessor` 패리티
게이트가 이 선언을 요구한다.
@@ -98,12 +120,30 @@ feature 는 `ApiErrorCarrier` 를 구현한 예외(자신의 `ApiErrorCode` 를
현재 sample 에 feature GraphQL schema/controller/resolver 가 있다고 가정하지 않는다. 이 leaf 는
health 스키마만 소유한다.
## 구현된 플랫폼 범위
## 구현된 플랫폼 범위 — 등급으로 말한다
query depth/cost 제한(`cost/`), persisted operation(`advanced/persisted/`),
DataLoader/batching(`dataloader/`), subscription(`advanced/subscription/`, `advanced/websocket/`,
`advanced/sse/`)은 **더 이상 미구현이 아니다.** 다만 이들은 정책·계약·검증 기계이며, 실제
composition root 가 채택할 때 정책 값과 인증/인가 빈을 함께 제공해야 한다.
"구현됐다" 는 네 가지 서로 다른 사실을 한 단어로 덮는다. 그래서 capability 마다 아래 등급을
쓰고, **현재 등급보다 높게 표현하지 않는다.**
| 등급 | 의미 |
| --- | --- |
| `modelled` | 정책·계약 객체가 있고 단위 테스트가 있다. 요청 경로에는 없다. |
| `wired` | Spring 실행 경로에 연결돼 있고, 실제 endpoint 테스트가 그 사실을 증명한다. |
| `integration-verified` | 실제 외부 시스템(datastore/broker) 과의 통합 증거가 있다. |
| `production-verified` | 실부하·장애 시나리오 증거가 있다. |
| Capability | 등급 | 증거 |
| --- | --- | --- |
| 실행 파이프라인 / 인가 / cost 예산 | `wired` | `runtime/GraphQlPlatformExecutionPathTest` (random-port, 거부 시 resolver 호출 0회) |
| depth/complexity 제한 (`cost/`) | `wired` | 같은 테스트의 depth/alias/complexity 케이스 |
| preparsed document cache (`execution/`) | `wired` | `GraphQlPreparsedDocumentAdapter` + 같은 테스트의 캐시 hit 케이스 |
| 커스텀 scalar (`scalar/`) | `wired` | 같은 테스트의 scalar coercion 케이스 |
| 요청 크기/Accept 협상 (`http/`) | `wired` | `GraphQlRequestBoundsTest`, `GraphQlAcceptNegotiationTest` |
| DataLoader/batching (`dataloader/`) | `wired` | `runtime/GraphQlBatchLoaderRegistrar` + `dataloader/GraphQlBatchContractTest` |
| persisted operation (`advanced/persisted/`) | `modelled` | 중립 `OperationalRecordStorePort` 기반 레지스트리 + 방향성 테스트. durable 구현체는 미제공 |
| subscription / WebSocket / SSE / RSocket | `modelled` | 정책·상태기계 단위 테스트만. Spring transport handler 는 없다(그래서 타입 이름도 `*Admission` 이다) |
| federation / incremental / codegen / compat | `modelled` | 단위 테스트만 |
| 실부하·장애 | 미달성 | `graphqlPerformanceTest` 레인이 자리를 예약, 증거 없으면 릴리스 게이트가 거부 |
여전히 미구현인 것:
@@ -114,7 +154,13 @@ composition root 가 채택할 때 정책 값과 인증/인가 빈을 함께 제
생성한다(`graphqlPerformanceTest` 레인이 그 자리를 예약해 둔다).
- 실제 datastore 통합 증거 — `testkit/GraphQlJpaIntegrationFixture` /
`GraphQlMongoIntegrationFixture` 가 계약을 정의하고 `GraphQlStorageIntegrationEvidence`
증거를 요구한다. 실 datastore 기동은 persistence leaf 의 책임 범위다.
증거를 요구한다. 실 datastore 기동은 persistence leaf 의 책임 범위다. 이 testkit 은
**production jar 에 없다**`src/testFixtures/java` 에 살고 `verifyGraphQlProductionJar`
그 사실을 jar 내용으로 확인한다.
- persisted operation 의 durable 저장 구현체 — 이 leaf 는 중립 계약
`dev.caskeleton.shared.opstore.OperationalRecordStorePort` 에만 의존하고 key/value 매핑만
소유한다. Postgres/Redis 구현체는 **그 중립 계약을** 구현하며, 이 leaf 의 타입을 구현하지
않는다(그랬다면 인프라 → 인바운드 전송으로 의존이 뒤집힌다).
- Advanced capability 는 전부 **기본 비활성**이다(`advanced/bootstrap/GraphQlAdvancedFeatureFlags`).
EXPERIMENTAL 등급(RSocket, incremental delivery, HTTP GET draft)은 명시적 승인 없이는
`GraphQlAdvancedModuleGuard` 가 production 활성화를 거부한다.
@@ -133,11 +179,30 @@ cd src
`quarantine`·`graphql-performance` 태그를 제외한다:
```bash
./gradlew :adapter:inbound:graphql:graphqlStableTest --console=plain # 404 tests
./gradlew :adapter:inbound:graphql:graphqlStableTest --console=plain # 551 tests
./gradlew :adapter:inbound:graphql:graphqlContractTest --console=plain # 9 tests
./gradlew :adapter:inbound:graphql:graphqlAdvancedTest --console=plain # 141 tests
./gradlew :adapter:inbound:graphql:graphqlAdvancedTest --console=plain # 152 tests
./gradlew :adapter:inbound:graphql:graphqlPerformanceTest --console=plain # 실부하 인프라 필요
```
`graphqlPerformanceTest``@Tag("graphql-performance")` 가 하나도 없으면 **실패한다** — 이는
버그가 아니라 "성능 증거 없음"을 통과로 위장하지 않기 위한 fail-closed 설계다.
위 숫자는 `build/test-results/<lane>/*.xml` 의 실제 실행 결과다(기본 `test` 703, transport
qualification 8). 문서에 옮겨 적은 숫자는 반드시 마지막 green 실행에서 다시 읽어 갱신한다 —
컴파일이 깨진 채로 남은 과거 숫자는 통과 증거가 아니라 통과했다는 인상일 뿐이다.
## 아티팩트 게이트
```bash
./gradlew :adapter:inbound:graphql:verifyGraphQlProductionJar --console=plain
./gradlew :adapter:inbound:graphql:verifyGraphQlApiSurface --console=plain
```
- `verifyGraphQlProductionJar` — production jar 에 `testkit`/`InMemory`/`Fixture`/`TestContext`
클래스가 하나라도 있으면 실패한다. 계약 스위트와 in-memory 스텁은 `src/testFixtures/java`
소유한다.
- `verifyGraphQlApiSurface``docs/architecture/graphql-api-surface.txt` 스냅샷과 실제 public
타입 목록이 다르면 실패한다. 단일 jar 안에서 `public` 은 모든 adopter 에게 public 이므로,
표면 증가는 리뷰 결정이지 빌드 부산물이 아니다. 승인 후:
`./gradlew :adapter:inbound:graphql:updateGraphQlApiSurface -PapproveGraphQlApiSurfaceChange`.
+43 -8
View File
@@ -72,11 +72,40 @@ gRPC 와 달리 spring-graphql / graphql-java 는 Spring Boot BOM 이 관리한
버전 명시도, 모듈 스코프 platform import 도 필요 없다 — `build.gradle` 은 BOM-managed 좌표만
선언하고, per-module `gradle.lockfile` 이 strict locking 으로 정확한 버전을 고정한다.
## 설정 — 프레임워크 `spring.graphql.*`
## 설정 — 프레임워크 `spring.graphql.*` + 플랫폼 `backend.graphql.*`
이 모듈은 자체 `@ConfigurationProperties` 를 두지 않는다. path, graphiql, introspection, schema
location 은 프레임워크 `spring.graphql.*` composition-root `application.yml` 에서 설정한다
(모듈별 `yml` 없음). 정말 필요한 knob 이 생기기 전까지 커스텀 설정 클래스는 두지 않는다.
전송 계층 설정(path, graphiql, introspection, schema location)은 프레임워크 `spring.graphql.*`
가 소유한다. composition-root `application.yml` 에서 설정하며 모듈별 `yml` 은 없다.
플랫폼 정책은 `spring.graphql.*` 로 표현할 수 없다 — 실행 프로파일, cost/page 한계, preparsed
캐시 경계, cursor 키 링, 관측 label 로 허용할 operation 이름은 전부 이 leaf 의 결정이다. 그래서
`GraphQlPlatformProperties`**`backend.graphql`** prefix 로 `@ConfigurationProperties`
바인딩한다(`spring.graphql.platform.*` 이 아니다 — 그 prefix 는 존재한 적이 없다).
```yaml
backend:
graphql:
production: true
environment: PRODUCTION_PUBLIC
execution-profile: BLOCKING_MVC
validation-policy-version: v1 # preparsed 캐시 키의 일부
console:
graphiql-enabled: false
introspection-enabled: false
limits:
maximum-page-size: 100
maximum-complexity: 10000
preparsed-cache-entries: 1000
preparsed-cache-weight: 10000000
preparsed-cache-expire-after-access: 30m
cursor:
key-ids: [cursor-key-1] # 키 자체는 설정에 오지 않는다
observed-operation-names: [] # 비우면 모든 operation 이름이 `other` 로 접힌다
```
`observed-operation-names` 가 비어 있는 것이 기본값이자 안전한 값이다. operation 이름은 문법만
검증될 뿐 개수가 제한되지 않으므로, 원본을 그대로 metric label 로 쓰면 정상 클라이언트 하나가
metrics 백엔드를 무너뜨릴 수 있다(`observation/GraphQlOperationNameCardinality`).
`GraphqlHttpBoundaryQualificationTest` 는 실제 random-port MVC HTTP 서버 위에서 test-only
SecurityFilterChain 과 CORS allowlist 를 조합해 인증, origin, GraphiQL 비활성화, introspection
@@ -109,10 +138,16 @@ composition root 는 이 leaf 를 채택할 때 인증/인가 및 CORS 정책을
분해 비용은 낮게 유지했다.
어느 패턴이든 "패키지는 경계가 아니다"라는 약점은 기계 검증으로 메웠다 —
`build/GraphQlStableModule`·`GraphQlAdvancedModule` 이 모듈 정체성과 허용 edge 를 값으로 선언하고,
`GraphQlModuleBoundaryTest` 가 **실제 소스 트리를 스캔**해 Stable→Advanced import, core-api 의
프레임워크 import, Stable edge 의 Advanced 참조를 실패시킨다. Gradle 이 해주던 일을 테스트가
한다.
`moduleboundary/GraphQlStableModule`·`GraphQlAdvancedModule` 이 모듈 정체성과 허용 edge 를 값으로
선언하고, `GraphQlModuleBoundaryTest` 가 **실제 소스 트리를 스캔**해 Stable→Advanced import,
CORE 모듈의 프레임워크 import, 선언되지 않은 edge, 미등록 패키지를 실패시킨다. Gradle 이
해주던 일을 테스트가 한다.
스캔은 컴파일된 클래스가 아니라 **소스 텍스트**를 읽는다. 경계가 금지하는 import 는 상수
인라이닝이나 미보존 시그니처로 바이트코드에서 지워지는 경우가 많아서, 바이트코드 스캔은
리뷰어가 읽는 소스가 여전히 경계를 넘는데도 clean 이라고 보고한다. 그리고 스캐너는
**파일을 하나도 못 찾으면 통과가 아니라 실패한다** — 0개 스캔으로 green 이 되는 것이 이
모델이 막으려는 실패 그 자체다.
## ArchUnit/JPA 없이 아키텍처 규칙을 강제한 방법
+196 -9
View File
@@ -10,27 +10,53 @@
// coordinates the BOM does not manage).
description = 'Inbound adapter: GraphQL API (Spring for GraphQL, GraphQL execution platform)'
// The contract suites, the integration fixtures and the in-memory registries are for the people
// verifying an adoption, not for the adoption. Shipped in the production jar they were reachable
// from any adopter's runtime code — an in-memory persisted-operation registry is a perfectly
// working bean until the second instance starts, and a `testContext(String)` mints an authenticated
// actor without a credential. A separate test-fixtures artifact keeps them consumable by the tests
// that want them and out of the jar that runs in production; `verifyGraphQlProductionJar` checks
// the second half rather than trusting it.
apply plugin: 'java-test-fixtures'
apply from: "${rootProject.projectDir}/gradle/strict-qualification-test.gradle"
apply from: "${rootProject.projectDir}/gradle/graphql-platform-conventions.gradle"
dependencies {
implementation project(':shared-contract')
// The fixtures exercise the platform through the same contracts an adopter uses.
testFixturesImplementation project(':shared-contract')
testFixturesImplementation 'org.springframework.boot:spring-boot-starter-graphql'
// Transport-neutral on purpose. The platform binds to Spring for GraphQL's execution and
// interceptor contracts, and to nothing that decides which server runs them: no production file
// imports `org.springframework.web`, `jakarta.servlet` or `org.springframework.http`.
//
// Depending on `spring-boot-starter-web` here put an embedded Tomcat on every adopter's
// runtimeClasspath while the same artifact advertised a REACTIVE_WEBFLUX execution profile —
// a profile that could never have run, because the servlet container arrived with the leaf.
// Choosing the server is the composition root's decision; this leaf states which profile it was
// configured for and refuses to start when the running context disagrees.
implementation 'org.springframework.boot:spring-boot-starter-graphql'
implementation 'org.springframework.boot:spring-boot-starter-web'
// GraphQlPlatformProperties is a @ConfigurationProperties binding, so this leaf owes the
// repository-wide processor parity gate (`verifyConfigurationPropertiesProcessor`) a metadata
// declaration — an adopter configuring spring.graphql.platform.* gets IDE completion and
// validation from the generated metadata rather than from prose.
// declaration — an adopter configuring backend.graphql.* gets IDE completion and validation
// from the generated metadata rather than from prose. (The prefix is `backend.graphql`; this
// comment used to say `spring.graphql.platform.*`, which never existed.)
annotationProcessor 'org.springframework.boot:spring-boot-configuration-processor'
// REACTIVE_WEBFLUX execution profile (design §10). WebFlux is compileOnly on purpose: the
// reactive transport adapter and its event-loop guard compile against Spring's reactive
// transport types, but an adopter that runs the BLOCKING_MVC profile must not inherit a WebFlux
// runtime. Reactor Core itself arrives with spring-graphql, so the reactive contracts stay
// usable in both profiles. spring-webflux is already on the test classpath.
compileOnly 'org.springframework:spring-webflux'
// A raw request body can only be capped before something decodes it, and on a servlet stack the
// only place that exists is a filter. `compileOnly` is what keeps that from contradicting the
// paragraph above: it is the servlet API, not a server, and it stays off runtimeClasspath
// entirely — so the filter class simply never loads for an adopter who is not running servlets.
compileOnly 'jakarta.servlet:jakarta.servlet-api'
// The random-port transport tests need a real servlet server; production does not. Keeping the
// server on the test classpath is what lets the qualification prove the platform works over
// HTTP without shipping that choice to adopters.
testImplementation 'org.springframework.boot:spring-boot-starter-web'
// GraphQlTester (spring-graphql-test, BOM-managed) — the health test assembles the schema +
// controller through a real AnnotatedControllerConfigurer and drives it with an
@@ -41,6 +67,11 @@ 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'
// 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
// is what turns "this tag is bounded" into a number a test can fail on.
testImplementation 'io.micrometer:micrometer-core'
}
registerGraphQlPlatformTestLanes()
@@ -52,3 +83,159 @@ registerStrictQualificationTest(
'dev.caskeleton.adapter.inbound.graphql.GraphqlHttpBoundaryQualificationTest'
],
description: 'Runs exact no-skip GraphQL conditional transport wire evidence.')
// verifyGraphQlProductionJar — the production artifact must carry nothing a test wrote.
//
// Moving the testkit into test fixtures is a source-tree decision, and source-tree decisions drift.
// One `implementation` where a `testFixturesImplementation` belonged, one file created in the wrong
// directory, and the contract suites are back inside the jar an adopter deploys — where an
// in-memory persisted-operation registry looks like a working bean until a second instance starts,
// and where `testContext(String)` hands out an authenticated actor to anyone who calls it.
//
// So the claim is checked against the jar rather than against the layout that is supposed to
// produce it. Entry names and class names only: this reads the archive index, never the bytecode.
tasks.register('verifyGraphQlProductionJar') {
group = 'verification'
description = 'Fails when the GraphQL production jar contains testkit, fixture or in-memory-only types.'
dependsOn tasks.named('jar')
def jarFile = tasks.named('jar').flatMap { it.archiveFile }
inputs.file(jarFile)
outputs.upToDateWhen { true }
doLast {
Map<String, String> forbidden = [
'/testkit/' : 'contract suites and integration fixtures belong to test fixtures',
'InMemory' : 'an in-memory implementation is a development stand-in, not a shipped default',
'ForTests' : 'a for-tests factory in the production jar is reachable from production code',
'TestContext' : 'a credential-free authenticated context must not ship',
'Fixture' : 'fixtures belong to test fixtures',
]
List<String> violations = []
new java.util.zip.ZipFile(jarFile.get().asFile).withCloseable { archive ->
archive.entries().each { entry ->
if (entry.directory || !entry.name.endsWith('.class')) {
return
}
forbidden.each { marker, reason ->
if (entry.name.contains(marker)) {
violations << "${entry.name}: ${reason}"
}
}
}
}
if (!violations.isEmpty()) {
throw new GradleException(
"The GraphQL production jar contains non-production types:\n " +
violations.sort().join('\n ') +
"\nMove them to src/testFixtures/java, or declare them with " +
"testFixturesImplementation."
)
}
}
}
tasks.named('check') {
dependsOn tasks.named('verifyGraphQlProductionJar')
}
// verifyGraphQlApiSurface — every public type this leaf exposes is a committed decision.
//
// One jar, 40-odd packages, and a public type in any of them is reachable from every adopter's
// code. Package boundaries express the intended structure but enforce nothing across a single
// artifact: `public` inside a jar means public to everybody who has the jar. The consequence is not
// hypothetical — a package that went missing from a commit was still compiled against by seven
// production files, and nothing in the build had an opinion about what the surface was supposed to
// be.
//
// A snapshot does not shrink the surface. It makes each addition visible in review, which is the
// prerequisite for shrinking it: the `api` and `spi` packages are the surface an adopter is meant
// to use, and everything else in this file is a candidate for becoming internal when the leaf is
// split into capability artifacts. Until then the number cannot grow by accident.
def graphQlApiSurfaceFile = rootProject.file('../docs/architecture/graphql-api-surface.txt')
Closure<String> renderGraphQlApiSurface = {
def sourceRoot = file('src/main/java')
def typePattern = ~/(?m)^public\s+(?:final\s+|abstract\s+|sealed\s+|non-sealed\s+)*(class|interface|enum|record|@interface)\s+(\w+)/
def packagePattern = ~/(?m)^package\s+([\w.]+)\s*;/
List<String> types = []
sourceRoot.eachFileRecurse { candidate ->
if (!candidate.isFile() || !candidate.name.endsWith('.java')) {
return
}
String text = candidate.getText('UTF-8')
def packageMatcher = packagePattern.matcher(text)
if (!packageMatcher.find()) {
return
}
String packageName = packageMatcher.group(1)
def typeMatcher = typePattern.matcher(text)
while (typeMatcher.find()) {
types << "${packageName}.${typeMatcher.group(2)}".toString()
}
}
types = types.unique().toSorted()
String header =
"# GraphQL leaf public API surface — every public top-level type in src/main/java.\n" +
"# A public type in a single-jar leaf is reachable from every adopter's code, so\n" +
"# additions are reviewed rather than discovered. `api` and `spi` are the intended\n" +
"# external surface; the rest are candidates to become internal when this leaf is\n" +
"# split into capability artifacts.\n" +
"# Update only after review with:\n" +
"# ./gradlew :adapter:inbound:graphql:updateGraphQlApiSurface -PapproveGraphQlApiSurfaceChange\n" +
"# types: ${types.size()}\n"
header + (types.isEmpty() ? '' : types.join('\n') + '\n')
}
tasks.register('verifyGraphQlApiSurface') {
group = 'verification'
description = 'Fails without mutation when the committed GraphQL public API surface drifts.'
doLast {
if (project.hasProperty('approveGraphQlApiSurfaceChange')) {
throw new GradleException(
'verifyGraphQlApiSurface is read-only; use updateGraphQlApiSurface to record an ' +
'approved change.')
}
String rendered = renderGraphQlApiSurface()
if (!graphQlApiSurfaceFile.isFile()) {
throw new GradleException(
"verifyGraphQlApiSurface: missing committed baseline ${graphQlApiSurfaceFile}")
}
String committed = graphQlApiSurfaceFile.getText('UTF-8')
if (committed != rendered) {
List<String> committedTypes = committed.readLines().findAll { !it.startsWith('#') }
List<String> renderedTypes = rendered.readLines().findAll { !it.startsWith('#') }
List<String> added = (renderedTypes - committedTypes).toSorted()
List<String> removed = (committedTypes - renderedTypes).toSorted()
throw new GradleException(
"verifyGraphQlApiSurface: the public API surface changed.\n" +
(added.isEmpty() ? '' : " added:\n " + added.join('\n ') + '\n') +
(removed.isEmpty() ? '' : " removed:\n " + removed.join('\n ') + '\n') +
"Review the change, then record it with:\n" +
" ./gradlew :adapter:inbound:graphql:updateGraphQlApiSurface " +
"-PapproveGraphQlApiSurfaceChange")
}
logger.lifecycle('verifyGraphQlApiSurface: OK — the committed public API surface is unchanged.')
}
}
tasks.register('updateGraphQlApiSurface') {
group = 'verification'
description = 'Rewrites the committed GraphQL public API surface baseline after review.'
doLast {
if (!project.hasProperty('approveGraphQlApiSurfaceChange')) {
throw new GradleException(
'updateGraphQlApiSurface requires -PapproveGraphQlApiSurfaceChange: growing the ' +
'public surface is a review decision, not a build step.')
}
graphQlApiSurfaceFile.parentFile.mkdirs()
graphQlApiSurfaceFile.setText(renderGraphQlApiSurface(), 'UTF-8')
logger.lifecycle("updateGraphQlApiSurface: wrote ${graphQlApiSurfaceFile}")
}
}
tasks.named('check') {
dependsOn tasks.named('verifyGraphQlApiSurface')
}
+80 -76
View File
@@ -1,37 +1,37 @@
# 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.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,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
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
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
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,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.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,testAnnotationProcessor,testFixturesAnnotationProcessor
com.google.errorprone:error_prone_annotations:2.38.0=compileClasspath,testCompileClasspath,testFixturesCompileClasspath
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,testAnnotationProcessor,testFixturesAnnotationProcessor
com.google.errorprone:error_prone_check_api:2.49.0=annotationProcessor,testAnnotationProcessor,testFixturesAnnotationProcessor
com.google.errorprone:error_prone_core:2.49.0=annotationProcessor,testAnnotationProcessor,testFixturesAnnotationProcessor
com.google.googlejavaformat:google-java-format:1.35.0=annotationProcessor,testAnnotationProcessor,testFixturesAnnotationProcessor
com.google.guava:failureaccess:1.0.3=annotationProcessor,checkstyle,testAnnotationProcessor,testFixturesAnnotationProcessor
com.google.guava:guava:33.5.0-jre=annotationProcessor,testAnnotationProcessor,testFixturesAnnotationProcessor
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.graphql-java:graphql-java:25.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
com.graphql-java:java-dataloader:6.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
com.google.guava:listenablefuture:9999.0-empty-to-avoid-conflict-with-guava=annotationProcessor,checkstyle,testAnnotationProcessor,testFixturesAnnotationProcessor
com.google.j2objc:j2objc-annotations:3.1=annotationProcessor,checkstyle,testAnnotationProcessor,testFixturesAnnotationProcessor
com.google.protobuf:protobuf-java:4.33.2=annotationProcessor,testAnnotationProcessor,testFixturesAnnotationProcessor
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.puppycrawl.tools:checkstyle:13.5.0=checkstyle
@@ -39,18 +39,20 @@ com.vaadin.external.google:android-json:0.0.20131108.vaadin1=testCompileClasspat
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.5=compileClasspath,runtimeClasspath,testCompileClasspath,testFixturesCompileClasspath,testFixturesRuntimeClasspath,testRuntimeClasspath
info.picocli:picocli:4.7.7=checkstyle
io.github.eisop:dataflow-errorprone:3.41.0-eisop1=annotationProcessor,testAnnotationProcessor
io.github.java-diff-utils:java-diff-utils:4.12=annotationProcessor,testAnnotationProcessor
io.micrometer:context-propagation:1.2.0=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.projectreactor:reactor-core:3.8.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
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
jakarta.activation:jakarta.activation-api:2.1.4=testCompileClasspath,testRuntimeClasspath
jakarta.annotation:jakarta.annotation-api:3.0.0=compileClasspath,runtimeClasspath,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
javax.inject:javax.inject:1=annotationProcessor,testAnnotationProcessor
javax.inject:javax.inject:1=annotationProcessor,testAnnotationProcessor,testFixturesAnnotationProcessor
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
@@ -64,16 +66,16 @@ org.apache.commons:commons-text:1.15.0=spotbugs
org.apache.commons:commons-text:1.3=checkstyle
org.apache.httpcomponents:httpclient:4.5.13=checkstyle
org.apache.httpcomponents:httpcore:4.4.16=checkstyle
org.apache.logging.log4j:log4j-api:2.25.2=compileClasspath,runtimeClasspath,spotbugs,testCompileClasspath,testRuntimeClasspath
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,testRuntimeClasspath
org.apache.logging.log4j:log4j-to-slf4j:2.25.2=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=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.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.xbean:xbean-reflect:3.7=checkstyle
org.apiguardian:apiguardian-api:1.1.2=testCompileClasspath
org.assertj:assertj-core:3.27.6=testCompileClasspath,testRuntimeClasspath
@@ -84,8 +86,9 @@ org.codehaus.plexus:plexus-container-default:2.1.0=checkstyle
org.codehaus.plexus:plexus-utils:3.3.0=checkstyle
org.dom4j:dom4j:2.2.0=spotbugs
org.hamcrest:hamcrest:3.0=testCompileClasspath,testRuntimeClasspath
org.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,testRuntimeClasspath
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
@@ -95,79 +98,80 @@ 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.junit:junit-bom:6.1.0=spotbugs
org.latencyutils:LatencyUtils:2.0.3=testRuntimeClasspath
org.mockito:mockito-core:5.20.0=mockitoAgent,testCompileClasspath,testRuntimeClasspath
org.mockito:mockito-junit-jupiter:5.20.0=testCompileClasspath,testRuntimeClasspath
org.objenesis:objenesis:3.3=testRuntimeClasspath
org.opentest4j:opentest4j:1.3.0=testCompileClasspath,testRuntimeClasspath
org.osgi:org.osgi.annotation.bundle:2.0.0=compileClasspath,testCompileClasspath
org.osgi:org.osgi.annotation.versioning:1.1.2=compileClasspath,testCompileClasspath
org.osgi:org.osgi.resource:1.0.0=compileClasspath,testCompileClasspath
org.osgi:org.osgi.service.serviceloader:1.0.0=compileClasspath,testCompileClasspath
org.osgi:org.osgi.annotation.bundle:2.0.0=compileClasspath,testCompileClasspath,testFixturesCompileClasspath
org.osgi:org.osgi.annotation.versioning:1.1.2=compileClasspath,testCompileClasspath,testFixturesCompileClasspath
org.osgi:org.osgi.resource:1.0.0=compileClasspath,testCompileClasspath,testFixturesCompileClasspath
org.osgi:org.osgi.service.serviceloader:1.0.0=compileClasspath,testCompileClasspath,testFixturesCompileClasspath
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-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.pcollections:pcollections:4.0.1=annotationProcessor,testAnnotationProcessor,testFixturesAnnotationProcessor
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,testRuntimeClasspath
org.slf4j:slf4j-api:2.0.17=compileClasspath,runtimeClasspath,spotbugs,spotbugsSlf4j,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,testRuntimeClasspath
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,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=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-jackson:4.0.0=compileClasspath,runtimeClasspath,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=compileClasspath,runtimeClasspath,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,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,testRuntimeClasspath
org.springframework.boot:spring-boot-starter-logging:4.0.0=compileClasspath,runtimeClasspath,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=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-starter-tomcat: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-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,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=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-web-server:4.0.0=compileClasspath,runtimeClasspath,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=compileClasspath,runtimeClasspath,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,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,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,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-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=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework:spring-webflux:7.0.1=compileClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework:spring-webmvc:7.0.1=compileClasspath,runtimeClasspath,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.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.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
empty=
@@ -1,72 +0,0 @@
package dev.caskeleton.adapter.inbound.graphql;
import dev.caskeleton.shared.error.ApiErrorCarrier;
import dev.caskeleton.shared.error.ApiErrorCode;
import dev.caskeleton.shared.error.Category;
import graphql.GraphQLError;
import graphql.GraphqlErrorBuilder;
import graphql.schema.DataFetchingEnvironment;
import java.util.Map;
import org.springframework.graphql.execution.DataFetcherExceptionResolverAdapter;
import org.springframework.graphql.execution.ErrorType;
import org.springframework.stereotype.Component;
/**
* Centralises the GraphQL error contract: a data fetcher just throws, and this resolver translates
* any throwable carrying a stable {@link ApiErrorCode} (via the shared-contract {@link
* ApiErrorCarrier} hook) into a {@link GraphQLError} with an {@link ErrorType} classification plus
* machine-readable {@code code} / {@code category} extensions — the GraphQL sibling of the web
* adapter's {@code GlobalExceptionHandler} and the gRPC adapter's {@code
* GrpcExceptionHandlingInterceptor}.
*
* <p>The {@link ApiErrorCarrier} hook is implemented by the shared-contract {@code
* PersistenceFailureException} / {@code DependencyFailureException} (an error surfacing from an
* outbound adapter) and by feature throwables (which carry a mapped domain {@code ApiErrorCode}),
* so a single {@code instanceof ApiErrorCarrier} branch covers them all. A non-carrier throwable
* returns {@code null}: Spring for GraphQL then merges the other {@link
* org.springframework.graphql.execution.DataFetcherExceptionResolver} beans (e.g. a feature's own
* resolver mapping its domain exceptions) and finally its default handling. Only the stable {@link
* ApiErrorCode#code()} reaches the client — never the raw exception message, which may carry a
* SQLState or upstream detail.
*/
@Component
public class GraphqlExceptionResolver extends DataFetcherExceptionResolverAdapter {
@Override
protected GraphQLError resolveToSingleError(Throwable ex, DataFetchingEnvironment env) {
if (!(ex instanceof ApiErrorCarrier carrier)) {
return null; // fall through to other resolvers / Spring's default handling
}
ApiErrorCode code = carrier.errorCode();
var builder =
GraphqlErrorBuilder.newError()
.errorType(classify(code.category()))
.message(code.code())
.extensions(Map.of("code", code.code(), "category", code.category().name()));
// A real GraphQL execution always supplies the environment; a unit test may pass null. Only
// attach the field path/location when they are present.
if (env != null) {
builder.path(env.getExecutionStepInfo().getPath());
if (env.getField() != null) {
builder.location(env.getField().getSourceLocation());
}
}
return builder.build();
}
/**
* Maps the 10-value operational {@link Category} SSOT to a GraphQL {@link ErrorType} (design
* Error-Mapping table). The switch is exhaustive, so a new {@link Category} fails to compile
* until a mapping decision is made.
*/
private static ErrorType classify(Category category) {
return switch (category) {
case VALIDATION, CONFLICT, RATE_LIMIT -> ErrorType.BAD_REQUEST;
case AUTH -> ErrorType.UNAUTHORIZED;
case AUTHZ -> ErrorType.FORBIDDEN;
case NOT_FOUND -> ErrorType.NOT_FOUND;
case TRANSIENT_DEPENDENCY, PERMANENT_DEPENDENCY, DATA_INTEGRITY, INTERNAL ->
ErrorType.INTERNAL_ERROR;
};
}
}
@@ -0,0 +1,37 @@
package dev.caskeleton.adapter.inbound.graphql.advanced.admin;
import java.util.Objects;
/**
* A verified administrator, as the transport established them.
*
* <p>The admin service used to take the operator as a bare {@code String} and check it against an
* allowlist. A string is not evidence: any caller that could reach the service could name any
* operator on the list, so the allowlist described who <em>may</em> administer the registry while
* proving nothing about who actually did. The audit trail then recorded that name as fact.
*
* <p>Only the transport can construct this, having verified the credential, and it records whether
* the credential came from the request path. That check used to exist as a method nobody called.
*
* @param operator the verified operator reference
* @param applicationCredential whether the credential is one the request path also holds
*/
public record GraphQlAdminPrincipal(String operator, boolean applicationCredential) {
public GraphQlAdminPrincipal {
Objects.requireNonNull(operator, "operator is required");
if (operator.isBlank()) {
throw new IllegalArgumentException("operator is required");
}
}
/** A principal established from a dedicated operations credential. */
public static GraphQlAdminPrincipal operations(String operator) {
return new GraphQlAdminPrincipal(operator, false);
}
/** A principal established from a credential the request path also holds. */
public static GraphQlAdminPrincipal fromApplicationCredential(String operator) {
return new GraphQlAdminPrincipal(operator, true);
}
}
@@ -26,24 +26,20 @@ public final class GraphQlPersistedOperationAdminAuthorization {
}
/**
* Requires the operator to be an administrator.
* Requires a verified administrator holding an operations credential.
*
* @throws GraphQlPersistedOperationAdminDeniedException when they are not
*/
public void requireAdministrator(String operator) {
if (operator == null || !administrators.contains(operator)) {
throw new GraphQlPersistedOperationAdminDeniedException();
}
}
/**
* Refuses an application credential outright.
* <p>Both halves are checked here now. The credential-kind refusal used to be a separate public
* method that no caller invoked, so an application credential naming an allowlisted operator
* passed — which is the compromise this class exists to prevent, arriving through the door it
* documented.
*
* @throws GraphQlPersistedOperationAdminDeniedException when the caller came from the request
* path
* @throws GraphQlPersistedOperationAdminDeniedException when the principal is absent, not an
* administrator, or authenticated with a credential the request path also holds
*/
public void rejectApplicationCredential(boolean applicationCredential) {
if (applicationCredential) {
public void requireAdministrator(GraphQlAdminPrincipal principal) {
if (principal == null
|| principal.applicationCredential()
|| !administrators.contains(principal.operator())) {
throw new GraphQlPersistedOperationAdminDeniedException();
}
}
@@ -0,0 +1,47 @@
package dev.caskeleton.adapter.inbound.graphql.advanced.admin;
import dev.caskeleton.adapter.inbound.graphql.advanced.persisted.GraphQlPersistedOperation;
import dev.caskeleton.adapter.inbound.graphql.advanced.persisted.GraphQlPersistedOperationId;
import dev.caskeleton.adapter.inbound.graphql.advanced.persisted.GraphQlPersistedOperationTransition;
/**
* Applies a registry change and records it, as one durable unit.
*
* <p>The two used to be separate steps: the registry was mutated, then an entry was appended to an
* {@code ArrayList} field. A crash between them left a change nobody could account for, a failure
* in the append left a change with no record, and the list itself was not thread-safe, so two
* concurrent administrators could lose an entry outright. An audit trail with any of those
* properties is worse than none, because it is trusted.
*
* <p>A port rather than an implementation: transactional guarantees over both the registry and the
* trail need a store, and which store provides them is a deployment decision.
*/
public interface GraphQlPersistedOperationAdminPort {
/**
* Registers an operation and records it atomically.
*
* @return the stored operation
* @throws dev.caskeleton.adapter.inbound.graphql.advanced.persisted
* .GraphQlPersistedOperationConflictException when the id already holds a different document
*/
GraphQlPersistedOperation register(
GraphQlPersistedOperation operation, GraphQlPersistedOperationAudit audit);
/**
* Applies a transition and records it atomically.
*
* @return the operation as it now stands
* @throws dev.caskeleton.adapter.inbound.graphql.advanced.persisted
* .GraphQlPersistedOperationNotFoundException when the operation does not exist
* @throws dev.caskeleton.adapter.inbound.graphql.advanced.persisted
* .GraphQlPersistedOperationConflictException when the transition is not permitted
*/
GraphQlPersistedOperation apply(
GraphQlPersistedOperationId operationId,
GraphQlPersistedOperationTransition transition,
GraphQlPersistedOperationAudit audit);
/** The recorded trail, oldest first. */
java.util.List<GraphQlPersistedOperationAudit> auditTrail();
}
@@ -2,53 +2,59 @@ package dev.caskeleton.adapter.inbound.graphql.advanced.admin;
import dev.caskeleton.adapter.inbound.graphql.advanced.persisted.GraphQlPersistedOperation;
import dev.caskeleton.adapter.inbound.graphql.advanced.persisted.GraphQlPersistedOperationId;
import dev.caskeleton.adapter.inbound.graphql.advanced.persisted.GraphQlPersistedOperationRegistry;
import dev.caskeleton.adapter.inbound.graphql.advanced.persisted.GraphQlPersistedOperationStatus;
import dev.caskeleton.adapter.inbound.graphql.advanced.persisted.GraphQlPersistedOperationTransition;
import java.time.Clock;
import java.util.ArrayList;
import java.util.List;
import java.util.Objects;
/**
* The G4 operations plane for approved operations (Advanced plan Task 4).
*
* <p>Every change is authorized against the administrator set and recorded in the audit trail,
* because a registry change silently alters what the whole platform will execute. Blocking takes
* effect immediately; removal has to pass the usage gate first.
* <p>Every change is authorized against a verified principal and recorded in the same durable unit
* as the change itself, because a registry change silently alters what the whole platform will
* execute. Blocking takes effect immediately; retiring has to pass the usage gate first.
*
* <p>Every command returns the stored operation. A command that cannot be applied throws, so the
* audit trail records changes that happened rather than changes that were attempted — the previous
* service wrote {@code ABSENT -> BLOCKED} for operations that did not exist, which is precisely the
* entry an operator would trust during an incident.
*/
public final class GraphQlPersistedOperationAdminService {
private final GraphQlPersistedOperationRegistry registry;
private final GraphQlPersistedOperationAdminPort adminPort;
private final GraphQlPersistedOperationAdminAuthorization authorization;
private final GraphQlPersistedOperationRemovalGate removalGate;
private final Clock clock;
private final List<GraphQlPersistedOperationAudit> auditTrail = new ArrayList<>();
/**
* Creates the service.
*
* @param registry the approved operation store
* @param authorization who may administer it
* @param removalGate the usage gate protecting removals
* @param adminPort applies registry changes and their audit entries atomically
* @param authorization who may administer the registry
* @param removalGate the usage gate protecting retirement
* @param clock clock used for audit timestamps and the quiet period
*/
public GraphQlPersistedOperationAdminService(
GraphQlPersistedOperationRegistry registry,
GraphQlPersistedOperationAdminPort adminPort,
GraphQlPersistedOperationAdminAuthorization authorization,
GraphQlPersistedOperationRemovalGate removalGate,
Clock clock) {
this.registry = Objects.requireNonNull(registry);
this.adminPort = Objects.requireNonNull(adminPort);
this.authorization = Objects.requireNonNull(authorization);
this.removalGate = Objects.requireNonNull(removalGate);
this.clock = Objects.requireNonNull(clock);
}
/** Registers a new approved operation. */
public void register(
GraphQlPersistedOperation operation, String operator, String reason, String traceId) {
authorization.requireAdministrator(operator);
registry.register(operation);
audit(operation.id().value(), operator, reason, "ABSENT", operation.status().name(), traceId);
public GraphQlPersistedOperation register(
GraphQlPersistedOperation operation,
GraphQlAdminPrincipal principal,
String reason,
String traceId) {
authorization.requireAdministrator(principal);
return adminPort.register(
operation,
audit(operation.id(), principal, reason, "ABSENT", operation.status().name(), traceId));
}
/**
@@ -56,77 +62,100 @@ public final class GraphQlPersistedOperationAdminService {
*
* <p>Takes effect on the next request; a cached parse does not keep it executable.
*/
public void block(GraphQlPersistedOperationBlockCommand command) {
authorization.requireAdministrator(command.operator());
String before =
registry
.find(command.operationId())
.map(operation -> operation.status().name())
.orElse("ABSENT");
registry.updateStatus(command.operationId(), GraphQlPersistedOperationStatus.BLOCKED);
audit(
command.operationId().value(),
command.operator(),
public GraphQlPersistedOperation block(
GraphQlPersistedOperationBlockCommand command, GraphQlAdminPrincipal principal) {
return transition(
command.operationId(),
GraphQlPersistedOperationTransition.BLOCK,
principal,
command.reason(),
before,
GraphQlPersistedOperationStatus.BLOCKED.name(),
command.traceId());
}
/**
* Reverses a block.
*
* <p>Its own command, so leaving the emergency state is an explicit decision with its own audit
* entry. It used to be reachable by marking a blocked operation deprecated, which reads like a
* documentation change and made it executable again.
*/
public GraphQlPersistedOperation unblock(
GraphQlPersistedOperationId operationId,
GraphQlAdminPrincipal principal,
String reason,
String traceId) {
return transition(
operationId, GraphQlPersistedOperationTransition.UNBLOCK, principal, reason, traceId);
}
/** Marks an operation deprecated, which keeps it executable while clients migrate. */
public void deprecate(
GraphQlPersistedOperationId operationId, String operator, String reason, String traceId) {
authorization.requireAdministrator(operator);
String before =
registry.find(operationId).map(operation -> operation.status().name()).orElse("ABSENT");
registry.updateStatus(operationId, GraphQlPersistedOperationStatus.DEPRECATED);
audit(
operationId.value(),
operator,
reason,
before,
GraphQlPersistedOperationStatus.DEPRECATED.name(),
traceId);
public GraphQlPersistedOperation deprecate(
GraphQlPersistedOperationId operationId,
GraphQlAdminPrincipal principal,
String reason,
String traceId) {
return transition(
operationId, GraphQlPersistedOperationTransition.DEPRECATE, principal, reason, traceId);
}
/**
* Removes an operation once usage evidence permits it.
* Retires an operation once usage evidence permits it.
*
* <p>Named for what it does. It was called {@code remove}, and it blocked rather than deleted —
* an operator reading the method name would have believed the document was gone.
*
* @throws GraphQlPersistedOperationRemovalRejectedException when it was used within the quiet
* period
*/
public void remove(
public GraphQlPersistedOperation retireAndBlock(
GraphQlPersistedOperationId operationId,
GraphQlPersistedOperationUsage usage,
String operator,
GraphQlAdminPrincipal principal,
String reason,
String traceId) {
authorization.requireAdministrator(operator);
authorization.requireAdministrator(principal);
removalGate.verify(usage, clock.instant());
registry.updateStatus(operationId, GraphQlPersistedOperationStatus.BLOCKED);
audit(
operationId.value(),
operator,
reason,
"REMOVAL_APPROVED",
GraphQlPersistedOperationStatus.BLOCKED.name(),
traceId);
return transition(
operationId, GraphQlPersistedOperationTransition.RETIRE, principal, reason, traceId);
}
/** The audit trail, in order. */
public List<GraphQlPersistedOperationAudit> auditTrail() {
return List.copyOf(auditTrail);
return adminPort.auditTrail();
}
private void audit(
String operationId,
String operator,
private GraphQlPersistedOperation transition(
GraphQlPersistedOperationId operationId,
GraphQlPersistedOperationTransition transition,
GraphQlAdminPrincipal principal,
String reason,
String traceId) {
authorization.requireAdministrator(principal);
// The audit entry names the state the operation is actually leaving. The port applies both
// together, so a rejected transition leaves no entry at all.
GraphQlPersistedOperation updated =
adminPort.apply(
operationId,
transition,
audit(
operationId,
principal,
reason,
transition.name(),
transition.target().name(),
traceId));
return updated;
}
private GraphQlPersistedOperationAudit audit(
GraphQlPersistedOperationId operationId,
GraphQlAdminPrincipal principal,
String reason,
String before,
String after,
String traceId) {
auditTrail.add(
new GraphQlPersistedOperationAudit(
operationId, operator, reason, before, after, clock.instant(), traceId));
return new GraphQlPersistedOperationAudit(
operationId.value(), principal.operator(), reason, before, after, clock.instant(), traceId);
}
}
@@ -26,9 +26,6 @@ public enum GraphQlAdvancedCapability {
/** Client and transport DTO code generation. */
CODE_GENERATION(GraphQlAdvancedCapabilityGrade.ADVANCED),
/** Allowlisted Spring Data repository exposure. */
SPRING_DATA_COMPAT(GraphQlAdvancedCapabilityGrade.ADVANCED),
/** GraphQL Java 25 chained DataLoader dispatch. */
DATALOADER_CHAINING(GraphQlAdvancedCapabilityGrade.ADVANCED),
@@ -1,6 +1,8 @@
package dev.caskeleton.adapter.inbound.graphql.advanced.bootstrap;
import dev.caskeleton.adapter.inbound.graphql.build.GraphQlBuildModel;
import dev.caskeleton.adapter.inbound.graphql.moduleboundary.GraphQlAdvancedModule;
import dev.caskeleton.adapter.inbound.graphql.moduleboundary.GraphQlModuleBoundary;
import dev.caskeleton.adapter.inbound.graphql.moduleboundary.GraphQlStableModule;
import java.util.ArrayList;
import java.util.List;
import java.util.Set;
@@ -23,9 +25,9 @@ public final class GraphQlAdvancedDependencyRules {
* @throws IllegalStateException naming the offending edges
*/
public static void verifyStableDoesNotDependOnAdvanced() {
Set<String> advanced = GraphQlBuildModel.advancedModules();
Set<String> advanced = GraphQlAdvancedModule.moduleIds();
List<String> violations = new ArrayList<>();
GraphQlBuildModel.stableDependencyEdges()
GraphQlStableModule.dependencyEdges()
.forEach(
(module, dependencies) ->
dependencies.stream()
@@ -40,7 +42,7 @@ public final class GraphQlAdvancedDependencyRules {
/** Whether a package belongs to an Advanced capability. */
public static boolean advancedPackage(String packageName) {
return packageName != null
&& packageName.startsWith(GraphQlBuildModel.PACKAGE_ROOT + ".advanced");
&& packageName.startsWith(GraphQlModuleBoundary.PACKAGE_ROOT + ".advanced");
}
/** Every capability that must be flagged before it can run. */
@@ -1,13 +1,17 @@
package dev.caskeleton.adapter.inbound.graphql.advanced.codegen;
import dev.caskeleton.adapter.inbound.graphql.compat.GraphQlSchemaComparator;
import java.util.Objects;
/**
* Generates client request and response types, validating operations against the schema first.
* Plans client code generation and validates the operations it would generate from.
*
* <p>Compile-time validation is most of the value: an operation that no longer matches the schema
* becomes a build failure in the client's repository instead of a runtime error in production.
* <p>Named a plan because that is what it produces: which kinds may be generated and into which
* package. No source writer and no Gradle task exist behind it, and calling it a generator invited
* the reasonable assumption that running it emitted files.
*
* <p>Validation is the part that does real work, and it is most of the value: an operation that no
* longer matches the schema becomes a build failure in the client's repository instead of a runtime
* error in production.
*/
public final class GraphQlClientOperationGenerator {
@@ -27,18 +31,26 @@ public final class GraphQlClientOperationGenerator {
*
* @param sdl the schema
* @param operationDocument the operation to validate
* @throws GraphQlCodegenBoundaryException when either is missing
* @throws GraphQlCodegenBoundaryException when the document does not parse or does not match
*/
public void validateOperation(String sdl, String operationDocument) {
if (sdl == null || sdl.isBlank() || operationDocument == null || operationDocument.isBlank()) {
throw new GraphQlCodegenBoundaryException("CLIENT_REQUEST");
}
// Parsing the schema is what makes generation fail on an invalid schema rather than emitting
// sources against one.
GraphQlSchemaComparator.compare(sdl, sdl);
validateOperation(sdl, operationDocument, null);
}
/** The kinds this generator produces. */
/**
* Validates one named operation from a document.
*
* @param sdl the schema
* @param operationDocument the operation document
* @param operationName which operation, when the document declares several
* @throws GraphQlCodegenBoundaryException when the document does not parse, does not match the
* schema, or names no such operation
*/
public void validateOperation(String sdl, String operationDocument, String operationName) {
GraphQlOperationValidator.validate(sdl, operationDocument, operationName);
}
/** The kinds this plan covers. */
public java.util.Set<String> generatedTypes() {
profile.generatedTypes().forEach(GraphQlGeneratedSourceBoundary.standard()::requireAllowed);
return profile.generatedTypes();
@@ -0,0 +1,111 @@
package dev.caskeleton.adapter.inbound.graphql.advanced.codegen;
import graphql.language.Definition;
import graphql.language.Document;
import graphql.language.OperationDefinition;
import graphql.parser.InvalidSyntaxException;
import graphql.parser.Parser;
import graphql.schema.GraphQLSchema;
import graphql.schema.idl.RuntimeWiring;
import graphql.schema.idl.SchemaGenerator;
import graphql.schema.idl.SchemaParser;
import graphql.schema.idl.TypeDefinitionRegistry;
import graphql.schema.idl.errors.SchemaProblem;
import graphql.validation.ValidationError;
import graphql.validation.Validator;
import java.util.ArrayList;
import java.util.List;
import java.util.Locale;
/**
* Validates a client operation against the schema it will be compiled for.
*
* <p>The previous check confirmed both strings were non-blank and then compared the schema with
* itself, which is true of every schema. The operation document was never read, so a document with
* invalid syntax, an unknown field, or an argument that does not exist passed validation and became
* generated client code that fails at runtime — in the client's repository, against a schema that
* had already changed.
*
* <p>The schema is compiled to an executable {@link GraphQLSchema} because that is what the
* validator needs: field and argument existence, type compatibility and variable usage are
* questions about types, and a parsed SDL registry alone cannot answer them.
*/
public final class GraphQlOperationValidator {
private GraphQlOperationValidator() {}
/**
* Validates one operation document against a schema.
*
* @param sdl the schema, as SDL
* @param operationDocument the client operation
* @param operationName the operation to validate when the document declares several, or {@code
* null} when it declares one
* @throws GraphQlCodegenBoundaryException when either input is missing, the schema does not
* compile, the document does not parse, the document does not match the schema, or the
* operation to validate is ambiguous
*/
public static void validate(String sdl, String operationDocument, String operationName) {
if (sdl == null || sdl.isBlank() || operationDocument == null || operationDocument.isBlank()) {
throw new GraphQlCodegenBoundaryException("CLIENT_REQUEST");
}
GraphQLSchema schema = compile(sdl);
Document document = parse(operationDocument);
requireUnambiguousOperation(document, operationName);
List<ValidationError> errors = new Validator().validateDocument(schema, document, Locale.ROOT);
if (!errors.isEmpty()) {
List<String> messages = new ArrayList<>();
errors.forEach(error -> messages.add(error.getMessage()));
throw new GraphQlCodegenBoundaryException("CLIENT_REQUEST: " + String.join("; ", messages));
}
}
private static GraphQLSchema compile(String sdl) {
try {
TypeDefinitionRegistry registry = new SchemaParser().parse(sdl);
return new SchemaGenerator().makeExecutableSchema(registry, RuntimeWiring.MOCKED_WIRING);
} catch (SchemaProblem | InvalidSyntaxException invalid) {
throw new GraphQlCodegenBoundaryException("CLIENT_REQUEST: schema does not compile");
}
}
private static Document parse(String operationDocument) {
try {
return Parser.parse(operationDocument);
} catch (InvalidSyntaxException invalid) {
throw new GraphQlCodegenBoundaryException("CLIENT_REQUEST: operation does not parse");
}
}
/**
* Refuses a document whose operation cannot be identified.
*
* <p>Generating from a multi-operation document without being told which one means picking by
* position, and the generated client then changes meaning when someone reorders the file.
*/
private static void requireUnambiguousOperation(Document document, String operationName) {
List<OperationDefinition> operations = new ArrayList<>();
for (Definition<?> definition : document.getDefinitions()) {
if (definition instanceof OperationDefinition operation) {
operations.add(operation);
}
}
if (operations.isEmpty()) {
throw new GraphQlCodegenBoundaryException("CLIENT_REQUEST: document declares no operation");
}
if (operationName == null || operationName.isBlank()) {
if (operations.size() > 1) {
throw new GraphQlCodegenBoundaryException(
"CLIENT_REQUEST: document declares " + operations.size() + " operations");
}
return;
}
boolean found = operations.stream().anyMatch(op -> operationName.equals(op.getName()));
if (!found) {
throw new GraphQlCodegenBoundaryException(
"CLIENT_REQUEST: no operation named " + operationName);
}
}
}
@@ -1,39 +0,0 @@
package dev.caskeleton.adapter.inbound.graphql.advanced.compat;
import java.util.Set;
/**
* The repositories permitted to back GraphQL fields.
*
* <p>Empty by default. Spring Data's automatic exposure is convenient and turns filter, sort and
* pagination semantics into public API the moment it is switched on — the allowlist is what makes
* each of those a decision.
*/
public final class GraphQlRepositoryAllowlist {
private final Set<String> repositoryNames;
private GraphQlRepositoryAllowlist(Set<String> repositoryNames) {
this.repositoryNames = Set.copyOf(repositoryNames);
}
/** Nothing exposed. */
public static GraphQlRepositoryAllowlist empty() {
return new GraphQlRepositoryAllowlist(Set.of());
}
/** The named repositories exposed. */
public static GraphQlRepositoryAllowlist of(String... repositoryNames) {
return new GraphQlRepositoryAllowlist(Set.of(repositoryNames));
}
/** Whether a repository is allowlisted. */
public boolean contains(String repositoryName) {
return repositoryNames.contains(repositoryName);
}
/** The allowlisted repositories. */
public Set<String> repositoryNames() {
return repositoryNames;
}
}
@@ -1,40 +0,0 @@
package dev.caskeleton.adapter.inbound.graphql.advanced.compat;
import java.util.Set;
import java.util.TreeSet;
/**
* Which filter and sort arguments an exposed repository accepts.
*
* <p>Enumerated, because automatic exposure turns GraphQL arguments into Querydsl predicates: an
* unlisted argument becomes a query nobody designed, against a column that may have no index and
* may not be meant to be filterable at all.
*
* @param allowedFilterFields fields that may be filtered on
* @param allowedSortFields fields that may be sorted by
*/
public record GraphQlRepositoryArgumentPolicy(
Set<String> allowedFilterFields, Set<String> allowedSortFields) {
public GraphQlRepositoryArgumentPolicy {
allowedFilterFields = Set.copyOf(allowedFilterFields);
allowedSortFields = Set.copyOf(allowedSortFields);
}
/**
* Verifies the arguments a request supplied.
*
* @throws GraphQlRepositoryExposureRejectedException naming the unlisted arguments
*/
public void verify(Set<String> filterFields, Set<String> sortFields) {
var rejected = new TreeSet<String>();
filterFields.stream()
.filter(field -> !allowedFilterFields.contains(field))
.forEach(rejected::add);
sortFields.stream().filter(field -> !allowedSortFields.contains(field)).forEach(rejected::add);
if (!rejected.isEmpty()) {
throw new GraphQlRepositoryExposureRejectedException(
"unlisted filter or sort fields " + rejected);
}
}
}
@@ -1,19 +0,0 @@
package dev.caskeleton.adapter.inbound.graphql.advanced.compat;
/**
* One repository exposed at one schema coordinate.
*
* @param repositoryName the repository
* @param schemaCoordinate the field it backs
*/
public record GraphQlRepositoryExposure(String repositoryName, String schemaCoordinate) {
public GraphQlRepositoryExposure {
if (repositoryName == null || repositoryName.isBlank()) {
throw new IllegalArgumentException("repository name is required");
}
if (schemaCoordinate == null || schemaCoordinate.isBlank()) {
throw new IllegalArgumentException("schema coordinate is required");
}
}
}
@@ -1,21 +0,0 @@
package dev.caskeleton.adapter.inbound.graphql.advanced.compat;
/**
* Raised when a repository would be exposed without being allowlisted.
*
* <p>Automatic exposure turns a repository into a public API the moment it is annotated, so the
* default has to be refusal rather than registration.
*/
public class GraphQlRepositoryExposureRejectedException extends RuntimeException {
private static final long serialVersionUID = 1L;
/**
* Creates the failure.
*
* @param repositoryName the repository that is not allowlisted
*/
public GraphQlRepositoryExposureRejectedException(String repositoryName) {
super("repository is not allowlisted for GraphQL exposure: " + repositoryName);
}
}
@@ -1,58 +0,0 @@
package dev.caskeleton.adapter.inbound.graphql.advanced.compat;
import java.util.Objects;
/**
* Refuses repository exposure that was not deliberately configured (Advanced plan Task 15).
*
* <p>This is a compatibility path, not the mainstream API. The Stable route is a resolver calling
* an Application use case; automatic exposure exists for the cases where that is genuinely not
* worth writing, and it stays behind an allowlist, an argument policy, an explicit pagination
* choice and an approved projection.
*/
public final class GraphQlRepositoryExposureValidator {
private final GraphQlRepositoryAllowlist allowlist;
/**
* Creates the validator.
*
* @param allowlist repositories permitted to be exposed
*/
public GraphQlRepositoryExposureValidator(GraphQlRepositoryAllowlist allowlist) {
this.allowlist = Objects.requireNonNull(allowlist);
}
/**
* Verifies a repository may be exposed.
*
* @throws GraphQlRepositoryExposureRejectedException when it is not allowlisted
*/
public void verify(GraphQlRepositoryExposure exposure) {
if (!allowlist.contains(exposure.repositoryName())) {
throw new GraphQlRepositoryExposureRejectedException(exposure.repositoryName());
}
}
/**
* Verifies the full exposure configuration.
*
* @param exposure the repository and coordinate
* @param pagination the pagination policy
* @param projection the projection policy
* @throws GraphQlRepositoryExposureRejectedException when anything was left to default
*/
public void verifyConfiguration(
GraphQlRepositoryExposure exposure,
GraphQlRepositoryPaginationPolicy pagination,
GraphQlRepositoryProjectionPolicy projection) {
verify(exposure);
if (pagination.implicitSpringDataDefault()) {
throw new GraphQlRepositoryExposureRejectedException(
exposure.repositoryName()
+ " relies on the implicit offset pagination default; choose a pagination policy");
}
Objects.requireNonNull(projection, "an approved projection is required");
}
}
@@ -1,35 +0,0 @@
package dev.caskeleton.adapter.inbound.graphql.advanced.compat;
/**
* Pagination for an exposed repository, stated rather than inherited.
*
* <p>Spring Data's automatic exposure paginates by offset, twenty at a time, unless told otherwise.
* Both defaults are decisions: offset pagination skips and repeats rows under concurrent writes,
* and a page size that arrived by default is one nobody chose.
*
* @param keysetPagination whether keyset pagination is used instead of offset
* @param defaultPageSize page size when the client asks for none
* @param maximumPageSize largest page size the client may ask for
*/
public record GraphQlRepositoryPaginationPolicy(
boolean keysetPagination, int defaultPageSize, int maximumPageSize) {
/** The default Spring Data behaviour, which this platform requires to be chosen explicitly. */
public static final int SPRING_DATA_DEFAULT_PAGE_SIZE = 20;
public GraphQlRepositoryPaginationPolicy {
if (defaultPageSize < 1 || maximumPageSize < defaultPageSize) {
throw new IllegalArgumentException("invalid repository pagination policy");
}
}
/** An explicitly chosen keyset policy. */
public static GraphQlRepositoryPaginationPolicy keyset(int defaultPageSize, int maximumPageSize) {
return new GraphQlRepositoryPaginationPolicy(true, defaultPageSize, maximumPageSize);
}
/** Whether this policy merely restates Spring Data's defaults rather than choosing them. */
public boolean implicitSpringDataDefault() {
return !keysetPagination && defaultPageSize == SPRING_DATA_DEFAULT_PAGE_SIZE;
}
}
@@ -1,39 +0,0 @@
package dev.caskeleton.adapter.inbound.graphql.advanced.compat;
import java.util.Set;
/**
* Which projection an exposed repository returns.
*
* <p>Never the entity or document itself. Returning one exposes every persistence field as API —
* including the ones added later, by someone who had no idea this repository was reachable from
* GraphQL.
*
* @param projectionType the approved projection type name
* @param exposedFields fields the projection exposes
*/
public record GraphQlRepositoryProjectionPolicy(String projectionType, Set<String> exposedFields) {
public GraphQlRepositoryProjectionPolicy {
if (projectionType == null || projectionType.isBlank()) {
throw new IllegalArgumentException("an approved projection type is required");
}
exposedFields = Set.copyOf(exposedFields);
if (exposedFields.isEmpty()) {
throw new IllegalArgumentException("a projection must expose at least one field");
}
}
/**
* Verifies the projection is not a persistence type.
*
* @param persistenceTypeNames entity and document type names
* @throws GraphQlRepositoryExposureRejectedException when the projection is one of them
*/
public void verifyNotPersistenceType(Set<String> persistenceTypeNames) {
if (persistenceTypeNames.contains(projectionType)) {
throw new GraphQlRepositoryExposureRejectedException(
projectionType + " is a persistence type and must not be returned directly");
}
}
}
@@ -0,0 +1,25 @@
package dev.caskeleton.adapter.inbound.graphql.advanced.persisted;
/**
* Raised when an admin command names an operation the registry does not hold.
*
* <p>A distinct failure from a conflict: "there is nothing here" and "you cannot do that from here"
* lead an operator to different next steps, and the admin plane used to report neither — an unknown
* id produced a successful-looking audit entry.
*/
public class GraphQlPersistedOperationNotFoundException extends RuntimeException {
private static final long serialVersionUID = 1L;
/** Stable error code. */
public static final String CODE = "GRAPHQL_PERSISTED_OPERATION_NOT_FOUND";
/**
* Creates the failure.
*
* @param operationId the operation that does not exist
*/
public GraphQlPersistedOperationNotFoundException(String operationId) {
super(CODE + ": " + operationId);
}
}
@@ -0,0 +1,123 @@
package dev.caskeleton.adapter.inbound.graphql.advanced.persisted;
import dev.caskeleton.shared.opstore.OperationalRecord;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Objects;
import java.util.Set;
/**
* Translates a persisted operation to and from a neutral operational record.
*
* <p>The whole point of the split. Durable storage for persisted operations has to live in
* infrastructure, and infrastructure must not implement a type that belongs to an inbound transport
* — a Postgres adapter implementing {@code GraphQlPersistedOperationRegistry} would point the
* dependency from the database back at the GraphQL boundary. So the store speaks {@link
* OperationalRecord} and knows nothing about GraphQL, and this class is the only place that knows
* both vocabularies.
*
* <p>The encoding is length-framed rather than delimited. A canonical document contains newlines,
* an operation name is client-influenced, and a client profile is a free-form string; any delimiter
* chosen from those alphabets is a delimiter a value can contain, and the field after it is then
* read as something else. Framing each field by its length removes the question.
*/
public final class GraphQlPersistedOperationRecordMapping {
/** The key space this capability owns in the operational store. */
public static final String NAMESPACE = "graphql.persisted-operation";
private static final int FIELDS = 9;
private GraphQlPersistedOperationRecordMapping() {}
/**
* The store key for an operation id.
*
* @param id the operation id
*/
public static String keyFor(GraphQlPersistedOperationId id) {
Objects.requireNonNull(id, "persisted operation id is required");
return id.value();
}
/**
* Encodes an operation as a neutral record.
*
* @param operation the operation to store
* @param version the version the caller read, for the store's compare-and-set
*/
public static OperationalRecord toRecord(GraphQlPersistedOperation operation, long version) {
Objects.requireNonNull(operation, "persisted operation is required");
StringBuilder encoded = new StringBuilder();
write(encoded, operation.id().value());
write(encoded, operation.operationName());
write(encoded, operation.documentHash());
write(encoded, operation.canonicalDocument());
write(encoded, operation.schemaContractHash());
write(encoded, String.join(",", operation.allowedClientProfiles()));
write(encoded, Long.toString(operation.maximumComplexity()));
write(encoded, Integer.toString(operation.maximumVariablesBytes()));
write(encoded, operation.status().name());
return new OperationalRecord(NAMESPACE, keyFor(operation.id()), encoded.toString(), version);
}
/**
* Decodes an operation from a neutral record.
*
* @param record the stored record
* @throws IllegalArgumentException when the record is not a persisted operation of this shape
*/
public static GraphQlPersistedOperation fromRecord(OperationalRecord record) {
Objects.requireNonNull(record, "operational record is required");
if (!NAMESPACE.equals(record.namespace())) {
throw new IllegalArgumentException("operational record belongs to another namespace");
}
List<String> fields = readAll(record.value());
if (fields.size() != FIELDS) {
throw new IllegalArgumentException("stored persisted operation has an unexpected shape");
}
Set<String> profiles = new LinkedHashSet<>();
if (!fields.get(5).isEmpty()) {
profiles.addAll(List.of(fields.get(5).split(",", -1)));
}
return new GraphQlPersistedOperation(
new GraphQlPersistedOperationId(fields.get(0)),
fields.get(1),
fields.get(2),
fields.get(3),
fields.get(4),
profiles,
Long.parseLong(fields.get(6)),
Integer.parseInt(fields.get(7)),
GraphQlPersistedOperationStatus.valueOf(fields.get(8)));
}
private static void write(StringBuilder out, String value) {
out.append(value.length()).append(':').append(value);
}
private static List<String> readAll(String encoded) {
List<String> fields = new java.util.ArrayList<>();
int cursor = 0;
while (cursor < encoded.length()) {
int separator = encoded.indexOf(':', cursor);
if (separator < 0) {
throw new IllegalArgumentException("stored persisted operation is not length-framed");
}
int length;
try {
length = Integer.parseInt(encoded.substring(cursor, separator));
} catch (NumberFormatException malformed) {
throw new IllegalArgumentException("stored persisted operation has an invalid frame");
}
int start = separator + 1;
int end = start + length;
if (length < 0 || end > encoded.length()) {
throw new IllegalArgumentException("stored persisted operation frame runs past its value");
}
fields.add(encoded.substring(start, end));
cursor = end;
}
return List.copyOf(fields);
}
}
@@ -23,9 +23,17 @@ public interface GraphQlPersistedOperationRegistry {
Optional<GraphQlPersistedOperation> find(GraphQlPersistedOperationId id);
/**
* Replaces an operation's lifecycle state.
* Applies a lifecycle transition and returns the operation as it now stands.
*
* <p>Used by the admin plane to block an operation during an incident.
* <p>Returning the record, and failing when there is nothing to change, is what makes an audit
* entry trustworthy. The previous {@code updateStatus} was a no-op for an unknown id, so the
* admin plane recorded {@code ABSENT -> BLOCKED} as a successful incident response for an
* operation that had never existed — the one entry an operator would rely on afterwards.
*
* @throws GraphQlPersistedOperationNotFoundException when the operation does not exist
* @throws GraphQlPersistedOperationConflictException when the transition is not permitted from
* the operation's current state
*/
void updateStatus(GraphQlPersistedOperationId id, GraphQlPersistedOperationStatus status);
GraphQlPersistedOperation apply(
GraphQlPersistedOperationId id, GraphQlPersistedOperationTransition transition);
}
@@ -0,0 +1,80 @@
package dev.caskeleton.adapter.inbound.graphql.advanced.persisted;
import java.util.Map;
import java.util.Set;
/**
* Which lifecycle changes the registry will accept.
*
* <p>Without a table, {@code updateStatus} accepted anything, and the sequence that mattered was
* {@code BLOCKED → DEPRECATED}: an operation stopped during an incident could be made executable
* again by a status change that read like a documentation update. Blocking is the emergency
* control, so leaving it is the transition that has to be deliberate.
*
* <p>{@code BLOCKED} is therefore terminal except through {@link #UNBLOCK}, which exists precisely
* so that reversing an incident block is its own audited command rather than a side effect of
* something else.
*/
public enum GraphQlPersistedOperationTransition {
/** First registration of an operation. */
REGISTER,
/** Marks an operation deprecated while clients migrate. It stays executable. */
DEPRECATE,
/** Stops an operation immediately. */
BLOCK,
/** Reverses a block, deliberately and with its own audit entry. */
UNBLOCK,
/** Retires an operation: it is blocked and no longer offered. */
RETIRE;
private static final Map<
GraphQlPersistedOperationTransition, Set<GraphQlPersistedOperationStatus>>
ALLOWED_FROM =
Map.of(
DEPRECATE,
Set.of(
GraphQlPersistedOperationStatus.ACTIVE,
GraphQlPersistedOperationStatus.DEPRECATED),
BLOCK,
Set.of(
GraphQlPersistedOperationStatus.ACTIVE,
GraphQlPersistedOperationStatus.DEPRECATED),
UNBLOCK,
Set.of(GraphQlPersistedOperationStatus.BLOCKED),
RETIRE,
Set.of(
GraphQlPersistedOperationStatus.ACTIVE,
GraphQlPersistedOperationStatus.DEPRECATED,
GraphQlPersistedOperationStatus.BLOCKED));
/** The state this transition leaves the operation in. */
public GraphQlPersistedOperationStatus target() {
return switch (this) {
case REGISTER, UNBLOCK -> GraphQlPersistedOperationStatus.ACTIVE;
case DEPRECATE -> GraphQlPersistedOperationStatus.DEPRECATED;
case BLOCK, RETIRE -> GraphQlPersistedOperationStatus.BLOCKED;
};
}
/** Whether this transition may be applied to an operation currently in the given state. */
public boolean allowedFrom(GraphQlPersistedOperationStatus current) {
return this != REGISTER && ALLOWED_FROM.getOrDefault(this, Set.of()).contains(current);
}
/**
* Verifies the transition.
*
* @throws GraphQlPersistedOperationConflictException when the change is not permitted from here
*/
public void verify(GraphQlPersistedOperationId id, GraphQlPersistedOperationStatus current) {
if (!allowedFrom(current)) {
throw new GraphQlPersistedOperationConflictException(
id.value() + " cannot go " + current + " -> " + name());
}
}
}
@@ -0,0 +1,96 @@
package dev.caskeleton.adapter.inbound.graphql.advanced.persisted;
import dev.caskeleton.shared.opstore.OperationalRecord;
import dev.caskeleton.shared.opstore.OperationalRecordConflictException;
import dev.caskeleton.shared.opstore.OperationalRecordStorePort;
import java.util.Objects;
import java.util.Optional;
/**
* The persisted-operation registry, backed by whatever durable store the deployment provides.
*
* <p>This is the registry an adopter runs. The dependency points from here to a neutral contract in
* {@code shared-contract}, and the durable implementation of that contract points at the same
* contract from the other side — so a Postgres or Redis adapter never names a GraphQL type, and
* this leaf never names a datastore. The composition root connects the two and owns no policy.
*
* <p>Registration and transitions are compare-and-set against the version this registry read. Two
* admin planes blocking and approving the same operation a second apart used to resolve by arrival
* order, and the loser left no trace; now the loser is told.
*/
public final class OperationalStoreGraphQlPersistedOperationRegistry
implements GraphQlPersistedOperationRegistry {
private final OperationalRecordStorePort store;
/**
* Creates the registry.
*
* @param store the deployment's durable operational store
*/
public OperationalStoreGraphQlPersistedOperationRegistry(OperationalRecordStorePort store) {
this.store = Objects.requireNonNull(store, "operational record store is required");
}
@Override
public void register(GraphQlPersistedOperation operation) {
Objects.requireNonNull(operation, "persisted operation is required");
String key = GraphQlPersistedOperationRecordMapping.keyFor(operation.id());
Optional<OperationalRecord> stored =
store.find(GraphQlPersistedOperationRecordMapping.NAMESPACE, key);
if (stored.isPresent()) {
GraphQlPersistedOperation existing =
GraphQlPersistedOperationRecordMapping.fromRecord(stored.get());
if (!existing.canonicalDocument().equals(operation.canonicalDocument())) {
throw new GraphQlPersistedOperationConflictException(operation.id().value());
}
return;
}
try {
store.compareAndSet(
GraphQlPersistedOperationRecordMapping.toRecord(
operation, OperationalRecord.ABSENT_VERSION),
OperationalRecord.ABSENT_VERSION);
} catch (OperationalRecordConflictException lost) {
// Another instance registered the same id between the read and the write. That is a conflict
// in exactly the sense this capability already has a name for.
throw new GraphQlPersistedOperationConflictException(operation.id().value());
}
}
@Override
public Optional<GraphQlPersistedOperation> find(GraphQlPersistedOperationId id) {
Objects.requireNonNull(id, "persisted operation id is required");
return store
.find(
GraphQlPersistedOperationRecordMapping.NAMESPACE,
GraphQlPersistedOperationRecordMapping.keyFor(id))
.map(GraphQlPersistedOperationRecordMapping::fromRecord);
}
@Override
public GraphQlPersistedOperation apply(
GraphQlPersistedOperationId id, GraphQlPersistedOperationTransition transition) {
Objects.requireNonNull(id, "persisted operation id is required");
Objects.requireNonNull(transition, "transition is required");
OperationalRecord stored =
store
.find(
GraphQlPersistedOperationRecordMapping.NAMESPACE,
GraphQlPersistedOperationRecordMapping.keyFor(id))
.orElseThrow(() -> new GraphQlPersistedOperationNotFoundException(id.value()));
GraphQlPersistedOperation current = GraphQlPersistedOperationRecordMapping.fromRecord(stored);
transition.verify(id, current.status());
GraphQlPersistedOperation next = current.withStatus(transition.target());
try {
store.compareAndSet(
GraphQlPersistedOperationRecordMapping.toRecord(next, stored.version() + 1),
stored.version());
} catch (OperationalRecordConflictException lost) {
throw new GraphQlPersistedOperationConflictException(id.value());
}
return next;
}
}
@@ -5,9 +5,14 @@ import dev.caskeleton.adapter.inbound.graphql.advanced.security.GraphQlWebSocket
/**
* Authorizes a replay before any history is delivered.
*
* <p>Replay reads the past, so the check is stricter than for a live subscription: the actor
* resuming must be the actor the cursor was issued to, and must still be authorized now — access
* granted when the events were produced may since have been withdrawn.
* <p>Replay reads the past, so the check is stricter than for a live subscription: the tenant and
* actor resuming must be the ones the cursor was issued to, and must still be authorized now —
* access granted when the events were produced may since have been withdrawn.
*
* <p>Tenant is checked alongside actor rather than assumed to follow from it. Checking only the
* actor is correct exactly while one actor identity never spans two tenants, and nothing here
* enforces that; when it stops holding, the replay succeeds and hands over history the caller was
* never entitled to.
*/
public final class GraphQlReplayAuthorization {
@@ -17,17 +22,26 @@ public final class GraphQlReplayAuthorization {
* Verifies a replay request.
*
* @param cursorActorFingerprint the actor the cursor was issued to
* @param cursorTenantFingerprint the tenant the cursor was issued to
* @param principal the actor presenting it
* @param stillAuthorized whether the Application still authorizes this actor for the subscription
* @throws GraphQlReplayAuthorizationException when the actor differs or is no longer authorized
* @throws GraphQlReplayAuthorizationException when tenant or actor differs, or authorization has
* since been withdrawn
*/
public static void verify(
String cursorActorFingerprint, GraphQlWebSocketPrincipal principal, boolean stillAuthorized) {
String cursorActorFingerprint,
String cursorTenantFingerprint,
GraphQlWebSocketPrincipal principal,
boolean stillAuthorized) {
if (cursorActorFingerprint == null
|| !cursorActorFingerprint.equals(principal.actorFingerprint())) {
throw new GraphQlReplayAuthorizationException();
}
if (cursorTenantFingerprint == null
|| !cursorTenantFingerprint.equals(principal.tenantFingerprint())) {
throw new GraphQlReplayAuthorizationException();
}
if (!stillAuthorized) {
throw new GraphQlReplayAuthorizationException();
}
@@ -1,14 +1,25 @@
package dev.caskeleton.adapter.inbound.graphql.advanced.replay;
import dev.caskeleton.adapter.inbound.graphql.advanced.security.GraphQlWebSocketPrincipal;
import dev.caskeleton.adapter.inbound.graphql.pagination.GraphQlCursorCodec;
import dev.caskeleton.adapter.inbound.graphql.pagination.GraphQlCursorFraming;
import dev.caskeleton.adapter.inbound.graphql.pagination.GraphQlCursorPayload;
import dev.caskeleton.adapter.inbound.graphql.pagination.GraphQlCursorScope;
import java.util.Map;
import java.util.Objects;
/**
* A signed resume position for a subscription.
*
* <p>Signed and bound to the actor and subscription profile for the same reason connection cursors
* are: an unsigned resume token is a "start reading from here" parameter, and replay reads history.
* <p>Signed and bound to the tenant, the actor and the subscription profile for the same reason
* connection cursors are: an unsigned resume token is a "start reading from here" parameter, and
* replay reads history.
*
* <p>The tenant is part of the binding, not left to the actor fingerprint to imply. An actor
* identifier that happens to be unique per tenant today stops being a tenant check the moment one
* identity can act in two tenants, and the failure is silent — the cursor verifies, the actor
* matches, and the replay delivers another tenant's history. Taking the principal rather than a
* bare fingerprint makes issuing a cursor without a tenant impossible to express.
*
* <p>GraphQL itself defines no resume mechanism — this is an extension, and the durability behind
* it belongs to the messaging platform.
@@ -25,20 +36,21 @@ public final class GraphQlSubscriptionCursor {
*
* @param codec the signing codec
* @param subscriptionProfile the subscription this cursor belongs to
* @param actorFingerprint the actor it was issued to
* @param principal the tenant and actor it was issued to
* @param position the resume position
*/
public static String issue(
GraphQlCursorCodec codec,
String subscriptionProfile,
String actorFingerprint,
GraphQlWebSocketPrincipal principal,
GraphQlReplayPosition position) {
return codec.encode(
GraphQlCursorPayload.of(
GraphQlCursorPayload.issue(
QUERY_PROFILE,
GraphQlCursorPayload.FORWARD,
Map.of("id", subscriptionProfile, "sequence", Long.toString(position.sequence())),
actorFingerprint));
subscriptionProfile,
scopeOf(principal)));
}
/**
@@ -46,13 +58,38 @@ public final class GraphQlSubscriptionCursor {
*
* @param codec the signing codec
* @param cursor the cursor the client presented
* @param actorFingerprint the actor presenting it
* @param subscriptionProfile the subscription being resumed
* @param principal the tenant and actor presenting it
* @throws dev.caskeleton.adapter.inbound.graphql.pagination.GraphQlCursorException when the
* cursor was issued for another actor or subscription
* cursor was issued for another tenant, actor or subscription
*/
public static GraphQlReplayPosition resume(
GraphQlCursorCodec codec, String cursor, String actorFingerprint) {
GraphQlCursorPayload payload = codec.decode(cursor, QUERY_PROFILE, actorFingerprint);
GraphQlCursorCodec codec,
String cursor,
String subscriptionProfile,
GraphQlWebSocketPrincipal principal) {
GraphQlCursorPayload payload =
codec.decode(
cursor,
new GraphQlCursorScope(
QUERY_PROFILE,
subscriptionProfile,
GraphQlCursorPayload.FORWARD,
scopeOf(principal)));
return new GraphQlReplayPosition(Long.parseLong(payload.keyset().get("sequence")));
}
/**
* The scope fingerprint a cursor is bound to.
*
* <p>Length-framed rather than concatenated, so a tenant ending in the prefix of an actor cannot
* produce the same scope string as a different pair.
*/
private static String scopeOf(GraphQlWebSocketPrincipal principal) {
Objects.requireNonNull(principal, "principal is required");
StringBuilder scope = new StringBuilder();
GraphQlCursorFraming.write(scope, principal.tenantFingerprint());
GraphQlCursorFraming.write(scope, principal.actorFingerprint());
return scope.toString();
}
}
@@ -6,12 +6,16 @@ import dev.caskeleton.adapter.inbound.graphql.policy.GraphQlOperationType;
import java.util.Objects;
/**
* Registers the RSocket transport, once flag, approval, route and consumer all allow it.
* Decides whether an RSocket route may be served, and which interaction model it gets.
*
* <p>Admission, not a handler and not a registration: nothing here touches an {@code RSocket}
* acceptor. It answers "is this route allowed, and is this request/stream or request/response",
* which is what the runtime needs before it binds anything.
*
* <p>Experimental, so in production the guard additionally requires an approval profile. The
* interaction model comes from the operation type rather than the caller's request.
*/
public final class GraphQlRSocketHandlerFactory {
public final class GraphQlRSocketAdmission {
private final GraphQlAdvancedModuleGuard guard;
private final GraphQlRSocketProperties properties;
@@ -23,7 +27,7 @@ public final class GraphQlRSocketHandlerFactory {
* @param guard the Advanced capability guard
* @param properties transport configuration
*/
public GraphQlRSocketHandlerFactory(
public GraphQlRSocketAdmission(
GraphQlAdvancedModuleGuard guard, GraphQlRSocketProperties properties) {
this.guard = Objects.requireNonNull(guard);
this.properties = Objects.requireNonNull(properties);
@@ -7,13 +7,17 @@ import java.time.Clock;
import java.util.Objects;
/**
* Creates SSE streams, once the capability is enabled (Advanced plan Task 9).
* Decides whether an SSE stream may be opened, and issues its heartbeat schedule if so.
*
* <p>Admission, not a handler: what it returns is a {@link GraphQlSseHeartbeat} and a {@link
* GraphQlSseTermination}, both plain schedules. Nothing here writes an event to a response the
* runtime that owns the response does that, using these bounds.
*
* <p>The request shape is a POST with a JSON body and {@code Accept: text/event-stream} the same
* request envelope as every other transport, with a streaming response. Authorization and cost
* policy are the WebSocket ones; only the delivery mechanism differs.
*/
public final class GraphQlSseHandlerFactory {
public final class GraphQlSseAdmission {
/** The {@code Accept} value that selects SSE. */
public static final String EVENT_STREAM_MEDIA_TYPE = "text/event-stream";
@@ -29,7 +33,7 @@ public final class GraphQlSseHandlerFactory {
* @param properties connection bounds
* @param clock clock used for heartbeats and termination
*/
public GraphQlSseHandlerFactory(
public GraphQlSseAdmission(
GraphQlAdvancedModuleGuard guard, GraphQlSseProperties properties, Clock clock) {
this.guard = Objects.requireNonNull(guard);
this.properties = Objects.requireNonNull(properties);
@@ -1,5 +1,6 @@
package dev.caskeleton.adapter.inbound.graphql.advanced.subscription;
import dev.caskeleton.adapter.inbound.graphql.security.GraphQlContextCleanup;
import java.util.Queue;
import java.util.concurrent.ConcurrentLinkedQueue;
import java.util.concurrent.atomic.AtomicBoolean;
@@ -10,6 +11,11 @@ import java.util.concurrent.atomic.AtomicBoolean;
* <p>An unsubscribed client whose upstream keeps running is the expensive failure here: the Kafka
* consumer, the polling task and the nested publishers all continue for a subscriber that has gone,
* and nothing in the request path notices.
*
* <p>Every hook runs exactly once, and one that throws does not stop the rest. The loop used to
* abandon the queue at the first failure, so a broken consumer-close left the polling task and the
* nested publishers running — the leak the second and third hooks existed to prevent, caused by the
* first one failing.
*/
public final class GraphQlSubscriptionCancellation {
@@ -40,10 +46,13 @@ public final class GraphQlSubscriptionCancellation {
}
private void drain() {
GraphQlContextCleanup cleanup = GraphQlContextCleanup.create();
Runnable hook = upstream.poll();
while (hook != null) {
hook.run();
cleanup.register(hook);
hook = upstream.poll();
}
// The same run-all-then-rethrow-with-suppressed semantics the request path already uses.
cleanup.close();
}
}
@@ -2,8 +2,8 @@ package dev.caskeleton.adapter.inbound.graphql.advanced.subscription;
import java.time.Duration;
import java.time.Instant;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.Objects;
import java.util.concurrent.atomic.AtomicReference;
/**
* Graceful shutdown for long-lived subscriptions.
@@ -12,13 +12,29 @@ import java.util.concurrent.atomic.AtomicInteger;
* storm against an instance that is already leaving. New subscriptions are refused immediately,
* existing ones get a bounded window to finish, and the window has a deadline so a stuck stream
* cannot delay shutdown forever.
*
* <p>Phase, count and drain start move together as one immutable value behind a single
* compare-and-set, because they are one fact and not three. Held apart they raced in both
* directions: a subscription that passed the "not draining" check and then incremented the count
* was admitted onto a node that had begun draining in between, and a reader that saw the draining
* flag before the separate {@code drainStartedAt} field was written computed the deadline against
* {@code null}. Shutdown is exactly when both happen, and exactly when neither is easy to see.
*/
public final class GraphQlSubscriptionDrainCoordinator {
/**
* The whole coordinator state, replaced atomically.
*
* @param phase where the node is in its lifetime
* @param active subscriptions currently streaming
* @param startedAt when draining began; non-null whenever the phase is not {@code ACCEPTING}
*/
private record State(GraphQlSubscriptionDrainPhase phase, int active, Instant startedAt) {}
private static final State OPEN = new State(GraphQlSubscriptionDrainPhase.ACCEPTING, 0, null);
private final Duration drainTimeout;
private final AtomicBoolean draining = new AtomicBoolean();
private final AtomicInteger active = new AtomicInteger();
private Instant drainStartedAt;
private final AtomicReference<State> state = new AtomicReference<>(OPEN);
/**
* Creates the coordinator.
@@ -33,44 +49,95 @@ public final class GraphQlSubscriptionDrainCoordinator {
}
/**
* Registers a new subscription.
* Registers a new subscription and returns its lease.
*
* @throws GraphQlSubscriptionDrainingException while draining
* <p>The admission decision and the count increment are the same compare-and-set, so a
* subscription is never admitted onto a node that started draining between the two.
*
* @return the lease to close when the subscription ends
* @throws GraphQlSubscriptionDrainingException while draining or once closed
*/
public void register() {
if (draining.get()) {
throw new GraphQlSubscriptionDrainingException();
public GraphQlSubscriptionLease register() {
while (true) {
State current = state.get();
if (current.phase() != GraphQlSubscriptionDrainPhase.ACCEPTING) {
throw new GraphQlSubscriptionDrainingException();
}
State next = new State(current.phase(), current.active() + 1, current.startedAt());
if (state.compareAndSet(current, next)) {
return new GraphQlSubscriptionLease(this);
}
}
active.incrementAndGet();
}
/** Records that a subscription finished. */
public void deregister() {
active.updateAndGet(current -> Math.max(0, current - 1));
}
/** Starts draining; no new subscriptions are accepted from here. */
/**
* Starts draining; no new subscriptions are accepted from here.
*
* <p>Idempotent: a second call keeps the original start instant, so a shutdown hook that fires
* twice cannot extend the window it is supposed to bound.
*
* @param now the instant draining began
*/
public void startDraining(Instant now) {
if (draining.compareAndSet(false, true)) {
drainStartedAt = now;
Objects.requireNonNull(now, "drain start instant is required");
while (true) {
State current = state.get();
if (current.phase() != GraphQlSubscriptionDrainPhase.ACCEPTING) {
return;
}
GraphQlSubscriptionDrainPhase next =
current.active() == 0
? GraphQlSubscriptionDrainPhase.CLOSED
: GraphQlSubscriptionDrainPhase.DRAINING;
if (state.compareAndSet(current, new State(next, current.active(), now))) {
return;
}
}
}
/** Whether every subscription has finished, or the drain window has elapsed. */
/**
* Whether every subscription has finished, or the drain window has elapsed.
*
* @param now the current instant
*/
public boolean drained(Instant now) {
if (!draining.get()) {
return false;
}
return active.get() == 0 || !now.isBefore(drainStartedAt.plus(drainTimeout));
Objects.requireNonNull(now, "current instant is required");
State current = state.get();
return switch (current.phase()) {
case ACCEPTING -> false;
case CLOSED -> true;
case DRAINING ->
current.active() == 0 || !now.isBefore(current.startedAt().plus(drainTimeout));
};
}
/** Subscriptions still streaming. */
public int activeSubscriptions() {
return active.get();
return state.get().active();
}
/** Whether the coordinator is draining. */
/** Whether the coordinator has stopped accepting new subscriptions. */
public boolean draining() {
return draining.get();
return state.get().phase() != GraphQlSubscriptionDrainPhase.ACCEPTING;
}
/** Where the node is in its subscription lifetime. */
public GraphQlSubscriptionDrainPhase phase() {
return state.get().phase();
}
/** Releases one lease; called by {@link GraphQlSubscriptionLease#close()}. */
void release() {
while (true) {
State current = state.get();
int remaining = Math.max(0, current.active() - 1);
GraphQlSubscriptionDrainPhase next =
current.phase() == GraphQlSubscriptionDrainPhase.DRAINING && remaining == 0
? GraphQlSubscriptionDrainPhase.CLOSED
: current.phase();
if (state.compareAndSet(current, new State(next, remaining, current.startedAt()))) {
return;
}
}
}
}
@@ -0,0 +1,20 @@
package dev.caskeleton.adapter.inbound.graphql.advanced.subscription;
/**
* Where a node is in its subscription lifetime.
*
* <p>Three phases rather than a boolean, because "draining" and "finished draining" are different
* answers to the shutdown question and a boolean can only carry one of them. The transitions are
* one-way: a node that has started draining never accepts again, and a closed one never reopens.
*/
public enum GraphQlSubscriptionDrainPhase {
/** New subscriptions are admitted. */
ACCEPTING,
/** New subscriptions are refused; existing ones have a bounded window to finish. */
DRAINING,
/** Every subscription finished, or the drain window elapsed. */
CLOSED
}
@@ -1,5 +1,6 @@
package dev.caskeleton.adapter.inbound.graphql.advanced.subscription;
import dev.caskeleton.adapter.inbound.graphql.http.GraphQlRequestSize;
import java.util.Map;
/**
@@ -24,8 +25,15 @@ public record GraphQlSubscriptionEvent(Map<String, Object> payload, long sequenc
}
}
/** An estimate of this event's serialized size, for the byte budget. */
/**
* This event's serialized size, for the byte budget.
*
* <p>Counted as canonical JSON, not as {@code Map.toString()}. The two diverge by more than a
* constant: {@code toString} writes {@code {a=1}} where JSON writes {@code {"a":1}}, omits the
* quoting that dominates a string-heavy payload, and renders a nested list differently again. A
* queue bounded by that number is bounded by something that is not the thing being queued.
*/
public long approximateBytes() {
return payload.toString().getBytes(java.nio.charset.StandardCharsets.UTF_8).length;
return GraphQlRequestSize.jsonBytes(payload);
}
}
@@ -0,0 +1,37 @@
package dev.caskeleton.adapter.inbound.graphql.advanced.subscription;
import java.util.Objects;
import java.util.concurrent.atomic.AtomicBoolean;
/**
* A subscription's claim on the node, released exactly once when the stream ends.
*
* <p>A lease rather than a paired {@code register}/{@code deregister} call, because the paired form
* makes correctness depend on the caller: a stream that ended on an error path without its matching
* deregister left the count permanently above zero, and the node then drained for the full timeout
* on every shutdown while reporting subscriptions that no longer existed. Releasing twice is just
* as damaging in the other direction — it decrements someone else's subscription — so the release
* is idempotent and the second call does nothing.
*/
public final class GraphQlSubscriptionLease implements AutoCloseable {
private final GraphQlSubscriptionDrainCoordinator coordinator;
private final AtomicBoolean released = new AtomicBoolean();
GraphQlSubscriptionLease(GraphQlSubscriptionDrainCoordinator coordinator) {
this.coordinator = Objects.requireNonNull(coordinator, "coordinator is required");
}
/** Releases the lease; subsequent calls do nothing. */
@Override
public void close() {
if (released.compareAndSet(false, true)) {
coordinator.release();
}
}
/** Whether this lease has already been released. */
public boolean released() {
return released.get();
}
}
@@ -7,12 +7,19 @@ import java.util.List;
import java.util.Objects;
/**
* Creates connection lifecycles, once the capability is enabled.
* Decides whether a WebSocket connection may be opened, and issues its lifecycle if so.
*
* <p>Admission, not a handler. It was called a handler factory, which promised a Spring {@code
* WebSocketHandler} that reads and writes frames; what it returns is a {@link
* GraphQlWebSocketLifecycle} a state machine with no socket and no I/O. An adopter who wired the
* old name where Spring expected a handler found a name that fit and behaviour that did not. The
* transport binding stays with the runtime that owns the socket; this owns the decision to let a
* connection exist.
*
* <p>The guard is checked here rather than per message, so a deployment without the flag never
* accepts a WebSocket connection at all.
*/
public final class GraphQlWebSocketHandlerFactory {
public final class GraphQlWebSocketAdmission {
private final GraphQlAdvancedModuleGuard guard;
private final GraphQlWebSocketProperties properties;
@@ -25,7 +32,7 @@ public final class GraphQlWebSocketHandlerFactory {
* @param properties connection bounds
* @param clock clock used for lifecycle deadlines
*/
public GraphQlWebSocketHandlerFactory(
public GraphQlWebSocketAdmission(
GraphQlAdvancedModuleGuard guard, GraphQlWebSocketProperties properties, Clock clock) {
this.guard = Objects.requireNonNull(guard);
this.properties = Objects.requireNonNull(properties);
@@ -0,0 +1,72 @@
package dev.caskeleton.adapter.inbound.graphql.architecture;
import java.util.List;
import java.util.Set;
/**
* Whether a resolver's return container yields one value or many.
*
* <p>The distinction is what makes the subscription rule correct. The rule used to reject every
* {@code Publisher} outside a subscription, and {@code Mono<T>} is a {@code Publisher} — so {@code
* Mono<OrderView> order()} on a query, which Spring for GraphQL supports and documents, was refused
* by the platform's own boundary check. A query may complete asynchronously; what it may not do is
* emit a stream, because the HTTP profile has no way to deliver one.
*
* <p>Recognised by name rather than by class reference: naming {@code reactor.core.publisher.Mono}
* here would drag Reactor into a rule that exists to keep this boundary framework-light, and the
* check has to work whether or not the adopter has Reactor at all.
*/
public enum GraphQlAsyncReturnShape {
/** Not an async container at all. */
SYNCHRONOUS,
/** Completes once with at most one value: legal on every operation type. */
SINGLE_VALUE,
/** Emits zero or more values over time: legal only on a subscription. */
MULTI_VALUE;
private static final Set<String> SINGLE_VALUE_CONTAINERS =
Set.of(
"reactor.core.publisher.Mono",
"java.util.concurrent.CompletionStage",
"java.util.concurrent.CompletableFuture",
"java.util.concurrent.Future",
"java.util.Optional",
"org.springframework.graphql.data.method.annotation.SchemaMapping");
private static final List<String> MULTI_VALUE_INTERFACES =
List.of("org.reactivestreams.Publisher", "java.util.stream.Stream");
/** Classifies a resolver return type. */
public static GraphQlAsyncReturnShape of(Class<?> returnType) {
if (returnType == null) {
return SYNCHRONOUS;
}
if (SINGLE_VALUE_CONTAINERS.contains(returnType.getName())) {
return SINGLE_VALUE;
}
return implementsAny(returnType, MULTI_VALUE_INTERFACES) ? MULTI_VALUE : SYNCHRONOUS;
}
/** Whether this shape may be returned from a field that is not a subscription. */
public boolean allowedOutsideSubscription() {
return this != MULTI_VALUE;
}
private static boolean implementsAny(Class<?> type, List<String> interfaceNames) {
if (type == null || type == Object.class) {
return false;
}
if (interfaceNames.contains(type.getName())) {
return true;
}
for (Class<?> implemented : type.getInterfaces()) {
if (implementsAny(implemented, interfaceNames)) {
return true;
}
}
return implementsAny(type.getSuperclass(), interfaceNames);
}
}
@@ -6,7 +6,6 @@ import java.lang.reflect.Parameter;
import java.util.ArrayList;
import java.util.List;
import java.util.Set;
import org.reactivestreams.Publisher;
/**
* Inspects one annotated resolver method against the transport boundary (design §11).
@@ -52,19 +51,30 @@ public final class GraphQlControllerInspector {
List<String> violations = new ArrayList<>();
String coordinate = coordinateOf(method);
Class<?> returnType = method.getReturnType();
String returnRejection = GraphQlReturnTypePolicy.rejection(returnType);
if (returnRejection != null) {
violations.add(coordinate + " returns " + returnRejection);
// The generic type, not the erased one. `Mono<OrderEntity>` erases to `Mono`, which passes
// every persistence rule while carrying exactly the type those rules forbid.
for (Class<?> referenced : GraphQlTypeGraph.referencedTypes(method.getGenericReturnType())) {
String returnRejection = GraphQlReturnTypePolicy.rejection(referenced);
if (returnRejection != null) {
violations.add(coordinate + " returns " + returnRejection);
}
}
if (Publisher.class.isAssignableFrom(returnType) && !subscription(method)) {
violations.add(coordinate + " returns a Publisher outside a subscription");
GraphQlAsyncReturnShape shape = GraphQlAsyncReturnShape.of(method.getReturnType());
if (!shape.allowedOutsideSubscription() && !subscription(method)) {
violations.add(
coordinate
+ " returns a multi-value publisher outside a subscription; a query or mutation may "
+ "complete asynchronously but cannot emit a stream");
}
for (Parameter parameter : method.getParameters()) {
String argumentRejection = GraphQlInputTypePolicy.rejection(parameter.getType());
if (argumentRejection != null) {
violations.add(coordinate + " binds " + parameter.getName() + ": " + argumentRejection);
for (Class<?> referenced :
GraphQlTypeGraph.referencedTypes(parameter.getParameterizedType())) {
String argumentRejection = GraphQlInputTypePolicy.rejection(referenced);
if (argumentRejection != null) {
violations.add(coordinate + " binds " + parameter.getName() + ": " + argumentRejection);
}
}
}
@@ -1,11 +1,13 @@
package dev.caskeleton.adapter.inbound.graphql.architecture;
import java.io.File;
import java.io.IOException;
import java.io.UncheckedIOException;
import java.lang.reflect.Constructor;
import java.lang.reflect.Field;
import java.lang.reflect.Method;
import java.lang.reflect.Parameter;
import java.net.JarURLConnection;
import java.net.URL;
import java.nio.file.Files;
import java.nio.file.Path;
@@ -15,6 +17,9 @@ import java.util.Enumeration;
import java.util.List;
import java.util.Set;
import java.util.TreeSet;
import java.util.function.Supplier;
import java.util.jar.JarEntry;
import java.util.jar.JarFile;
import java.util.stream.Stream;
/**
@@ -35,13 +40,37 @@ public final class GraphQlResolverBoundaryRules {
Set.of("EntityManager", "MongoTemplate", "SessionFactory", "DataSource", "JdbcTemplate");
/**
* Type-name suffixes that indicate a repository dependency.
* Type-name suffixes that suggest a repository dependency.
*
* <p>A supporting signal, not a verdict. A name is the weakest evidence available: {@code
* OrderRepository} may be a Spring Data interface or an application port that happens to be named
* that way, and a rule that decides on the suffix alone both misses the real repository imported
* under another name and refuses a legitimate collaborator. So the suffix only counts when the
* type also sits in a persistence-shaped package or is an interface — see {@link
* #repositoryEvidence}.
*
* <p>DTO mappers are deliberately absent: a resolver mapping an Application result onto a GraphQL
* payload is exactly what it is supposed to do.
*/
public static final Set<String> FORBIDDEN_TYPE_SUFFIXES = Set.of("Repository", "Dao");
/** Package name fragments that make a repository-suffixed type a repository in fact. */
public static final Set<String> PERSISTENCE_PACKAGE_FRAGMENTS =
Set.of(".repository", ".persistence", ".dao", ".jpa", ".mongo");
/** Interfaces whose presence proves a type is a repository, matched by name. */
public static final Set<String> REPOSITORY_INTERFACES =
Set.of(
"org.springframework.data.repository.Repository",
"org.springframework.data.repository.CrudRepository",
"org.springframework.data.repository.PagingAndSortingRepository",
"org.springframework.data.repository.ListCrudRepository",
"org.springframework.data.repository.reactive.ReactiveCrudRepository");
/** Annotations whose presence proves a type is a repository, matched by name. */
public static final Set<String> REPOSITORY_ANNOTATIONS =
Set.of("org.springframework.stereotype.Repository");
private GraphQlResolverBoundaryRules() {}
/**
@@ -66,46 +95,40 @@ public final class GraphQlResolverBoundaryRules {
}
}
/** Persistence-access violations across the given classes, in deterministic order. */
/**
* Persistence-access violations across the given classes, in deterministic order.
*
* <p>Every declared type is walked as a generic type graph. Checking the erased type would let
* {@code Optional<OrderRepository>}, {@code List<OrderEntity>} and {@code Mono<OrderRepository>}
* through — the wrapper is what a leak looks like once someone has tidied the signature.
*/
public static List<String> persistenceViolations(Collection<Class<?>> classes) {
List<String> violations = new ArrayList<>();
for (Class<?> type : classes) {
for (Field field : type.getDeclaredFields()) {
if (forbidden(field.getType())) {
violations.add(
type.getSimpleName()
+ "."
+ field.getName()
+ " depends on "
+ field.getType().getName());
}
record(
violations,
field.getGenericType(),
() -> type.getSimpleName() + "." + field.getName() + " depends on ");
}
for (Constructor<?> constructor : type.getDeclaredConstructors()) {
for (Parameter parameter : constructor.getParameters()) {
if (forbidden(parameter.getType())) {
violations.add(
type.getSimpleName() + " constructor injects " + parameter.getType().getName());
}
record(
violations,
parameter.getParameterizedType(),
() -> type.getSimpleName() + " constructor injects ");
}
}
for (Method method : type.getDeclaredMethods()) {
if (forbidden(method.getReturnType())) {
violations.add(
type.getSimpleName()
+ "#"
+ method.getName()
+ " returns "
+ method.getReturnType().getName());
}
record(
violations,
method.getGenericReturnType(),
() -> type.getSimpleName() + "#" + method.getName() + " returns ");
for (Parameter parameter : method.getParameters()) {
if (forbidden(parameter.getType())) {
violations.add(
type.getSimpleName()
+ "#"
+ method.getName()
+ " accepts "
+ parameter.getType().getName());
}
record(
violations,
parameter.getParameterizedType(),
() -> type.getSimpleName() + "#" + method.getName() + " accepts ");
}
}
}
@@ -113,25 +136,100 @@ public final class GraphQlResolverBoundaryRules {
return List.copyOf(violations);
}
private static void record(
List<String> violations, java.lang.reflect.Type declared, Supplier<String> prefix) {
for (Class<?> referenced : GraphQlTypeGraph.referencedTypes(declared)) {
String evidence = repositoryEvidence(referenced);
if (evidence != null) {
violations.add(prefix.get() + referenced.getName() + " (" + evidence + ")");
}
}
}
/** Whether a type represents direct persistence or repository access. */
public static boolean forbidden(Class<?> type) {
if (type == null || type.isPrimitive()) {
return false;
}
Class<?> subject = type.isArray() ? type.getComponentType() : type;
if (GraphQlReturnTypePolicy.forbiddenPrefix(subject) != null
|| GraphQlReturnTypePolicy.isPersistenceMapped(subject)) {
return true;
}
String simpleName = subject.getSimpleName();
if (FORBIDDEN_TYPE_NAMES.contains(simpleName)) {
return true;
}
return FORBIDDEN_TYPE_SUFFIXES.stream().anyMatch(simpleName::endsWith);
return repositoryEvidence(type) != null;
}
/**
* Loads every class declared directly in a package from the current classpath.
* Why a type counts as persistence access, or {@code null} when it does not.
*
* <p>Ordered strongest first, so a diagnostic names the reason that would survive a rename. The
* name-only signal is last and qualified, because it is the one that is wrong in both directions.
*/
public static String repositoryEvidence(Class<?> type) {
if (type == null || type.isPrimitive()) {
return null;
}
Class<?> subject = type;
while (subject.isArray()) {
subject = subject.getComponentType();
}
if (GraphQlReturnTypePolicy.forbiddenPrefix(subject) != null) {
return "declared in a persistence or driver package";
}
if (GraphQlReturnTypePolicy.isPersistenceMapped(subject)) {
return "carries a persistence mapping annotation";
}
if (implementsRepositoryInterface(subject)) {
return "implements a Spring Data repository interface";
}
for (java.lang.annotation.Annotation annotation : subject.getAnnotations()) {
if (REPOSITORY_ANNOTATIONS.contains(annotation.annotationType().getName())) {
return "annotated as a repository";
}
}
String simpleName = subject.getSimpleName();
if (FORBIDDEN_TYPE_NAMES.contains(simpleName)) {
return "is a persistence infrastructure type";
}
boolean repositoryName = FORBIDDEN_TYPE_SUFFIXES.stream().anyMatch(simpleName::endsWith);
if (repositoryName && type.isInterface()) {
return "is a repository-named interface";
}
if (repositoryName && persistenceShaped(subject)) {
return "is named as a repository and declared in a persistence package";
}
return null;
}
/**
* Whether the type sits in a package that makes a repository name mean what it says.
*
* <p>The pair of name-based rules above is what demoting the suffix heuristic looks like in
* practice. A repository-named <em>interface</em> is a port by every convention this repository
* follows, so it still counts on its own. A repository-named concrete class outside a persistence
* package no longer does — that shape is far more often a value object or a view than a
* data-access type, and refusing it made the rule something to work around.
*/
private static boolean persistenceShaped(Class<?> type) {
String packageName = "." + type.getPackageName() + ".";
return PERSISTENCE_PACKAGE_FRAGMENTS.stream()
.anyMatch(fragment -> packageName.contains(fragment + "."));
}
private static boolean implementsRepositoryInterface(Class<?> type) {
if (type == null || type == Object.class) {
return false;
}
if (REPOSITORY_INTERFACES.contains(type.getName())) {
return true;
}
for (Class<?> implemented : type.getInterfaces()) {
if (implementsRepositoryInterface(implemented)) {
return true;
}
}
return implementsRepositoryInterface(type.getSuperclass());
}
/**
* Loads every class in a package and its sub-packages from the current classpath.
*
* <p>Recursive and JAR-aware. The previous scan listed direct children of a {@code file:}
* directory only, so an adopter whose controllers sit one package deeper, or whose classes ship
* inside a jar — which is to say, every adopter running a packaged application — was checked
* against nothing while the rule reported success.
*
* @throws GraphQlControllerContractException when the package cannot be located, so a rule can
* never pass by scanning nothing
@@ -147,17 +245,10 @@ public final class GraphQlResolverBoundaryRules {
Enumeration<URL> roots = classLoader.getResources(resourcePath);
while (roots.hasMoreElements()) {
URL root = roots.nextElement();
if (!"file".equals(root.getProtocol())) {
continue;
}
Path directory = Path.of(root.toURI());
try (Stream<Path> files = Files.list(directory)) {
files
.filter(Files::isRegularFile)
.map(path -> path.getFileName().toString())
.filter(name -> name.endsWith(".class"))
.map(name -> name.substring(0, name.length() - ".class".length()))
.forEach(name -> classNames.add(packageName + "." + name));
if ("file".equals(root.getProtocol())) {
collectFromDirectory(Path.of(root.toURI()), packageName, classNames);
} else if ("jar".equals(root.getProtocol())) {
collectFromJar(root, resourcePath, classNames);
}
}
} catch (IOException ex) {
@@ -175,10 +266,48 @@ public final class GraphQlResolverBoundaryRules {
for (String className : classNames) {
try {
classes.add(Class.forName(className, false, classLoader));
} catch (ClassNotFoundException ex) {
} catch (ClassNotFoundException | NoClassDefFoundError ex) {
throw new IllegalStateException("cannot load " + className, ex);
}
}
return List.copyOf(classes);
}
private static void collectFromDirectory(Path directory, String packageName, Set<String> into)
throws IOException {
try (Stream<Path> entries = Files.walk(directory)) {
entries
.filter(Files::isRegularFile)
.filter(path -> path.getFileName().toString().endsWith(".class"))
.forEach(
path -> {
String relative =
directory.relativize(path).toString().replace(File.separatorChar, '.');
String className =
packageName
+ "."
+ relative.substring(0, relative.length() - ".class".length());
if (!className.contains("$")) {
into.add(className);
}
});
}
}
private static void collectFromJar(URL root, String resourcePath, Set<String> into)
throws IOException {
JarURLConnection connection = (JarURLConnection) root.openConnection();
// The connection owns the jar file when caching is on, so it must not be closed here: doing so
// would shut a JarFile that the rest of the JVM is still reading from.
connection.setUseCaches(true);
JarFile jar = connection.getJarFile();
Enumeration<JarEntry> entries = jar.entries();
while (entries.hasMoreElements()) {
String name = entries.nextElement().getName();
if (!name.startsWith(resourcePath + "/") || !name.endsWith(".class") || name.contains("$")) {
continue;
}
into.add(name.substring(0, name.length() - ".class".length()).replace('/', '.'));
}
}
}
@@ -0,0 +1,112 @@
package dev.caskeleton.adapter.inbound.graphql.architecture;
import java.lang.reflect.GenericArrayType;
import java.lang.reflect.ParameterizedType;
import java.lang.reflect.Type;
import java.lang.reflect.TypeVariable;
import java.lang.reflect.WildcardType;
import java.util.ArrayDeque;
import java.util.Deque;
import java.util.LinkedHashSet;
import java.util.Set;
/**
* Every class a declared type actually reaches, generics included.
*
* <p>The boundary rules used to inspect {@code Method#getReturnType} and {@code Parameter#getType},
* which erase to the container: {@code List<OrderEntity>} reports {@code List}, {@code
* Mono<OrderEntity>} reports {@code Mono}, and {@code Optional<OrderRepository>} reports {@code
* Optional}. Every one of those passed a rule whose whole purpose was to notice the type inside —
* the wrapper is exactly what a resolver leaking an entity looks like in practice.
*
* <p>Traversal is bounded and cycle-guarded. A type variable can refer to its own bound ({@code <T
* extends Comparable<T>>}), and a rule that recursed into that would hang on a perfectly legal
* signature.
*/
public final class GraphQlTypeGraph {
/** Ceiling on how many distinct types one signature may reach before it is refused. */
public static final int MAXIMUM_VISITED_TYPES = 512;
private GraphQlTypeGraph() {}
/**
* Every concrete class reachable from a declared type.
*
* <p>Arrays contribute their component type, parameterized types contribute their raw type and
* every argument, wildcards and type variables contribute their bounds.
*
* @param type a declared return, parameter or field type
* @return the reachable classes, in first-seen order
*/
public static Set<Class<?>> referencedTypes(Type type) {
Set<Class<?>> found = new LinkedHashSet<>();
if (type == null) {
return found;
}
Set<Type> visited = new LinkedHashSet<>();
Deque<Type> pending = new ArrayDeque<>();
pending.push(type);
while (!pending.isEmpty() && visited.size() < MAXIMUM_VISITED_TYPES) {
Type current = pending.pop();
if (current == null || !visited.add(current)) {
continue;
}
if (current instanceof Class<?> raw) {
Class<?> component = raw;
while (component.isArray()) {
component = component.getComponentType();
}
if (!component.isPrimitive()) {
found.add(component);
}
} else if (current instanceof ParameterizedType parameterized) {
pending.push(parameterized.getRawType());
for (Type argument : parameterized.getActualTypeArguments()) {
pending.push(argument);
}
} else if (current instanceof GenericArrayType array) {
pending.push(array.getGenericComponentType());
} else if (current instanceof WildcardType wildcard) {
for (Type bound : wildcard.getUpperBounds()) {
pending.push(bound);
}
for (Type bound : wildcard.getLowerBounds()) {
pending.push(bound);
}
} else if (current instanceof TypeVariable<?> variable) {
for (Type bound : variable.getBounds()) {
pending.push(bound);
}
}
}
return found;
}
/**
* The raw class a declared type erases to, or {@code null}.
*
* <p>Used where the container itself is the subject — deciding whether a return type is a
* publisher, for instance — as opposed to what it contains.
*/
public static Class<?> rawType(Type type) {
if (type instanceof Class<?> raw) {
return raw;
}
if (type instanceof ParameterizedType parameterized) {
return rawType(parameterized.getRawType());
}
if (type instanceof GenericArrayType array) {
Class<?> component = rawType(array.getGenericComponentType());
return component == null ? null : component.arrayType();
}
if (type instanceof WildcardType wildcard) {
return wildcard.getUpperBounds().length == 0 ? null : rawType(wildcard.getUpperBounds()[0]);
}
if (type instanceof TypeVariable<?> variable) {
return variable.getBounds().length == 0 ? null : rawType(variable.getBounds()[0]);
}
return null;
}
}
@@ -59,7 +59,7 @@ public final class GraphQlPlatformActuatorEndpoint {
properties.environment().name(),
GraphQlHttpProfile.V1.name(),
supportedCapabilities,
properties.cursorKeyIds(),
properties.cursor().keyIds(),
registeredOperations,
registeredFetchProfiles);
}
@@ -1,20 +1,71 @@
package dev.caskeleton.adapter.inbound.graphql.autoconfigure;
import dev.caskeleton.adapter.inbound.graphql.build.GraphQlBuildModel;
import dev.caskeleton.adapter.inbound.graphql.api.GraphQlClientProfile;
import dev.caskeleton.adapter.inbound.graphql.architecture.GraphQlControllerInspector;
import dev.caskeleton.adapter.inbound.graphql.architecture.GraphQlResolverBoundaryRules;
import dev.caskeleton.adapter.inbound.graphql.architecture.GraphQlTransportTypeRules;
import dev.caskeleton.adapter.inbound.graphql.context.TenantContext;
import dev.caskeleton.adapter.inbound.graphql.cost.GraphQlComplexityCalculator;
import dev.caskeleton.adapter.inbound.graphql.cost.GraphQlCostCatalog;
import dev.caskeleton.adapter.inbound.graphql.cost.GraphQlDocumentShapeAnalyzer;
import dev.caskeleton.adapter.inbound.graphql.cost.GraphQlStructuralLimitPolicy;
import dev.caskeleton.adapter.inbound.graphql.cost.GraphQlStructuralLimits;
import dev.caskeleton.adapter.inbound.graphql.error.GraphQlExceptionResolver;
import dev.caskeleton.adapter.inbound.graphql.execution.BoundedPreparsedDocumentProvider;
import dev.caskeleton.adapter.inbound.graphql.execution.GraphQlExecutionPipeline;
import dev.caskeleton.adapter.inbound.graphql.execution.GraphQlExecutionPipelineValidator;
import dev.caskeleton.adapter.inbound.graphql.execution.GraphQlPreparsedCacheMetrics;
import dev.caskeleton.adapter.inbound.graphql.execution.GraphQlPreparsedCachePolicy;
import dev.caskeleton.adapter.inbound.graphql.http.GraphQlJsonStructurePolicy;
import dev.caskeleton.adapter.inbound.graphql.observation.GraphQlDataLoaderObservationConvention;
import dev.caskeleton.adapter.inbound.graphql.observation.GraphQlMetricCardinalityPolicy;
import dev.caskeleton.adapter.inbound.graphql.observation.GraphQlOperationNameCardinality;
import dev.caskeleton.adapter.inbound.graphql.observation.GraphQlRequestObservationConvention;
import dev.caskeleton.adapter.inbound.graphql.observation.GraphQlResolverObservationConvention;
import dev.caskeleton.adapter.inbound.graphql.observation.GraphQlSensitiveAttributeFilter;
import dev.caskeleton.adapter.inbound.graphql.policy.GraphQlClientPolicy;
import dev.caskeleton.adapter.inbound.graphql.runtime.GraphQlCostBudgetHandler;
import dev.caskeleton.adapter.inbound.graphql.runtime.GraphQlDataFetcherExceptionResolver;
import dev.caskeleton.adapter.inbound.graphql.runtime.GraphQlDocumentAuthorizationHandler;
import dev.caskeleton.adapter.inbound.graphql.runtime.GraphQlExecutionChain;
import dev.caskeleton.adapter.inbound.graphql.runtime.GraphQlOperationSelectionHandler;
import dev.caskeleton.adapter.inbound.graphql.runtime.GraphQlPlatformInstrumentation;
import dev.caskeleton.adapter.inbound.graphql.runtime.GraphQlPlatformWebInterceptor;
import dev.caskeleton.adapter.inbound.graphql.runtime.GraphQlPreparsedDocumentAdapter;
import dev.caskeleton.adapter.inbound.graphql.runtime.GraphQlPrincipalResolver;
import dev.caskeleton.adapter.inbound.graphql.runtime.GraphQlWireErrorMapper;
import dev.caskeleton.adapter.inbound.graphql.runtime.servlet.GraphQlRequestBodyLimitFilter;
import dev.caskeleton.adapter.inbound.graphql.scalar.GraphQlScalarWiringConfigurer;
import dev.caskeleton.adapter.inbound.graphql.schema.GraphQlMappingInspectionGate;
import java.util.Set;
import dev.caskeleton.adapter.inbound.graphql.schema.GraphQlScalarDefinition;
import dev.caskeleton.adapter.inbound.graphql.schema.GraphQlScalarManifest;
import dev.caskeleton.adapter.inbound.graphql.security.GraphQlAuthenticationContextFactory;
import dev.caskeleton.adapter.inbound.graphql.security.GraphQlAuthorizationInterceptor;
import dev.caskeleton.adapter.inbound.graphql.security.GraphQlAuthorizationPolicy;
import graphql.execution.instrumentation.Instrumentation;
import graphql.execution.preparsed.PreparsedDocumentEntry;
import graphql.schema.idl.SchemaPrinter;
import java.time.Clock;
import java.util.List;
import org.springframework.beans.factory.InitializingBean;
import org.springframework.beans.factory.ObjectProvider;
import org.springframework.boot.autoconfigure.AutoConfiguration;
import org.springframework.boot.autoconfigure.condition.ConditionalOnClass;
import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean;
import org.springframework.boot.autoconfigure.condition.ConditionalOnWebApplication;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.boot.graphql.autoconfigure.GraphQlAutoConfiguration;
import org.springframework.boot.graphql.autoconfigure.GraphQlProperties;
import org.springframework.boot.graphql.autoconfigure.GraphQlSourceBuilderCustomizer;
import org.springframework.boot.web.servlet.FilterRegistrationBean;
import org.springframework.context.ApplicationContext;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.core.Ordered;
import org.springframework.graphql.execution.DataFetcherExceptionResolver;
import org.springframework.graphql.execution.GraphQlSource;
import org.springframework.graphql.server.WebGraphQlInterceptor;
import org.springframework.stereotype.Controller;
import org.springframework.util.ClassUtils;
/**
* Assembles the Stable platform and validates it at startup (Stable plan Task 46).
@@ -22,11 +73,30 @@ import org.springframework.context.annotation.Configuration;
* <p>Composes Stable capabilities only. Advanced capabilities are opt-in and must never arrive
* through this configuration — an Advanced capability that activates because the Stable starter is
* on the classpath is exactly the accident the module boundary exists to prevent.
*
* <p>A real {@code @AutoConfiguration}, registered in {@code AutoConfiguration.imports}, and
* ordered after Spring Boot's own GraphQL auto-configuration. Both halves matter: without the
* registration the class was named auto-configuration while behaving as an ordinary
* {@code @Configuration}, so every {@code @ConditionalOnMissingBean} on it was evaluated before an
* adopter's beans existed and silently failed to back off. Without the ordering, this would race
* the framework's own {@code GraphQlSource} and schema beans.
*/
@Configuration(proxyBeanMethods = false)
@AutoConfiguration(after = GraphQlAutoConfiguration.class)
@EnableConfigurationProperties(GraphQlPlatformProperties.class)
public class GraphQlPlatformAutoConfiguration {
/** Client profile applied to a caller with no verified credential. */
public static final String DEFAULT_ANONYMOUS_PROFILE = "anonymous";
/**
* Tenant applied to a caller with no verified credential.
*
* <p>Declared as {@link TenantContext#system} rather than as a credential-derived tenant, because
* that is what it is: no credential was verified, so no tenant was proven. An adopter serving
* more than one tenant supplies a principal resolver, and the anonymous path then never runs.
*/
public static final String DEFAULT_ANONYMOUS_TENANT = "public";
/** The startup validator. */
@Bean
@ConditionalOnMissingBean
@@ -39,22 +109,377 @@ public class GraphQlPlatformAutoConfiguration {
*
* <p>An {@code InitializingBean} rather than a listener, so an unsafe configuration fails the
* refresh instead of being logged after the application has already begun serving.
*
* <p>This validates the adopter's configuration, which is the only part that varies at runtime.
* The Stable/Advanced module direction is a property of the source tree, not of a deployment, so
* it is enforced where it can actually fail — {@code GraphQlModuleBoundaryTest} scans the real
* imports at build time. Re-checking a compile-time constant during refresh proved nothing and
* cost every adopter a startup-time source scan.
*/
@Bean
public InitializingBean graphQlPlatformConfigurationCheck(
GraphQlPlatformProperties properties, GraphQlPlatformStartupValidator validator) {
GraphQlPlatformProperties properties,
GraphQlPlatformStartupValidator validator,
GraphQlExecutionPipeline pipeline,
GraphQlScalarWiringConfigurer scalarWiring,
GraphQlClientPolicy clientPolicy,
ObjectProvider<GraphQlRuntimeTransport> transport,
ObjectProvider<GraphQlProperties> frameworkProperties) {
return () -> {
validator.validate(properties);
GraphQlExecutionPipelineValidator.validate(GraphQlExecutionPipeline.stable());
verifyNoAdvancedCapabilityOnTheStableStarter();
GraphQlProperties framework = frameworkProperties.getIfAvailable();
validator.validateRuntime(
new GraphQlPlatformRuntime(
properties,
pipeline,
scalarWiring,
clientPolicy,
transport.getIfAvailable(() -> GraphQlRuntimeTransport.NONE),
framework == null ? null : framework.getSchema().getIntrospection().isEnabled(),
framework == null ? null : framework.getGraphiql().isEnabled()));
};
}
/** The Stable execution pipeline. */
/**
* Checks the resolvers this application actually registered.
*
* <p>The boundary rules could only be pointed at a package name, which meant the fixture packages
* in this leaf's own tests were the only thing ever checked. An adopter's controllers — the ones
* that can actually return an entity or inject a repository — were never inspected by anything.
* Reading the context is what closes that: it sees the beans that will serve requests, including
* the ones contributed by a library the adopter did not write.
*/
@Bean
public InitializingBean graphQlControllerBoundaryCheck(ApplicationContext context) {
return () -> {
List<Class<?>> controllers = graphQlControllerClasses(context);
if (controllers.isEmpty()) {
return;
}
GraphQlTransportTypeRules.assertTransportTypesOnly(controllers);
GraphQlResolverBoundaryRules.assertNoPersistenceAccess(controllers);
GraphQlTransportTypeRules.assertRawDataFetcherIsInfrastructureOnly(controllers);
};
}
/**
* The user classes of every {@code @Controller} bean that declares a GraphQL mapping.
*
* <p>Proxies are unwrapped: a transactional or secured controller is registered as a CGLIB
* subclass whose declared methods carry no annotations, and inspecting that would find nothing.
*/
private static List<Class<?>> graphQlControllerClasses(ApplicationContext context) {
List<Class<?>> controllers = new java.util.ArrayList<>();
for (String beanName : context.getBeanNamesForAnnotation(Controller.class)) {
Class<?> beanType = context.getType(beanName);
if (beanType == null) {
continue;
}
Class<?> userClass = ClassUtils.getUserClass(beanType);
for (java.lang.reflect.Method method : userClass.getDeclaredMethods()) {
if (!method.isSynthetic() && GraphQlControllerInspector.isResolver(method)) {
controllers.add(userClass);
break;
}
}
}
return List.copyOf(controllers);
}
/**
* Detects a servlet runtime.
*
* <p>Nested conditional configurations rather than a classpath probe in the validator: this is
* exactly the question {@code @ConditionalOnWebApplication} answers, and it answers it the same
* way Spring Boot decides which transport to wire.
*/
@Configuration(proxyBeanMethods = false)
@ConditionalOnWebApplication(type = ConditionalOnWebApplication.Type.SERVLET)
static class ServletRuntimeDetection {
@Bean
GraphQlRuntimeTransport graphQlRuntimeTransport() {
return GraphQlRuntimeTransport.SERVLET;
}
}
/** Detects a reactive runtime. */
@Configuration(proxyBeanMethods = false)
@ConditionalOnWebApplication(type = ConditionalOnWebApplication.Type.REACTIVE)
static class ReactiveRuntimeDetection {
@Bean
GraphQlRuntimeTransport graphQlRuntimeTransport() {
return GraphQlRuntimeTransport.REACTIVE;
}
}
/**
* Caps the raw request body upstream of the JSON decoder, on a servlet stack.
*
* <p>Loaded only where servlets exist. The leaf takes the servlet API as {@code compileOnly}, so
* on a reactive or non-web application this class is simply not on the classpath and the
* condition never matches — which is why the guard is {@code @ConditionalOnClass} as well as
* {@code @ConditionalOnWebApplication}.
*/
@Configuration(proxyBeanMethods = false)
@ConditionalOnClass(jakarta.servlet.Filter.class)
@ConditionalOnWebApplication(type = ConditionalOnWebApplication.Type.SERVLET)
static class ServletRequestBodyLimit {
/**
* Registers the cap with the highest precedence.
*
* <p>The whole point is to run before anything reads the body, so it has to precede the
* decoder, Spring Security's filters and any application filter that might buffer the request.
*/
@Bean
@ConditionalOnMissingBean
FilterRegistrationBean<GraphQlRequestBodyLimitFilter> graphQlRequestBodyLimitFilter(
GraphQlClientPolicy clientPolicy, ObjectProvider<GraphQlProperties> frameworkProperties) {
// The body carries the document, the variables and the extensions plus JSON framing, so the
// cap is their sum with room for the envelope rather than any one of them.
long maxBodyBytes =
(long) clientPolicy.maxDocumentBytes()
+ clientPolicy.maxVariablesBytes()
+ clientPolicy.maxVariablesBytes()
+ ENVELOPE_FRAMING_ALLOWANCE_BYTES;
// The endpoint path follows the framework when the framework is there. It is optional
// because the platform can be assembled without Boot's GraphQL auto-configuration — a slice
// test, or a composition root that wires the endpoint itself — and a cap that refuses to
// exist in those contexts would make the leaf harder to test than to secure.
String path = frameworkProperties.getIfAvailable(GraphQlProperties::new).getHttp().getPath();
FilterRegistrationBean<GraphQlRequestBodyLimitFilter> registration =
new FilterRegistrationBean<>(new GraphQlRequestBodyLimitFilter(path, maxBodyBytes));
registration.setOrder(Ordered.HIGHEST_PRECEDENCE);
registration.addUrlPatterns(path);
return registration;
}
}
/** Slack for the JSON envelope around the three sized fields. */
static final int ENVELOPE_FRAMING_ALLOWANCE_BYTES = 1024;
/**
* The execution pipeline, derived from the handlers that actually run.
*
* <p>Derived rather than declared: a hand-written stage list can describe a pipeline the code
* does not have, and this one cannot. The startup check above validates this value, so a chain
* assembled in the wrong order fails the refresh instead of serving requests.
*/
@Bean
@ConditionalOnMissingBean
public GraphQlExecutionPipeline graphQlExecutionPipeline() {
return GraphQlExecutionPipeline.stable();
public GraphQlExecutionPipeline graphQlExecutionPipeline(GraphQlExecutionChain chain) {
return chain.pipeline();
}
/** The clock every deadline and expiry check reads. */
@Bean
@ConditionalOnMissingBean(name = "graphQlPlatformClock")
public Clock graphQlPlatformClock() {
return Clock.systemUTC();
}
/**
* The limits applied to a request when the adopter has not registered a client policy.
*
* <p>Page size, complexity and introspection follow {@code backend.graphql.*}; the rest are the
* calibration starting points from the design. An adopter replaces this bean rather than editing
* a table of constants.
*/
@Bean
@ConditionalOnMissingBean
public GraphQlClientPolicy graphQlClientPolicy(GraphQlPlatformProperties properties) {
return GraphQlClientPolicy.defaults(
properties.limits().maximumPageSize(),
properties.limits().maximumComplexity(),
properties.console().introspectionEnabled());
}
/**
* How a request is authenticated.
*
* <p>Anonymous by default, because authentication belongs to the composition root. This is not a
* permissive default in itself: what an anonymous caller may do is decided by the authorization
* policy and the client profile, both of which are checked on every request.
*/
@Bean
@ConditionalOnMissingBean
public GraphQlPrincipalResolver graphQlPrincipalResolver() {
return GraphQlPrincipalResolver.anonymous();
}
/** The only factory allowed to build a request context. */
@Bean
@ConditionalOnMissingBean
public GraphQlAuthenticationContextFactory graphQlAuthenticationContextFactory(
Clock graphQlPlatformClock) {
return new GraphQlAuthenticationContextFactory(graphQlPlatformClock);
}
/**
* Coordinate authorization rules.
*
* <p>There is no safe default here, so production does not get one. Coordinate rules are
* application knowledge: a deny-by-default skeleton policy would answer nothing and adopters
* would replace it with an allow-all one, while an allow-by-default policy shipped into
* production would be an unauthorized endpoint. So development gets the permissive default that
* lets the schema be explored, and {@code backend.graphql.production=true} refuses to start
* without an explicit policy.
*/
@Bean
@ConditionalOnMissingBean
public GraphQlAuthorizationPolicy graphQlAuthorizationPolicy(
GraphQlPlatformProperties properties) {
if (properties.production()) {
throw new GraphQlPlatformConfigurationException(
List.of(
"backend.graphql.production=true requires an explicit GraphQlAuthorizationPolicy "
+ "bean; the platform has no application coordinates to authorize on its own"));
}
return GraphQlAuthorizationPolicy.builder().denyByDefault(false).build();
}
/** Registered field costs; unregistered coordinates fall back to a conservative weight. */
@Bean
@ConditionalOnMissingBean
public GraphQlCostCatalog graphQlCostCatalog() {
return GraphQlCostCatalog.of();
}
/** Document structure measurement. */
@Bean
@ConditionalOnMissingBean
public GraphQlDocumentShapeAnalyzer graphQlDocumentShapeAnalyzer() {
return new GraphQlDocumentShapeAnalyzer();
}
/** Per-field pricing, driven by the page policy the client policy declares. */
@Bean
@ConditionalOnMissingBean
public GraphQlComplexityCalculator graphQlComplexityCalculator(
GraphQlCostCatalog catalog, GraphQlClientPolicy clientPolicy) {
return new GraphQlComplexityCalculator(
catalog, clientPolicy.defaultPageSize(), clientPolicy.maxPageSize());
}
/** Structural ceilings derived from the client policy. */
@Bean
@ConditionalOnMissingBean
public GraphQlStructuralLimitPolicy graphQlStructuralLimitPolicy(
GraphQlClientPolicy clientPolicy) {
return new GraphQlStructuralLimitPolicy(GraphQlStructuralLimits.from(clientPolicy));
}
/** The executable chain: select the operation, authorize it, judge its cost. */
@Bean
@ConditionalOnMissingBean
public GraphQlExecutionChain graphQlExecutionChain(
GraphQlClientPolicy clientPolicy,
GraphQlAuthorizationPolicy authorizationPolicy,
GraphQlDocumentShapeAnalyzer analyzer,
GraphQlStructuralLimitPolicy structuralLimits,
GraphQlComplexityCalculator calculator,
Clock graphQlPlatformClock) {
return GraphQlExecutionChain.stable(
new GraphQlOperationSelectionHandler(clientPolicy),
new GraphQlDocumentAuthorizationHandler(
new GraphQlAuthorizationInterceptor(authorizationPolicy), analyzer, clientPolicy),
new GraphQlCostBudgetHandler(
analyzer, structuralLimits, calculator, clientPolicy, graphQlPlatformClock));
}
/**
* Puts the chain on the real execution path.
*
* <p>Spring for GraphQL picks up every {@code Instrumentation} bean, so this is what turns the
* policy objects from a catalogue into something a request has to pass.
*/
@Bean
@ConditionalOnMissingBean(GraphQlPlatformInstrumentation.class)
public Instrumentation graphQlPlatformInstrumentation(GraphQlExecutionChain chain) {
return new GraphQlPlatformInstrumentation(chain);
}
/**
* The application's deliberate failure mappings.
*
* <p>Empty by default, which means every unrecognised failure is masked. An adopter replaces this
* bean to give its own modelled failures a stable code rather than an opaque internal error.
*/
@Bean
@ConditionalOnMissingBean
public GraphQlExceptionResolver graphQlApplicationExceptionMappings() {
return GraphQlExceptionResolver.defaults();
}
/** The single vocabulary every client-visible error is produced from. */
@Bean
@ConditionalOnMissingBean
public GraphQlWireErrorMapper graphQlWireErrorMapper(GraphQlExceptionResolver mappings) {
return new GraphQlWireErrorMapper(mappings);
}
/** Adapts the mapper onto Spring's data-fetcher exception contract. */
@Bean
@ConditionalOnMissingBean(GraphQlDataFetcherExceptionResolver.class)
public DataFetcherExceptionResolver graphQlDataFetcherExceptionResolver(
GraphQlWireErrorMapper mapper) {
return new GraphQlDataFetcherExceptionResolver(mapper);
}
/** Shape ceilings for decoded {@code variables} and {@code extensions}. */
@Bean
@ConditionalOnMissingBean
public GraphQlJsonStructurePolicy graphQlJsonStructurePolicy(GraphQlClientPolicy clientPolicy) {
return GraphQlJsonStructurePolicy.from(clientPolicy);
}
/** Establishes the request context on the real {@code /graphql} endpoint. */
@Bean
@ConditionalOnMissingBean(GraphQlPlatformWebInterceptor.class)
public WebGraphQlInterceptor graphQlPlatformWebInterceptor(
GraphQlPrincipalResolver principalResolver,
GraphQlAuthenticationContextFactory contextFactory,
GraphQlClientPolicy clientPolicy,
GraphQlJsonStructurePolicy structurePolicy,
GraphQlPlatformProperties properties,
Clock graphQlPlatformClock) {
return new GraphQlPlatformWebInterceptor(
principalResolver,
contextFactory,
clientPolicy,
structurePolicy,
new GraphQlClientProfile(DEFAULT_ANONYMOUS_PROFILE),
TenantContext.system(DEFAULT_ANONYMOUS_TENANT),
properties.production(),
graphQlPlatformClock);
}
/** The approved scalar set; the wiring configurer refuses to wire anything absent from it. */
@Bean
@ConditionalOnMissingBean
public GraphQlScalarManifest graphQlScalarManifest() {
return GraphQlScalarManifest.of(
GraphQlScalarWiringConfigurer.stableScalars().keySet().stream()
.map(GraphQlScalarDefinition::named)
.toArray(GraphQlScalarDefinition[]::new));
}
/**
* Registers the approved custom scalars through Spring's supported wiring entry point.
*
* <p>Declared as the concrete type rather than as {@code RuntimeWiringConfigurer} so the startup
* check can ask it which scalars it will wire. Spring still picks it up as a configurer.
*/
@Bean
@ConditionalOnMissingBean
public GraphQlScalarWiringConfigurer graphQlScalarWiringConfigurer(
GraphQlScalarManifest manifest) {
return new GraphQlScalarWiringConfigurer(manifest);
}
/** The Stable schema mapping gate. */
@@ -83,8 +508,22 @@ public class GraphQlPlatformAutoConfiguration {
@Bean
@ConditionalOnMissingBean
public GraphQlRequestObservationConvention graphQlRequestObservationConvention(
GraphQlSensitiveAttributeFilter filter) {
return new GraphQlRequestObservationConvention(filter);
GraphQlSensitiveAttributeFilter filter, GraphQlOperationNameCardinality operationNames) {
return new GraphQlRequestObservationConvention(filter, operationNames);
}
/**
* Which operation names may become metric labels.
*
* <p>Defaults to the deployment's declared list, which is empty unless configured. Empty means
* every named operation collapses to one label: correct by default, and legible for any adopter
* who names the operations they care about.
*/
@Bean
@ConditionalOnMissingBean
public GraphQlOperationNameCardinality graphQlOperationNameCardinality(
GraphQlPlatformProperties properties) {
return new GraphQlOperationNameCardinality(properties.observedOperationNames());
}
/** The resolver observation convention. */
@@ -103,16 +542,58 @@ public class GraphQlPlatformAutoConfiguration {
return new GraphQlDataLoaderObservationConvention(filter);
}
private static void verifyNoAdvancedCapabilityOnTheStableStarter() {
Set<String> advanced = GraphQlBuildModel.advancedModules();
GraphQlBuildModel.stableDependencyEdges()
.forEach(
(module, dependencies) -> {
if (dependencies.stream().anyMatch(advanced::contains)) {
throw new GraphQlPlatformConfigurationException(
java.util.List.of(
"stable module " + module + " depends on an advanced capability"));
}
});
/**
* The preparsed document cache bounds.
*
* <p>Idle expiry and both size bounds come from configuration rather than from a constant, so an
* adopter whose documents are large can trade entries for weight without forking the platform.
*/
@Bean
@ConditionalOnMissingBean
public GraphQlPreparsedCachePolicy graphQlPreparsedCachePolicy(
GraphQlPlatformProperties properties) {
return new GraphQlPreparsedCachePolicy(
properties.limits().preparsedCacheEntries(),
properties.limits().preparsedCacheWeight(),
properties.limits().preparsedCacheExpireAfterAccess());
}
/** The bounded parse/validate cache. */
@Bean
@ConditionalOnMissingBean
public BoundedPreparsedDocumentProvider<PreparsedDocumentEntry> graphQlPreparsedDocumentCache(
GraphQlPreparsedCachePolicy policy, Clock clock) {
return new BoundedPreparsedDocumentProvider<>(
policy, new GraphQlPreparsedCacheMetrics(), clock);
}
/**
* Hands the bounded cache to graphql-java, which is the only thing that consults one.
*
* <p>The schema hash is resolved through an {@code ObjectProvider} on first use: this customizer
* runs while the {@code GraphQlSource} is still being built, so asking for the schema here would
* be asking for the bean currently under construction.
*/
@Bean
@ConditionalOnMissingBean(name = "graphQlPreparsedDocumentCustomizer")
public GraphQlSourceBuilderCustomizer graphQlPreparsedDocumentCustomizer(
BoundedPreparsedDocumentProvider<PreparsedDocumentEntry> cache,
ObjectProvider<GraphQlSource> graphQlSource,
GraphQlPlatformProperties properties) {
GraphQlPreparsedDocumentAdapter adapter =
new GraphQlPreparsedDocumentAdapter(
cache, () -> schemaContractHash(graphQlSource), properties.validationPolicyVersion());
return builder ->
builder.configureGraphQl(graphQl -> graphQl.preparsedDocumentProvider(adapter));
}
private static String schemaContractHash(ObjectProvider<GraphQlSource> graphQlSource) {
GraphQlSource source = graphQlSource.getIfAvailable();
if (source == null) {
// A cache keyed on an unknown schema would survive a schema change, which is the one thing
// the schema part of the key exists to prevent. Partition it instead of guessing.
return "schema-unavailable";
}
return GraphQlPreparsedDocumentAdapter.sha256(new SchemaPrinter().print(source.schema()));
}
}
@@ -1,12 +1,20 @@
package dev.caskeleton.adapter.inbound.graphql.autoconfigure;
import dev.caskeleton.adapter.inbound.graphql.execution.GraphQlExecutionProfile;
import java.time.Duration;
import java.util.Set;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.boot.context.properties.bind.DefaultValue;
/**
* The platform's configuration surface (design §23).
*
* <p>Every default is declared with {@link DefaultValue}, which is the only kind of default the
* binder actually applies. A primitive with no annotation binds to zero, and this record's zeros —
* a page size of nothing, a complexity budget of nothing — are exactly the values the startup
* validator refuses, so the platform used to refuse to start until an operator supplied two numbers
* that have perfectly good defaults.
*
* <p>The unsupported capabilities appear here as explicit flags rather than being absent. A
* deployment that tries to enable multipart upload, HTTP array batching, a request-wide transaction
* or automatic repository exposure should fail at startup with a clear reason — silently ignoring
@@ -15,84 +23,202 @@ import org.springframework.boot.context.properties.ConfigurationProperties;
* @param production whether production rules apply
* @param environment environment governing introspection and GraphiQL
* @param executionProfile runtime execution profile
* @param graphiqlEnabled whether GraphiQL is served
* @param introspectionEnabled whether introspection is answered
* @param maximumPageSize largest connection page size
* @param maximumComplexity largest accepted complexity score
* @param cursorKeyIds signing key identities for cursors
* @param multipartUploadEnabled unsupported; must stay false
* @param httpArrayBatchEnabled unsupported; must stay false
* @param requestWideTransactionEnabled unsupported; must stay false
* @param repositoryAutoExposureEnabled unsupported outside the Advanced compatibility capability
* @param responseCacheEnabled unsupported; must stay false
* @param advancedCapabilitiesOnStableStarter whether Advanced modules leaked into the Stable
* starter
* @param console query console and introspection exposure
* @param limits page and cost ceilings
* @param cursor cursor signing key ring
* @param unsupported capabilities this platform deliberately does not implement
* @param unbridgedBlockingResolvers resolvers that block without an approved bridge
*/
@ConfigurationProperties("backend.graphql")
public record GraphQlPlatformProperties(
boolean production,
GraphQlPlatformEnvironment environment,
GraphQlExecutionProfile executionProfile,
boolean graphiqlEnabled,
boolean introspectionEnabled,
int maximumPageSize,
long maximumComplexity,
Set<String> cursorKeyIds,
boolean multipartUploadEnabled,
boolean httpArrayBatchEnabled,
boolean requestWideTransactionEnabled,
boolean repositoryAutoExposureEnabled,
boolean responseCacheEnabled,
boolean advancedCapabilitiesOnStableStarter,
Set<String> unbridgedBlockingResolvers) {
@DefaultValue("false") boolean production,
@DefaultValue("PRODUCTION_PUBLIC") GraphQlPlatformEnvironment environment,
@DefaultValue("BLOCKING_MVC") GraphQlExecutionProfile executionProfile,
@DefaultValue Console console,
@DefaultValue Limits limits,
@DefaultValue Cursor cursor,
@DefaultValue Unsupported unsupported,
@DefaultValue("v1") String validationPolicyVersion,
@DefaultValue Set<String> observedOperationNames,
@DefaultValue Set<String> unbridgedBlockingResolvers) {
public GraphQlPlatformProperties {
// Belt and braces for programmatic construction: the binder honours @DefaultValue, but this
// record is also built directly in tests and by adopters composing a policy in Java.
environment = environment == null ? GraphQlPlatformEnvironment.PRODUCTION_PUBLIC : environment;
executionProfile =
executionProfile == null ? GraphQlExecutionProfile.BLOCKING_MVC : executionProfile;
cursorKeyIds = cursorKeyIds == null ? Set.of() : Set.copyOf(cursorKeyIds);
console = console == null ? Console.disabled() : console;
limits = limits == null ? Limits.defaults() : limits;
cursor = cursor == null ? Cursor.none() : cursor;
unsupported = unsupported == null ? Unsupported.none() : unsupported;
// Part of the preparsed cache key: bumping it invalidates every cached validation, which is
// what an adopter needs when their own validation rules change without the schema changing.
validationPolicyVersion =
validationPolicyVersion == null || validationPolicyVersion.isBlank()
? "v1"
: validationPolicyVersion;
// The operation names allowed to become metric labels. Empty collapses them all, which is the
// safe default: a cardinality bound that has to be switched on is one nobody has switched on.
observedOperationNames =
observedOperationNames == null ? Set.of() : Set.copyOf(observedOperationNames);
unbridgedBlockingResolvers =
unbridgedBlockingResolvers == null ? Set.of() : Set.copyOf(unbridgedBlockingResolvers);
}
/**
* Query console and schema disclosure.
*
* @param graphiqlEnabled whether GraphiQL is served
* @param introspectionEnabled whether introspection is answered
*/
public record Console(
@DefaultValue("false") boolean graphiqlEnabled,
@DefaultValue("false") boolean introspectionEnabled) {
/**
* Neither the console nor introspection, which is the only safe default for an unknown host.
*/
public static Console disabled() {
return new Console(false, false);
}
}
/**
* Page, cost and cache ceilings.
*
* @param maximumPageSize largest connection page size
* @param maximumComplexity largest accepted pre-execution complexity score
* @param preparsedCacheEntries largest number of cached parsed documents
* @param preparsedCacheWeight largest total cached document weight, in characters
* @param preparsedCacheExpireAfterAccess how long an unused cached document is kept
*/
public record Limits(
@DefaultValue("100") int maximumPageSize,
@DefaultValue("10000") long maximumComplexity,
@DefaultValue("1000") long preparsedCacheEntries,
@DefaultValue("10000000") long preparsedCacheWeight,
@DefaultValue("30m") Duration preparsedCacheExpireAfterAccess) {
/** The declared defaults, for programmatic construction. */
public static Limits defaults() {
return new Limits(100, 10_000, 1_000, 10_000_000, Duration.ofMinutes(30));
}
}
/**
* The cursor signing key ring.
*
* @param keyIds signing key identities; the keys themselves never appear in configuration
*/
public record Cursor(@DefaultValue Set<String> keyIds) {
public Cursor {
keyIds = keyIds == null ? Set.of() : Set.copyOf(keyIds);
}
/** An empty key ring, which production refuses to start with. */
public static Cursor none() {
return new Cursor(Set.of());
}
/** A key ring with the given identities. */
public static Cursor of(Set<String> keyIds) {
return new Cursor(keyIds);
}
}
/**
* Capabilities the platform does not implement.
*
* <p>Present as flags so enabling one fails the boot instead of being ignored.
*
* @param multipartUpload GraphQL multipart upload
* @param httpArrayBatch HTTP array batching
* @param requestWideTransaction one database transaction spanning a whole request
* @param repositoryAutoExposure automatic repository exposure as GraphQL fields
* @param responseCache cross-request response caching
* @param advancedCapabilitiesOnStableStarter Advanced modules reachable from the Stable starter
*/
public record Unsupported(
@DefaultValue("false") boolean multipartUpload,
@DefaultValue("false") boolean httpArrayBatch,
@DefaultValue("false") boolean requestWideTransaction,
@DefaultValue("false") boolean repositoryAutoExposure,
@DefaultValue("false") boolean responseCache,
@DefaultValue("false") boolean advancedCapabilitiesOnStableStarter) {
/** Nothing unsupported enabled. */
public static Unsupported none() {
return new Unsupported(false, false, false, false, false, false);
}
/** Returns a copy with one capability toggled, for startup-validation tests. */
public Unsupported with(String capability, boolean enabled) {
return new Unsupported(
"multipart".equals(capability) ? enabled : multipartUpload,
"arrayBatch".equals(capability) ? enabled : httpArrayBatch,
"requestWideTransaction".equals(capability) ? enabled : requestWideTransaction,
"repositoryAutoExposure".equals(capability) ? enabled : repositoryAutoExposure,
"responseCache".equals(capability) ? enabled : responseCache,
"advancedOnStableStarter".equals(capability)
? enabled
: advancedCapabilitiesOnStableStarter);
}
}
/** Safe production defaults. */
public static GraphQlPlatformProperties productionDefaults() {
return new GraphQlPlatformProperties(
true,
GraphQlPlatformEnvironment.PRODUCTION_INTERNAL,
GraphQlExecutionProfile.BLOCKING_MVC,
false,
false,
100,
10_000,
Set.of("cursor-key-1"),
false,
false,
false,
false,
false,
false,
Console.disabled(),
Limits.defaults(),
Cursor.of(Set.of("cursor-key-1")),
Unsupported.none(),
"v1",
Set.of(),
Set.of());
}
/** Returns a copy with GraphiQL enabled or disabled. */
public GraphQlPlatformProperties withGraphiqlEnabled(boolean enabled) {
return withConsole(new Console(enabled, console.introspectionEnabled()));
}
/** Returns a copy with introspection enabled or disabled. */
public GraphQlPlatformProperties withIntrospectionEnabled(boolean enabled) {
return withConsole(new Console(console.graphiqlEnabled(), enabled));
}
/** Returns a copy with a different console policy. */
public GraphQlPlatformProperties withConsole(Console replacement) {
return new GraphQlPlatformProperties(
production,
environment,
executionProfile,
enabled,
introspectionEnabled,
maximumPageSize,
maximumComplexity,
cursorKeyIds,
multipartUploadEnabled,
httpArrayBatchEnabled,
requestWideTransactionEnabled,
repositoryAutoExposureEnabled,
responseCacheEnabled,
advancedCapabilitiesOnStableStarter,
replacement,
limits,
cursor,
unsupported,
validationPolicyVersion,
observedOperationNames,
unbridgedBlockingResolvers);
}
/** Returns a copy with different limits. */
public GraphQlPlatformProperties withLimits(Limits replacement) {
return new GraphQlPlatformProperties(
production,
environment,
executionProfile,
console,
replacement,
cursor,
unsupported,
validationPolicyVersion,
observedOperationNames,
unbridgedBlockingResolvers);
}
@@ -102,17 +228,12 @@ public record GraphQlPlatformProperties(
production,
environment,
executionProfile,
graphiqlEnabled,
introspectionEnabled,
maximumPageSize,
maximumComplexity,
Set.copyOf(keyIds),
multipartUploadEnabled,
httpArrayBatchEnabled,
requestWideTransactionEnabled,
repositoryAutoExposureEnabled,
responseCacheEnabled,
advancedCapabilitiesOnStableStarter,
console,
limits,
Cursor.of(keyIds),
unsupported,
validationPolicyVersion,
observedOperationNames,
unbridgedBlockingResolvers);
}
@@ -122,19 +243,12 @@ public record GraphQlPlatformProperties(
production,
environment,
executionProfile,
graphiqlEnabled,
introspectionEnabled,
maximumPageSize,
maximumComplexity,
cursorKeyIds,
"multipart".equals(capability) ? enabled : multipartUploadEnabled,
"arrayBatch".equals(capability) ? enabled : httpArrayBatchEnabled,
"requestWideTransaction".equals(capability) ? enabled : requestWideTransactionEnabled,
"repositoryAutoExposure".equals(capability) ? enabled : repositoryAutoExposureEnabled,
"responseCache".equals(capability) ? enabled : responseCacheEnabled,
"advancedOnStableStarter".equals(capability)
? enabled
: advancedCapabilitiesOnStableStarter,
console,
limits,
cursor,
unsupported.with(capability, enabled),
validationPolicyVersion,
observedOperationNames,
unbridgedBlockingResolvers);
}
@@ -144,17 +258,12 @@ public record GraphQlPlatformProperties(
production,
environment,
executionProfile,
graphiqlEnabled,
introspectionEnabled,
maximumPageSize,
maximumComplexity,
cursorKeyIds,
multipartUploadEnabled,
httpArrayBatchEnabled,
requestWideTransactionEnabled,
repositoryAutoExposureEnabled,
responseCacheEnabled,
advancedCapabilitiesOnStableStarter,
console,
limits,
cursor,
unsupported,
validationPolicyVersion,
observedOperationNames,
Set.copyOf(coordinates));
}
@@ -164,17 +273,12 @@ public record GraphQlPlatformProperties(
production,
environment,
profile,
graphiqlEnabled,
introspectionEnabled,
maximumPageSize,
maximumComplexity,
cursorKeyIds,
multipartUploadEnabled,
httpArrayBatchEnabled,
requestWideTransactionEnabled,
repositoryAutoExposureEnabled,
responseCacheEnabled,
advancedCapabilitiesOnStableStarter,
console,
limits,
cursor,
unsupported,
validationPolicyVersion,
observedOperationNames,
unbridgedBlockingResolvers);
}
}
@@ -0,0 +1,43 @@
package dev.caskeleton.adapter.inbound.graphql.autoconfigure;
import dev.caskeleton.adapter.inbound.graphql.execution.GraphQlExecutionPipeline;
import dev.caskeleton.adapter.inbound.graphql.policy.GraphQlClientPolicy;
import dev.caskeleton.adapter.inbound.graphql.scalar.GraphQlScalarWiringConfigurer;
import java.util.Objects;
/**
* What the context actually assembled, gathered so it can be validated as one thing.
*
* <p>The platform's beans are all replaceable, which is the point of
* {@code @ConditionalOnMissingBean} — and also the risk. An adopter can supply a pipeline with
* authorization after execution, a scalar manifest naming a scalar with no coercion, or a client
* policy whose page ceiling is higher than the one the operator configured. None of those fail at
* wiring time; they fail at request time, in production, quietly.
*
* @param properties the bound configuration
* @param pipeline the pipeline that will run, derived from the registered handler chain
* @param scalarWiring the scalar wiring that will be applied to the schema
* @param clientPolicy the limits a request will be measured against
* @param transport the server this context is actually running on
* @param frameworkIntrospectionEnabled {@code spring.graphql.schema.introspection.enabled}, or
* {@code null} when the framework properties are not on the classpath
* @param frameworkGraphiqlEnabled {@code spring.graphql.graphiql.enabled}, or {@code null} when the
* framework properties are not on the classpath
*/
public record GraphQlPlatformRuntime(
GraphQlPlatformProperties properties,
GraphQlExecutionPipeline pipeline,
GraphQlScalarWiringConfigurer scalarWiring,
GraphQlClientPolicy clientPolicy,
GraphQlRuntimeTransport transport,
Boolean frameworkIntrospectionEnabled,
Boolean frameworkGraphiqlEnabled) {
public GraphQlPlatformRuntime {
Objects.requireNonNull(properties, "properties are required");
Objects.requireNonNull(pipeline, "pipeline is required");
Objects.requireNonNull(scalarWiring, "scalar wiring is required");
Objects.requireNonNull(clientPolicy, "client policy is required");
transport = transport == null ? GraphQlRuntimeTransport.NONE : transport;
}
}
@@ -32,42 +32,43 @@ public final class GraphQlPlatformStartupValidator {
// Reported once even when both the production flag and the environment forbid it, so a single
// misconfiguration does not appear as two problems.
if (properties.graphiqlEnabled()
if (properties.console().graphiqlEnabled()
&& (properties.production() || !properties.environment().graphiqlAllowed())) {
problems.add(
"GraphiQL must not be enabled in " + properties.environment() + " or in production");
}
if (properties.production() && properties.cursorKeyIds().isEmpty()) {
if (properties.production() && properties.cursor().keyIds().isEmpty()) {
problems.add("a cursor signing key is required; unsigned cursors are client-editable");
}
if (properties.introspectionEnabled() && !properties.environment().introspectionAllowed()) {
if (properties.console().introspectionEnabled()
&& !properties.environment().introspectionAllowed()) {
problems.add("introspection is not permitted in " + properties.environment());
}
if (properties.maximumPageSize() < 1) {
if (properties.limits().maximumPageSize() < 1) {
problems.add("maximum page size must be positive");
}
if (properties.maximumComplexity() < 1) {
if (properties.limits().maximumComplexity() < 1) {
problems.add("maximum complexity must be positive");
}
if (properties.multipartUploadEnabled()) {
if (properties.unsupported().multipartUpload()) {
problems.add(
"GraphQL multipart upload is unsupported; use the Fileserver upload reservation");
}
if (properties.httpArrayBatchEnabled()) {
if (properties.unsupported().httpArrayBatch()) {
problems.add("HTTP array batching is unsupported");
}
if (properties.requestWideTransactionEnabled()) {
if (properties.unsupported().requestWideTransaction()) {
problems.add("request-wide database transactions are unsupported; use one mutation use case");
}
if (properties.repositoryAutoExposureEnabled()) {
if (properties.unsupported().repositoryAutoExposure()) {
problems.add(
"automatic repository exposure is unsupported outside the Advanced compatibility capability");
}
if (properties.responseCacheEnabled()) {
if (properties.unsupported().responseCache()) {
problems.add("response caching is unsupported until an actor/tenant cache key model exists");
}
if (properties.advancedCapabilitiesOnStableStarter()) {
if (properties.unsupported().advancedCapabilitiesOnStableStarter()) {
problems.add("the Stable starter must not activate Advanced capabilities");
}
if (properties.executionProfile() == GraphQlExecutionProfile.REACTIVE_WEBFLUX
@@ -78,4 +79,105 @@ public final class GraphQlPlatformStartupValidator {
}
return List.copyOf(problems);
}
/**
* Validates the beans the context actually assembled, not the ones the platform ships.
*
* @throws GraphQlPlatformConfigurationException listing every problem found
*/
public void validateRuntime(GraphQlPlatformRuntime runtime) {
List<String> problems = runtimeProblems(runtime);
if (!problems.isEmpty()) {
throw new GraphQlPlatformConfigurationException(problems);
}
}
/**
* Problems with the assembled runtime, in a deterministic order.
*
* <p>Every check here is on an injected bean rather than on a platform constant. Validating
* {@code GraphQlExecutionPipeline.stable()} would prove the platform's own default is well formed
* and say nothing about the pipeline an adopter actually replaced it with — which is the only one
* that will serve requests.
*/
public List<String> runtimeProblems(GraphQlPlatformRuntime runtime) {
List<String> problems = new ArrayList<>();
problems.addAll(
dev.caskeleton.adapter.inbound.graphql.execution.GraphQlExecutionPipelineValidator.problems(
runtime.pipeline()));
if (!runtime.transport().supports(runtime.properties().executionProfile())) {
problems.add(
"backend.graphql.execution-profile is "
+ runtime.properties().executionProfile()
+ " but this context runs on "
+ runtime.transport()
+ "; the composition root chooses the server and the two must agree");
}
// Independent of the declared profile: on an event loop, a resolver that blocks without a
// bridge stalls every other request sharing the thread. MIXED_CONTROLLED is accepted on a
// reactive transport precisely because the crossings are declared, so an undeclared one is the
// condition that makes the profile a lie.
if (runtime.transport() == GraphQlRuntimeTransport.REACTIVE
&& !runtime.properties().unbridgedBlockingResolvers().isEmpty()) {
problems.add(
"resolvers block without an executor bridge on a reactive transport: "
+ new java.util.TreeSet<>(runtime.properties().unbridgedBlockingResolvers()));
}
try {
runtime.scalarWiring().wiredScalars();
} catch (RuntimeException unwirable) {
problems.add("scalar manifest cannot be wired: " + unwirable.getMessage());
}
if (runtime.clientPolicy().maxPageSize() > runtime.properties().limits().maximumPageSize()) {
problems.add(
"client policy maxPageSize ("
+ runtime.clientPolicy().maxPageSize()
+ ") exceeds backend.graphql.limits.maximum-page-size ("
+ runtime.properties().limits().maximumPageSize()
+ ")");
}
if (runtime.clientPolicy().maxComplexity()
> runtime.properties().limits().maximumComplexity()) {
problems.add(
"client policy maxComplexity ("
+ runtime.clientPolicy().maxComplexity()
+ ") exceeds backend.graphql.limits.maximum-complexity ("
+ runtime.properties().limits().maximumComplexity()
+ ")");
}
if (runtime.clientPolicy().introspectionAllowed()
&& !runtime.properties().console().introspectionEnabled()) {
problems.add(
"client policy allows introspection while backend.graphql.console.introspection-enabled "
+ "is false; one of the two is not what the operator configured");
}
// The framework flags are what a request actually meets. A platform that says introspection is
// off while `spring.graphql.schema.introspection.enabled` says it is on has two answers to one
// question, and the client gets the framework's.
if (runtime.frameworkIntrospectionEnabled() != null
&& runtime.frameworkIntrospectionEnabled()
!= runtime.properties().console().introspectionEnabled()) {
problems.add(
"backend.graphql.console.introspection-enabled ("
+ runtime.properties().console().introspectionEnabled()
+ ") contradicts spring.graphql.schema.introspection.enabled ("
+ runtime.frameworkIntrospectionEnabled()
+ ")");
}
if (runtime.frameworkGraphiqlEnabled() != null
&& runtime.frameworkGraphiqlEnabled() != runtime.properties().console().graphiqlEnabled()) {
problems.add(
"backend.graphql.console.graphiql-enabled ("
+ runtime.properties().console().graphiqlEnabled()
+ ") contradicts spring.graphql.graphiql.enabled ("
+ runtime.frameworkGraphiqlEnabled()
+ ")");
}
return List.copyOf(problems);
}
}
@@ -0,0 +1,44 @@
package dev.caskeleton.adapter.inbound.graphql.autoconfigure;
import dev.caskeleton.adapter.inbound.graphql.execution.GraphQlExecutionProfile;
/**
* The server this context is actually running on, as opposed to the one it was configured for.
*
* <p>The two used to be unable to disagree, because the leaf shipped an embedded servlet container
* itself — which made {@code REACTIVE_WEBFLUX} a profile nobody could ever run. Now the composition
* root chooses the server, so the two can disagree, and something has to notice.
*/
public enum GraphQlRuntimeTransport {
/** A servlet application: Spring MVC owns the {@code /graphql} route. */
SERVLET,
/** A reactive application: WebFlux owns the {@code /graphql} route. */
REACTIVE,
/** No web server — a plain application context, a test slice, or a batch process. */
NONE;
/**
* Whether an execution profile can run on this transport.
*
* <p>{@link #NONE} accepts every profile: a context with no server has no route to contradict,
* and failing there would break every non-web test that assembles the platform.
*
* <p>{@code MIXED_CONTROLLED} runs on both by definition — it is the profile for a deployment
* that crosses between blocking and reactive work through declared bridges. Accepting it here is
* not a loophole: a reactive context still refuses to start with resolvers that block without a
* bridge, whichever profile is declared.
*/
public boolean supports(GraphQlExecutionProfile profile) {
if (this == NONE || profile == GraphQlExecutionProfile.MIXED_CONTROLLED) {
return true;
}
return switch (this) {
case SERVLET -> profile == GraphQlExecutionProfile.BLOCKING_MVC;
case REACTIVE -> profile == GraphQlExecutionProfile.REACTIVE_WEBFLUX;
case NONE -> true;
};
}
}
@@ -177,11 +177,65 @@ public enum GraphQlChangeKind {
GraphQlCompatibilityImpact.BREAKING,
"removing a scalar breaks existing operations"),
/** A scalar's declared serialization contract changed. */
SCALAR_COERCION_CHANGED(
/**
* A scalar's SDL declaration changed.
*
* <p>Not a coercion change. Whether {@code DateTime} still parses the same strings is a property
* of its {@code Coercing} implementation, which the SDL does not contain — swapping the codec
* while leaving the SDL alone was invisible here, and editing the description raised a false
* breaking change. Coercion compatibility belongs to the scalar manifest's codec version, and
* this kind now says only what it can see.
*/
SCALAR_DECLARATION_CHANGED(
GraphQlCompatibilityImpact.REVIEW_REQUIRED,
GraphQlCompatibilityImpact.REVIEW_REQUIRED,
"a changed scalar declaration needs its manifest codec version checked for a coercion change"),
/**
* A type kept its name but changed kind, such as {@code type Foo} becoming {@code input Foo}.
*
* <p>Compared before anything else. Field-by-field comparison of two different kinds produces
* plausible per-field findings and misses the only one that matters: every operation naming the
* type breaks, whatever its fields now are.
*/
TYPE_KIND_CHANGED(
GraphQlCompatibilityImpact.BREAKING,
GraphQlCompatibilityImpact.BREAKING,
"changing a scalar coercion requires a new scalar or a new scalar manifest version"),
"a type that changed kind breaks every operation naming it"),
/**
* A default value was removed from an argument or input field.
*
* <p>Breaking for a non-null input: the default was the reason omitting it was legal.
*/
INPUT_DEFAULT_REMOVED(
GraphQlCompatibilityImpact.BREAKING,
GraphQlCompatibilityImpact.BREAKING,
"removing a default makes a previously omissible input required"),
/** A default value changed, so an omitted input now means something different. */
INPUT_DEFAULT_CHANGED(
GraphQlCompatibilityImpact.REVIEW_REQUIRED,
GraphQlCompatibilityImpact.REVIEW_REQUIRED,
"a changed default silently changes what an omitted input means"),
/** A default value was added, which makes a previously required input omissible. */
INPUT_DEFAULT_ADDED(
GraphQlCompatibilityImpact.COMPATIBLE,
GraphQlCompatibilityImpact.REVIEW_REQUIRED,
"a new default is accepting but changes generated models"),
/**
* The directives applied to a schema element changed.
*
* <p>Distinct from a directive definition change. {@code @deprecated} appearing on a field, or
* {@code @oneOf} disappearing from an input, changes what clients are told and what the engine
* enforces, and comparing only definitions could not see either.
*/
APPLIED_DIRECTIVE_CHANGED(
GraphQlCompatibilityImpact.REVIEW_REQUIRED,
GraphQlCompatibilityImpact.REVIEW_REQUIRED,
"a change to applied directives changes advertised or enforced behaviour"),
/** A directive definition appeared. */
DIRECTIVE_ADDED(
@@ -58,12 +58,14 @@ public final class GraphQlSchemaComparator {
List<GraphQlSchemaChange> changes = new ArrayList<>();
compareTypePresence(previous, candidate, changes);
compareTypeKinds(previous, candidate, changes);
compareOutputTypes(previous, candidate, changes);
compareInputTypes(previous, candidate, changes);
compareEnums(previous, candidate, changes);
compareUnions(previous, candidate, changes);
compareScalars(previous, candidate, changes);
compareDirectives(previous, candidate, changes);
compareAppliedDirectives(previous, candidate, changes);
return new GraphQlCompatibilityReport(changes.stream().sorted(DETERMINISTIC_ORDER).toList());
}
@@ -84,6 +86,121 @@ public final class GraphQlSchemaComparator {
.forEach(name -> changes.add(GraphQlSchemaChange.of(name, GraphQlChangeKind.TYPE_ADDED)));
}
/**
* Reports types that kept their name and changed kind.
*
* <p>Runs before the per-kind comparisons, which only ever look at types of their own kind and
* would therefore report {@code type Foo -> input Foo} as a removal from one map and an addition
* to another, or as nothing at all.
*/
private static void compareTypeKinds(
TypeDefinitionRegistry previous,
TypeDefinitionRegistry candidate,
List<GraphQlSchemaChange> changes) {
Map<String, TypeDefinition> previousTypes = previous.types();
Map<String, TypeDefinition> candidateTypes = candidate.types();
for (String name : new TreeSet<>(previousTypes.keySet())) {
TypeDefinition after = candidateTypes.get(name);
if (after == null) {
continue;
}
if (!previousTypes.get(name).getClass().equals(after.getClass())) {
changes.add(GraphQlSchemaChange.of(name, GraphQlChangeKind.TYPE_KIND_CHANGED));
}
}
}
/**
* Reports changes to the directives applied to types and their fields.
*
* <p>Applied directives, not definitions: {@code @deprecated} appearing on a field and
* {@code @oneOf} disappearing from an input are both behaviour changes that leave every directive
* definition untouched.
*/
private static void compareAppliedDirectives(
TypeDefinitionRegistry previous,
TypeDefinitionRegistry candidate,
List<GraphQlSchemaChange> changes) {
Map<String, TypeDefinition> previousTypes = previous.types();
Map<String, TypeDefinition> candidateTypes = candidate.types();
for (String name : new TreeSet<>(previousTypes.keySet())) {
TypeDefinition before = previousTypes.get(name);
TypeDefinition after = candidateTypes.get(name);
if (after == null || !before.getClass().equals(after.getClass())) {
continue;
}
if (!appliedDirectives(before).equals(appliedDirectives(after))) {
changes.add(GraphQlSchemaChange.of(name, GraphQlChangeKind.APPLIED_DIRECTIVE_CHANGED));
}
if (before instanceof ImplementingTypeDefinition<?> beforeType
&& after instanceof ImplementingTypeDefinition<?> afterType) {
compareFieldDirectives(name, beforeType, afterType, changes);
}
}
}
private static void compareFieldDirectives(
String typeName,
ImplementingTypeDefinition<?> before,
ImplementingTypeDefinition<?> after,
List<GraphQlSchemaChange> changes) {
Map<String, FieldDefinition> candidateFields =
byName(after.getFieldDefinitions(), FieldDefinition::getName);
for (FieldDefinition field : before.getFieldDefinitions()) {
FieldDefinition candidateField = candidateFields.get(field.getName());
if (candidateField == null) {
continue;
}
if (!appliedDirectives(field).equals(appliedDirectives(candidateField))) {
changes.add(
GraphQlSchemaChange.of(
typeName + "." + field.getName(), GraphQlChangeKind.APPLIED_DIRECTIVE_CHANGED));
}
}
}
/** Applied directives, printed and sorted so declaration order is not a change. */
private static Set<String> appliedDirectives(graphql.language.Node<?> node) {
List<graphql.language.Directive> directives =
node instanceof graphql.language.DirectivesContainer<?> container
? container.getDirectives()
: List.of();
return directives.stream()
.map(GraphQlSchemaComparator::print)
.collect(Collectors.toCollection(TreeSet::new));
}
/**
* Reports a change to an input's default value.
*
* <p>A default is part of the input contract: removing one from a non-null input makes every
* request that omitted the field invalid, and changing one silently changes what omitting it
* means. Neither shows up as a type change, which is all the comparator used to look at.
*/
private static void compareInputDefault(
String coordinate,
InputValueDefinition before,
InputValueDefinition after,
List<GraphQlSchemaChange> changes) {
String beforeDefault =
before.getDefaultValue() == null ? null : print(before.getDefaultValue());
String afterDefault = after.getDefaultValue() == null ? null : print(after.getDefaultValue());
if (java.util.Objects.equals(beforeDefault, afterDefault)) {
return;
}
if (beforeDefault == null) {
changes.add(GraphQlSchemaChange.of(coordinate, GraphQlChangeKind.INPUT_DEFAULT_ADDED));
} else if (afterDefault == null) {
changes.add(GraphQlSchemaChange.of(coordinate, GraphQlChangeKind.INPUT_DEFAULT_REMOVED));
} else {
changes.add(GraphQlSchemaChange.of(coordinate, GraphQlChangeKind.INPUT_DEFAULT_CHANGED));
}
}
private static void compareOutputTypes(
TypeDefinitionRegistry previous,
TypeDefinitionRegistry candidate,
@@ -268,6 +385,7 @@ public final class GraphQlSchemaComparator {
GraphQlChangeKind.INPUT_FIELD_RELAXED,
GraphQlChangeKind.INPUT_FIELD_TYPE_CHANGED,
changes);
compareInputDefault(coordinate, previousFields.get(fieldName), candidateField, changes);
}
candidateFields.keySet().stream()
@@ -396,7 +514,7 @@ public final class GraphQlSchemaComparator {
}
if (!print(previousScalars.get(name)).equals(print(after))) {
changes.add(
GraphQlSchemaChange.of("scalar " + name, GraphQlChangeKind.SCALAR_COERCION_CHANGED));
GraphQlSchemaChange.of("scalar " + name, GraphQlChangeKind.SCALAR_DECLARATION_CHANGED));
}
}
candidateScalars.keySet().stream()
@@ -0,0 +1,60 @@
package dev.caskeleton.adapter.inbound.graphql.context;
import java.time.Instant;
import java.util.Objects;
import java.util.Optional;
/**
* The request context reduced to values an application command can carry.
*
* <p>An anti-corruption boundary, and the direction is the whole point. {@link
* GraphQlRequestContext} is an inbound transport type: it knows about client profiles, operation
* ids and GraphQL locales. Handing it to a use case would make {@code application-core} — and then
* every persistence and HTTP-client adapter the use case reaches — compile against the GraphQL
* boundary, so a change to a transport concern would ripple to the database layer and a non-GraphQL
* caller could not construct a command at all.
*
* <p>What crosses instead is this: four values with no transport vocabulary, which a REST, gRPC or
* scheduled caller can produce just as easily.
*
* @param actorId the acting identity, or {@code null} for an unauthenticated caller
* @param tenantId the tenant the work belongs to
* @param deadline when the caller stops waiting
* @param traceId correlation identity for logs and downstream calls
*/
public record GraphQlCommandAttribution(
String actorId, String tenantId, Instant deadline, String traceId) {
public GraphQlCommandAttribution {
Objects.requireNonNull(tenantId, "tenant is required");
Objects.requireNonNull(traceId, "trace id is required");
// Required, because the request context it comes from cannot exist without one. A command that
// travelled without a deadline would run until something else timed out, which is the point at
// which the caller has already given up and the work is being done for nobody.
Objects.requireNonNull(deadline, "deadline is required");
}
/**
* Maps a request context onto the values a command carries.
*
* @param context the inbound request context
*/
public static GraphQlCommandAttribution from(GraphQlRequestContext context) {
Objects.requireNonNull(context, "request context is required");
return new GraphQlCommandAttribution(
context.actor().authenticated() ? context.actor().value() : null,
context.tenant().value(),
context.deadline().value(),
context.traceId());
}
/** The acting identity, absent for an unauthenticated caller. */
public Optional<String> actor() {
return Optional.ofNullable(actorId);
}
/** When the caller stops waiting. */
public Instant deadlineAt() {
return deadline;
}
}
@@ -0,0 +1,295 @@
package dev.caskeleton.adapter.inbound.graphql.cost;
import graphql.language.Argument;
import graphql.language.Definition;
import graphql.language.Document;
import graphql.language.Field;
import graphql.language.FragmentDefinition;
import graphql.language.FragmentSpread;
import graphql.language.InlineFragment;
import graphql.language.IntValue;
import graphql.language.OperationDefinition;
import graphql.language.Selection;
import graphql.language.SelectionSet;
import graphql.language.Value;
import graphql.language.VariableReference;
import graphql.schema.GraphQLFieldDefinition;
import graphql.schema.GraphQLFieldsContainer;
import graphql.schema.GraphQLList;
import graphql.schema.GraphQLNonNull;
import graphql.schema.GraphQLObjectType;
import graphql.schema.GraphQLSchema;
import graphql.schema.GraphQLType;
import java.math.BigInteger;
import java.util.ArrayDeque;
import java.util.Deque;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.Objects;
/**
* Scores a whole document against the cost catalogue, before any resolver runs.
*
* <p>{@link GraphQlComplexityCalculator} prices one field; this walks the selection tree so a
* request has a single number to judge. The walk is schema-aware on purpose: a coordinate is {@code
* TypeName.fieldName}, and the type half only exists once each selection set has been resolved
* against the schema. Guessing it from the operation root would price {@code order { customer {
* orders { … } } }} as three root fields and miss the multiplication entirely.
*
* <p>Cardinality comes from the request, not from the schema: a connection's children are
* multiplied by the effective page size, and a page size supplied through a variable is resolved
* from the request variables rather than assumed to be the default. That is the difference between
* a budget and a suggestion — {@code first: $n} would otherwise cost the same at 1 and at 1000.
*
* <p>Traversal is bounded, and fragment cycles are cut by tracking the expansion path. This runs on
* documents that have passed validation, but the bound stays because the scorer is also used from
* the pre-execution path where a hostile document is exactly what it is meant to price.
*/
public final class GraphQlDocumentComplexityScorer {
/** Prefix that marks an introspection field, which is gated rather than priced. */
public static final String INTROSPECTION_FIELD_PREFIX = "__";
private final GraphQlComplexityCalculator calculator;
private final int maximumVisitedNodes;
/** Creates a scorer with the default traversal budget. */
public GraphQlDocumentComplexityScorer(GraphQlComplexityCalculator calculator) {
this(calculator, 200_000);
}
/**
* Creates a scorer.
*
* @param calculator per-field pricing
* @param maximumVisitedNodes traversal budget; exceeding it rejects the document
*/
public GraphQlDocumentComplexityScorer(
GraphQlComplexityCalculator calculator, int maximumVisitedNodes) {
this.calculator = Objects.requireNonNull(calculator, "complexity calculator is required");
if (maximumVisitedNodes < 1) {
throw new IllegalArgumentException("traversal budget must be positive");
}
this.maximumVisitedNodes = maximumVisitedNodes;
}
/**
* Scores one operation of a document.
*
* @param schema schema the document was validated against
* @param document the parsed document
* @param operation the selected operation
* @param variables the request variables, used to resolve page sizes
* @throws GraphQlComplexityRejectedException when a requested page exceeds the maximum
* @throws GraphQlStructuralLimitViolation when traversal exceeds the node budget
*/
public GraphQlComplexityResult score(
GraphQLSchema schema,
Document document,
OperationDefinition operation,
Map<String, Object> variables) {
Objects.requireNonNull(schema, "schema is required");
Objects.requireNonNull(document, "document is required");
Objects.requireNonNull(operation, "operation is required");
Map<String, FragmentDefinition> fragments = new LinkedHashMap<>();
for (Definition<?> definition : document.getDefinitions()) {
if (definition instanceof FragmentDefinition fragment) {
fragments.put(fragment.getName(), fragment);
}
}
GraphQLObjectType root = rootType(schema, operation);
if (root == null) {
// The schema does not define this operation type; validation rejects the document, and
// pricing a tree with no root would be inventing a number.
return new GraphQlComplexityResult(0);
}
long total =
selectionSetCost(
schema,
root,
operation.getSelectionSet(),
fragments,
variables == null ? Map.of() : variables,
new Counter(),
new ArrayDeque<>());
return new GraphQlComplexityResult(total);
}
private long selectionSetCost(
GraphQLSchema schema,
GraphQLFieldsContainer parent,
SelectionSet selectionSet,
Map<String, FragmentDefinition> fragments,
Map<String, Object> variables,
Counter counter,
Deque<String> expansionPath) {
if (selectionSet == null || parent == null) {
return 0;
}
long total = 0;
for (Selection<?> selection : selectionSet.getSelections()) {
counter.visit(maximumVisitedNodes);
if (selection instanceof Field field) {
total =
Math.addExact(
total,
fieldCost(schema, parent, field, fragments, variables, counter, expansionPath));
} else if (selection instanceof InlineFragment inlineFragment) {
GraphQLFieldsContainer target =
inlineFragment.getTypeCondition() == null
? parent
: fieldsContainer(schema, inlineFragment.getTypeCondition().getName());
total =
Math.addExact(
total,
selectionSetCost(
schema,
target,
inlineFragment.getSelectionSet(),
fragments,
variables,
counter,
expansionPath));
} else if (selection instanceof FragmentSpread spread) {
FragmentDefinition fragment = fragments.get(spread.getName());
// A fragment already on this path is a cycle. Validation rejects it, but the scorer must
// terminate on its own or the defence becomes the denial of service.
if (fragment == null || expansionPath.contains(spread.getName())) {
continue;
}
GraphQLFieldsContainer target =
fragment.getTypeCondition() == null
? parent
: fieldsContainer(schema, fragment.getTypeCondition().getName());
expansionPath.push(spread.getName());
total =
Math.addExact(
total,
selectionSetCost(
schema,
target,
fragment.getSelectionSet(),
fragments,
variables,
counter,
expansionPath));
expansionPath.pop();
}
}
return total;
}
private long fieldCost(
GraphQLSchema schema,
GraphQLFieldsContainer parent,
Field field,
Map<String, FragmentDefinition> fragments,
Map<String, Object> variables,
Counter counter,
Deque<String> expansionPath) {
if (field.getName().startsWith(INTROSPECTION_FIELD_PREFIX)) {
// Introspection is an allow/deny decision made by the authorization stage. Pricing it here
// would let an allowed introspection query consume the data budget it was never spending.
return 0;
}
GraphQLFieldDefinition definition = parent.getFieldDefinition(field.getName());
String coordinate = parent.getName() + "." + field.getName();
GraphQLFieldsContainer childContainer =
definition == null ? null : fieldsContainer(unwrap(definition.getType()));
long childCost =
selectionSetCost(
schema,
childContainer,
field.getSelectionSet(),
fragments,
variables,
counter,
expansionPath);
Integer first = pageArgument(field, "first", variables);
Integer last = pageArgument(field, "last", variables);
if (first != null || last != null) {
return calculator.connectionCost(coordinate, first, last, childCost).total();
}
return calculator.fieldCost(coordinate, childCost).total();
}
private static Integer pageArgument(Field field, String name, Map<String, Object> variables) {
for (Argument argument : field.getArguments()) {
if (!argument.getName().equals(name)) {
continue;
}
return intValue(argument.getValue(), variables);
}
return null;
}
private static Integer intValue(Value<?> value, Map<String, Object> variables) {
if (value instanceof IntValue intValue) {
BigInteger raw = intValue.getValue();
// A literal outside int range cannot be a page size; treating it as the maximum lets the
// calculator reject it rather than silently overflowing to something affordable.
return raw.bitLength() >= Integer.SIZE ? Integer.MAX_VALUE : raw.intValue();
}
if (value instanceof VariableReference reference) {
Object supplied = variables.get(reference.getName());
if (supplied instanceof Number number) {
long asLong = number.longValue();
return asLong > Integer.MAX_VALUE ? Integer.MAX_VALUE : (int) asLong;
}
}
return null;
}
private static GraphQLObjectType rootType(GraphQLSchema schema, OperationDefinition operation) {
OperationDefinition.Operation kind =
operation.getOperation() == null
? OperationDefinition.Operation.QUERY
: operation.getOperation();
return switch (kind) {
case QUERY -> schema.getQueryType();
case MUTATION -> schema.getMutationType();
case SUBSCRIPTION -> schema.getSubscriptionType();
};
}
private static GraphQLFieldsContainer fieldsContainer(GraphQLSchema schema, String typeName) {
GraphQLType type = schema.getType(typeName);
return fieldsContainer(type);
}
private static GraphQLFieldsContainer fieldsContainer(GraphQLType type) {
GraphQLType unwrapped = unwrap(type);
return unwrapped instanceof GraphQLFieldsContainer container ? container : null;
}
private static GraphQLType unwrap(GraphQLType type) {
GraphQLType current = type;
while (current instanceof GraphQLNonNull nonNull) {
current = nonNull.getWrappedType();
}
while (current instanceof GraphQLList list) {
current = unwrap(list.getWrappedType());
}
return current;
}
private static final class Counter {
private int visited;
void visit(int budget) {
if (++visited > budget) {
throw GraphQlStructuralLimitViolation.of("COMPLEXITY_TRAVERSAL", visited, budget);
}
}
}
}
@@ -16,10 +16,13 @@ import graphql.language.SelectionSet;
import graphql.language.Value;
import graphql.parser.Parser;
import java.util.ArrayDeque;
import java.util.ArrayList;
import java.util.Deque;
import java.util.LinkedHashMap;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
/**
* Measures a document's structure before execution (design §18).
@@ -61,11 +64,27 @@ public final class GraphQlDocumentShapeAnalyzer {
}
/**
* Measures a parsed document.
* Measures every operation in a document.
*
* <p>Kept for callers that judge a document before one operation has been chosen. Once an
* operation is selected, {@link #analyze(Document, OperationDefinition)} is the honest
* measurement: summing operations the request will not run charges a client for a document it
* only sent one part of, and — worse in the other direction — averages away the one that matters.
*
* @throws GraphQlStructuralLimitViolation when traversal exceeds the node budget
*/
public GraphQlDocumentShape analyze(Document document) {
return analyze(document, null);
}
/**
* Measures one selected operation and the fragments it can actually reach.
*
* @param document the parsed document
* @param operation the selected operation, or {@code null} to measure every operation
* @throws GraphQlStructuralLimitViolation when traversal exceeds the node budget
*/
public GraphQlDocumentShape analyze(Document document, OperationDefinition operation) {
Map<String, FragmentDefinition> fragments = new LinkedHashMap<>();
int operationCount = 0;
for (Definition<?> definition : document.getDefinitions()) {
@@ -77,29 +96,63 @@ public final class GraphQlDocumentShapeAnalyzer {
}
Counters counters = new Counters();
for (Definition<?> definition : document.getDefinitions()) {
if (definition instanceof OperationDefinition operation) {
walk(operation.getSelectionSet(), fragments, counters, 1, new ArrayDeque<>());
}
List<OperationDefinition> measured =
operation != null ? List.of(operation) : operations(document);
Set<String> reachableFragments = new LinkedHashSet<>();
for (OperationDefinition candidate : measured) {
walk(
candidate.getSelectionSet(),
fragments,
counters,
1,
new ArrayDeque<>(),
reachableFragments);
}
return new GraphQlDocumentShape(
counters.depth,
counters.fields,
counters.aliases,
fragments.size(),
// Fragments the walk could actually reach. Counting every definition would charge a client
// for fragments the selected operation never spreads, and let an unreachable one raise the
// count until an honest request is refused.
operation != null ? reachableFragments.size() : fragments.size(),
counters.fragmentSpreads,
operationCount,
operation != null ? 1 : operationCount,
counters.inputNestingDepth);
}
/** Whether a document selects any introspection field. */
/** Whether any operation in a document selects an introspection field. */
public boolean selectsIntrospection(Document document) {
return document.getDefinitions().stream()
.anyMatch(
definition ->
definition instanceof OperationDefinition operation
&& selectsIntrospection(operation.getSelectionSet()));
return selectsIntrospection(document, null);
}
/**
* Whether the selected operation reaches an introspection field.
*
* <p>Named fragments are expanded. The gate used to walk only fields and inline fragments, so
* {@code query Q { ...I } fragment I on Query { __schema { types { name } } }} passed a check
* whose entire purpose was to stop that query — the same class already expanded fragments for
* shape counting, which made the omission invisible.
*
* @param document the parsed document
* @param operation the selected operation, or {@code null} to check every operation
*/
public boolean selectsIntrospection(Document document, OperationDefinition operation) {
Map<String, FragmentDefinition> fragments = new LinkedHashMap<>();
for (Definition<?> definition : document.getDefinitions()) {
if (definition instanceof FragmentDefinition fragment) {
fragments.put(fragment.getName(), fragment);
}
}
List<OperationDefinition> candidates =
operation != null ? List.of(operation) : operations(document);
for (OperationDefinition candidate : candidates) {
if (selectsIntrospection(candidate.getSelectionSet(), fragments, new ArrayDeque<>())) {
return true;
}
}
return false;
}
/**
@@ -108,24 +161,60 @@ public final class GraphQlDocumentShapeAnalyzer {
* @throws GraphQlStructuralLimitViolation when introspection is selected but not permitted
*/
public void verifyIntrospection(Document document, boolean introspectionAllowed) {
if (!introspectionAllowed && selectsIntrospection(document)) {
verifyIntrospection(document, null, introspectionAllowed);
}
/**
* Rejects introspection reached by the selected operation.
*
* @throws GraphQlStructuralLimitViolation when introspection is selected but not permitted
*/
public void verifyIntrospection(
Document document, OperationDefinition operation, boolean introspectionAllowed) {
if (!introspectionAllowed && selectsIntrospection(document, operation)) {
throw GraphQlStructuralLimitViolation.of("INTROSPECTION", 1, 0);
}
}
private boolean selectsIntrospection(SelectionSet selectionSet) {
private static List<OperationDefinition> operations(Document document) {
List<OperationDefinition> operations = new ArrayList<>();
for (Definition<?> definition : document.getDefinitions()) {
if (definition instanceof OperationDefinition operation) {
operations.add(operation);
}
}
return operations;
}
private boolean selectsIntrospection(
SelectionSet selectionSet,
Map<String, FragmentDefinition> fragments,
Deque<String> expansionPath) {
if (selectionSet == null) {
return false;
}
for (Selection<?> selection : selectionSet.getSelections()) {
if (selection instanceof Field field) {
if (field.getName().startsWith(INTROSPECTION_FIELD_PREFIX)
|| selectsIntrospection(field.getSelectionSet())) {
|| selectsIntrospection(field.getSelectionSet(), fragments, expansionPath)) {
return true;
}
} else if (selection instanceof InlineFragment inlineFragment) {
if (selectsIntrospection(inlineFragment.getSelectionSet(), fragments, expansionPath)) {
return true;
}
} else if (selection instanceof FragmentSpread spread) {
FragmentDefinition fragment = fragments.get(spread.getName());
if (fragment == null || expansionPath.contains(spread.getName())) {
continue;
}
expansionPath.push(spread.getName());
boolean found = selectsIntrospection(fragment.getSelectionSet(), fragments, expansionPath);
expansionPath.pop();
if (found) {
return true;
}
} else if (selection instanceof InlineFragment inlineFragment
&& selectsIntrospection(inlineFragment.getSelectionSet())) {
return true;
}
}
return false;
@@ -136,7 +225,8 @@ public final class GraphQlDocumentShapeAnalyzer {
Map<String, FragmentDefinition> fragments,
Counters counters,
int depth,
Deque<String> expansionPath) {
Deque<String> expansionPath,
Set<String> reachableFragments) {
if (selectionSet == null) {
return;
@@ -153,17 +243,36 @@ public final class GraphQlDocumentShapeAnalyzer {
}
counters.inputNestingDepth =
Math.max(counters.inputNestingDepth, argumentNestingDepth(field.getArguments()));
walk(field.getSelectionSet(), fragments, counters, depth + 1, expansionPath);
walk(
field.getSelectionSet(),
fragments,
counters,
depth + 1,
expansionPath,
reachableFragments);
} else if (selection instanceof InlineFragment inlineFragment) {
walk(inlineFragment.getSelectionSet(), fragments, counters, depth + 1, expansionPath);
walk(
inlineFragment.getSelectionSet(),
fragments,
counters,
depth + 1,
expansionPath,
reachableFragments);
} else if (selection instanceof FragmentSpread spread) {
counters.fragmentSpreads++;
FragmentDefinition fragment = fragments.get(spread.getName());
// A fragment already on this expansion path is a cycle; expanding it again would not
// terminate, and the document is rejected by validation anyway.
if (fragment != null && !expansionPath.contains(spread.getName())) {
reachableFragments.add(spread.getName());
expansionPath.push(spread.getName());
walk(fragment.getSelectionSet(), fragments, counters, depth, expansionPath);
walk(
fragment.getSelectionSet(),
fragments,
counters,
depth,
expansionPath,
reachableFragments);
expansionPath.pop();
}
}
@@ -13,9 +13,13 @@ import java.util.function.BiFunction;
* Runs a batch load in ordered chunks under one context and budget (design §13).
*
* <p>Every chunk receives the same actor, tenant and deadline: a chunk that ran with a different
* scope would produce a result set mixing two tenants inside one logical batch. The budget is
* checked between chunks so a batch that has already exhausted the request deadline stops instead
* of issuing more work.
* scope would produce a result set mixing two tenants inside one logical batch.
*
* <p>The budget is checked before and after every chunk. Checking only before it meant the last
* chunk could run unbounded — a batch that started with a millisecond left was allowed to issue one
* more downstream call and wait for it however long it took, which is the case the budget exists
* for. Bounding the call itself is the loader's job, and the deadline is handed to it for that;
* this check is what stops the batch continuing past a budget that has already gone.
*/
public final class GraphQlBatchExecutor {
@@ -56,14 +60,21 @@ public final class GraphQlBatchExecutor {
Map<K, V> loaded = new LinkedHashMap<>();
for (List<K> chunk : chunker.chunk(keys)) {
if (Duration.between(started, clock.instant()).compareTo(budget) > 0) {
throw new GraphQlBatchTimeoutException(policy.loaderName().value());
}
requireBudget(started, budget);
loaded.putAll(loadChunk.apply(chunk, context));
// After, too: a chunk that overran the budget must not have its result used and must not be
// followed by another one.
requireBudget(started, budget);
}
return Map.copyOf(loaded);
}
private void requireBudget(java.time.Instant started, Duration budget) {
if (Duration.between(started, clock.instant()).compareTo(budget) > 0) {
throw new GraphQlBatchTimeoutException(policy.loaderName().value());
}
}
/**
* The budget for this batch: the loader's own timeout, never more than the request has left.
*
@@ -11,6 +11,12 @@ import java.util.Set;
* <p>Keys the loader did not return become {@link GraphQlBatchValue.Missing}, and keys it failed on
* become {@link GraphQlBatchValue.Failed}. Flattening both to null is the defect this mapper exists
* to prevent — it makes a dependency outage indistinguishable from empty data.
*
* <p>A null value means <em>missing</em>, in both loader shapes. The two used to disagree: a mapped
* loader returning {@code {k: null}} produced {@code Present(null)} while an ordered loader
* returning {@code [null]} produced {@code Missing}, so the same "no value for this key" answer
* meant two different things depending on which loader shape a field happened to use — and only one
* of them triggered the missing-key policy.
*/
public final class GraphQlBatchResultMapper {
@@ -39,19 +45,42 @@ public final class GraphQlBatchResultMapper {
public <K, V> GraphQlBatchResult<K, V> map(
List<K> keys, Map<K, V> loaded, Set<K> failedKeys, String errorCode) {
requireOnlyRequestedKeys(keys, loaded);
var result = new LinkedHashMap<K, GraphQlBatchValue<V>>();
for (K key : keys) {
V value = loaded.get(key);
if (failedKeys.contains(key)) {
result.put(key, GraphQlBatchValue.failed(errorCode));
} else if (loaded.containsKey(key)) {
result.put(key, GraphQlBatchValue.present(loaded.get(key)));
} else {
} else if (value == null) {
result.put(key, GraphQlBatchValue.missing());
} else {
result.put(key, GraphQlBatchValue.present(value));
}
}
return new GraphQlBatchResult<>(result);
}
/**
* Refuses a result that answers keys nobody asked for.
*
* <p>Matching cardinality is not the same as matching keys. A loader that returned the right
* number of entries under different keys used to pass: every requested key resolved to {@code
* Missing}, which reads as "the rows do not exist" rather than "the loader answered the wrong
* question", and the field quietly rendered null.
*/
private static <K, V> void requireOnlyRequestedKeys(List<K> keys, Map<K, V> loaded) {
Set<K> requested = new java.util.LinkedHashSet<>(keys);
Set<K> unrequested =
loaded.keySet().stream()
.filter(key -> !requested.contains(key))
.collect(java.util.stream.Collectors.toCollection(java.util.LinkedHashSet::new));
if (!unrequested.isEmpty()) {
throw new IllegalArgumentException(
"loader returned " + unrequested.size() + " key(s) that were not requested");
}
}
/**
* Maps ordered-loader output.
*
@@ -73,6 +102,7 @@ public final class GraphQlBatchResultMapper {
var result = new LinkedHashMap<K, GraphQlBatchValue<V>>();
for (int index = 0; index < keys.size(); index++) {
V value = orderedValues.get(index);
// Same rule as the mapped shape: null is the absence of a value, not a present null.
result.put(
keys.get(index),
value == null ? GraphQlBatchValue.missing() : GraphQlBatchValue.present(value));
@@ -1,8 +1,13 @@
package dev.caskeleton.adapter.inbound.graphql.execution;
import java.time.Clock;
import java.time.Instant;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.Objects;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.CompletionException;
import java.util.concurrent.ConcurrentHashMap;
import java.util.function.Function;
/**
@@ -14,7 +19,15 @@ import java.util.function.Function;
* data leak.
*
* <p>Eviction is least-recently-used and bounded by both entry count and total document weight,
* because the key space is client-controlled.
* because the key space is client-controlled. Entries also expire after a period without access, so
* a burst of one-off documents does not hold memory until enough later traffic pushes it out.
*
* <p>A miss parses outside the lock. The whole method used to be {@code synchronized}, which made
* one slow parse block every other request including the ones that would have hit the cache — the
* cache's own miss path became the contention point it existed to remove. Concurrent misses on the
* <em>same</em> key still parse once: they are the one case where waiting is cheaper than parsing,
* and letting a cold popular document be parsed by every arriving request is how a cache turns a
* deploy into a CPU spike.
*
* @param <D> the cached parsed-document type
*/
@@ -22,7 +35,11 @@ public final class BoundedPreparsedDocumentProvider<D> {
private final GraphQlPreparsedCachePolicy policy;
private final GraphQlPreparsedCacheMetrics metrics;
private final Clock clock;
private final Map<GraphQlPreparsedCacheKey, Entry<D>> cache;
private final ConcurrentHashMap<GraphQlPreparsedCacheKey, CompletableFuture<D>> inFlight =
new ConcurrentHashMap<>();
private final Object lock = new Object();
private long weight;
/**
@@ -30,11 +47,13 @@ public final class BoundedPreparsedDocumentProvider<D> {
*
* @param policy cache bounds
* @param metrics counters
* @param clock the clock idle expiry is measured against
*/
public BoundedPreparsedDocumentProvider(
GraphQlPreparsedCachePolicy policy, GraphQlPreparsedCacheMetrics metrics) {
this.policy = Objects.requireNonNull(policy);
this.metrics = Objects.requireNonNull(metrics);
GraphQlPreparsedCachePolicy policy, GraphQlPreparsedCacheMetrics metrics, Clock clock) {
this.policy = Objects.requireNonNull(policy, "cache policy is required");
this.metrics = Objects.requireNonNull(metrics, "cache metrics are required");
this.clock = Objects.requireNonNull(clock, "clock is required");
this.cache = new LinkedHashMap<>(16, 0.75f, true);
}
@@ -45,33 +64,60 @@ public final class BoundedPreparsedDocumentProvider<D> {
* @param documentWeight the document's size, used for the weight bound
* @param parseAndValidate invoked on a miss
*/
public synchronized D getDocument(
public D getDocument(
GraphQlPreparsedCacheKey key,
long documentWeight,
Function<GraphQlPreparsedCacheKey, D> parseAndValidate) {
Entry<D> cached = cache.get(key);
Objects.requireNonNull(key, "cache key is required");
Objects.requireNonNull(parseAndValidate, "parse function is required");
Instant now = clock.instant();
D cached = lookup(key, now);
if (cached != null) {
metrics.recordHit();
return cached.document();
return cached;
}
CompletableFuture<D> mine = new CompletableFuture<>();
CompletableFuture<D> leader = inFlight.putIfAbsent(key, mine);
if (leader != null) {
metrics.recordCoalesced();
return await(leader);
}
metrics.recordMiss();
D document = parseAndValidate.apply(key);
cache.put(key, new Entry<>(document, Math.max(1, documentWeight)));
weight += Math.max(1, documentWeight);
evictIfNeeded();
return document;
try {
D document = parseAndValidate.apply(key);
store(key, document, documentWeight, clock.instant());
mine.complete(document);
return document;
} catch (RuntimeException failure) {
// Failures are not cached: an invalid document is the client's to fix, and remembering the
// rejection would make a later schema deploy unable to accept a document it now supports.
mine.completeExceptionally(failure);
throw failure;
} finally {
inFlight.remove(key, mine);
}
}
/** Entries currently cached. */
public synchronized int size() {
return cache.size();
/** Entries currently cached, after expiring anything idle. */
public int size() {
Instant now = clock.instant();
synchronized (lock) {
expireIdle(now);
return cache.size();
}
}
/** Total weight currently cached. */
public synchronized long weight() {
return weight;
/** Total weight currently cached, after expiring anything idle. */
public long weight() {
Instant now = clock.instant();
synchronized (lock) {
expireIdle(now);
return weight;
}
}
/** The counters. */
@@ -79,6 +125,45 @@ public final class BoundedPreparsedDocumentProvider<D> {
return metrics;
}
private D lookup(GraphQlPreparsedCacheKey key, Instant now) {
synchronized (lock) {
expireIdle(now);
Entry<D> cached = cache.get(key);
if (cached == null) {
return null;
}
// Access refreshes the idle deadline, which is what expire-after-access means: a document
// still being used stays, and only the ones nobody asks for any more leave.
cache.put(key, new Entry<>(cached.document(), cached.weight(), now));
return cached.document();
}
}
private void store(GraphQlPreparsedCacheKey key, D document, long documentWeight, Instant now) {
long entryWeight = Math.max(1, documentWeight);
synchronized (lock) {
Entry<D> previous = cache.put(key, new Entry<>(document, entryWeight, now));
if (previous != null) {
weight -= previous.weight();
}
weight += entryWeight;
expireIdle(now);
evictIfNeeded();
}
}
private void expireIdle(Instant now) {
var entries = cache.entrySet().iterator();
while (entries.hasNext()) {
Map.Entry<GraphQlPreparsedCacheKey, Entry<D>> entry = entries.next();
if (!now.isBefore(entry.getValue().lastAccessAt().plus(policy.expireAfterAccess()))) {
weight -= entry.getValue().weight();
entries.remove();
metrics.recordExpiry();
}
}
}
private void evictIfNeeded() {
while (cache.size() > policy.maximumEntries() || weight > policy.maximumWeight()) {
var oldest = cache.entrySet().iterator();
@@ -92,5 +177,18 @@ public final class BoundedPreparsedDocumentProvider<D> {
}
}
private record Entry<D>(D document, long weight) {}
private D await(CompletableFuture<D> leader) {
try {
return leader.join();
} catch (CompletionException wrapped) {
// The leader's failure is this caller's failure too, but it belongs to them unwrapped: a
// CompletionException in a resolver stack says nothing about the document that was rejected.
if (wrapped.getCause() instanceof RuntimeException cause) {
throw cause;
}
throw wrapped;
}
}
private record Entry<D>(D document, long weight, Instant lastAccessAt) {}
}
@@ -18,13 +18,21 @@ public record GraphQlExecutionPipeline(List<GraphQlExecutionStage> stages) {
stages = List.copyOf(stages);
}
/** The Stable pipeline: context, authorization, parse/validate, cost, execute. */
/**
* The Stable pipeline: context, parse/validate, authorization, cost, execute.
*
* <p>Parsing precedes authorization because authorization has nothing to decide before it. A
* coordinate rule is keyed by {@code Type.field} and an operation rule by the selected operation,
* and neither exists until the document has been parsed and one operation has been chosen.
* Authorizing first would either authorize a request whose shape is still unknown, or force the
* authorization stage to parse the document itself — a second parser on the hostile-input path.
*/
public static GraphQlExecutionPipeline stable() {
return new GraphQlExecutionPipeline(
List.of(
GraphQlExecutionStage.CONTEXT,
GraphQlExecutionStage.AUTHORIZATION,
GraphQlExecutionStage.PARSE_VALIDATE,
GraphQlExecutionStage.AUTHORIZATION,
GraphQlExecutionStage.COST,
GraphQlExecutionStage.EXECUTE));
}
@@ -15,12 +15,20 @@ public final class GraphQlExecutionPipelineValidator {
/** Ordering constraints every pipeline must satisfy, as (earlier, later) pairs. */
private static final List<GraphQlExecutionStage[]> ORDERING_CONSTRAINTS =
List.of(
new GraphQlExecutionStage[] {
GraphQlExecutionStage.CONTEXT, GraphQlExecutionStage.PARSE_VALIDATE
},
new GraphQlExecutionStage[] {
GraphQlExecutionStage.CONTEXT, GraphQlExecutionStage.AUTHORIZATION
},
new GraphQlExecutionStage[] {
GraphQlExecutionStage.PERSISTED_LOOKUP, GraphQlExecutionStage.PARSE_VALIDATE
},
// Authorization is keyed by coordinates and by the selected operation, so the document
// has to be parsed and one operation chosen before it can decide anything.
new GraphQlExecutionStage[] {
GraphQlExecutionStage.PARSE_VALIDATE, GraphQlExecutionStage.AUTHORIZATION
},
new GraphQlExecutionStage[] {
GraphQlExecutionStage.PARSE_VALIDATE, GraphQlExecutionStage.COST
},
@@ -3,10 +3,14 @@ package dev.caskeleton.adapter.inbound.graphql.execution;
/**
* The ordered stages of GraphQL request execution (design §9, §18).
*
* <p>The order is a security property, not a preference. Context must exist before authorization
* can decide anything; a persisted lookup has to happen before parsing or the registry cannot
* supply the document; and cost has to be judged before resolvers run, because a budget checked
* afterwards has already been spent.
* <p>The order is a security property, not a preference. Context must exist before anything can
* decide who is calling; a persisted lookup has to happen before parsing or the registry cannot
* supply the document; authorization needs the parsed document, because a coordinate rule has no
* coordinate to check until one operation has been selected; and cost has to be judged before
* resolvers run, because a budget checked afterwards has already been spent.
*
* <p>Constants are declared in execution order, which is also the order {@code
* GraphQlExecutionPipeline.stable()} composes them in.
*/
public enum GraphQlExecutionStage {
@@ -16,12 +20,12 @@ public enum GraphQlExecutionStage {
/** Resolve an operation ID to its approved document (Advanced persisted-operation capability). */
PERSISTED_LOOKUP(false),
/** Operation-level authorization, before the document is executed. */
AUTHORIZATION(true),
/** Parse and validate the document against the schema. */
/** Parse and validate the document against the schema, and select one operation. */
PARSE_VALIDATE(true),
/** Operation and coordinate authorization, before the document is executed. */
AUTHORIZATION(true),
/** Structural and complexity budgets, before any resolver runs. */
COST(true),
@@ -13,6 +13,8 @@ public final class GraphQlPreparsedCacheMetrics {
private final AtomicLong hits = new AtomicLong();
private final AtomicLong misses = new AtomicLong();
private final AtomicLong evictions = new AtomicLong();
private final AtomicLong expiries = new AtomicLong();
private final AtomicLong coalesced = new AtomicLong();
/** Records a cache hit. */
public void recordHit() {
@@ -29,6 +31,21 @@ public final class GraphQlPreparsedCacheMetrics {
evictions.incrementAndGet();
}
/** Records an entry dropped for being idle past its expiry. */
public void recordExpiry() {
expiries.incrementAndGet();
}
/**
* Records a miss that waited for another caller's parse instead of parsing again.
*
* <p>Counted apart from misses so the two questions stay separable: how often the cache did not
* have the document, and how often concurrent demand for one cold document was coalesced.
*/
public void recordCoalesced() {
coalesced.incrementAndGet();
}
/** Cache hits so far. */
public long hits() {
return hits.get();
@@ -44,6 +61,16 @@ public final class GraphQlPreparsedCacheMetrics {
return evictions.get();
}
/** Entries dropped for being idle past their expiry. */
public long expiries() {
return expiries.get();
}
/** Misses that waited for another caller's parse. */
public long coalesced() {
return coalesced.get();
}
/** Hit ratio, or {@code 0} when nothing has been looked up yet. */
public double hitRatio() {
long total = hits.get() + misses.get();
@@ -0,0 +1,151 @@
package dev.caskeleton.adapter.inbound.graphql.http;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.List;
import java.util.Locale;
import java.util.regex.Pattern;
/**
* A parsed {@code Accept} header, ordered the way the client asked for.
*
* <p>Two properties of the header are easy to drop and expensive to get wrong. {@code q=0} is not a
* weak preference, it is a refusal — {@code application/graphql-response+json;q=0} means "never
* send me that" — and quality ranks the client's alternatives against each other. Iterating the
* server's own preference list and returning the first type that appears anywhere in the header
* ignores both, which is how a refused media type gets sent as if it had been requested.
*
* <p>Parsing is done here rather than with the framework's {@code MediaType} because this module is
* framework free, and the grammar involved is a comma-separated list with one parameter that
* matters. A malformed entry is dropped rather than failing the request: a client that sends
* nonsense alongside a usable type gets the usable type, and one that sends only nonsense gets the
* same answer as one that sent nothing acceptable.
*
* @param type the type half, lowercased, for example {@code application}
* @param subtype the subtype half, lowercased, for example {@code graphql-response+json}
* @param quality the {@code q} parameter, defaulting to {@code 1.0}
* @param specificity how concrete the entry is: 2 for a full type, 1 for {@code type/*}, 0 for
* {@code * / *}
* @param order the entry's position in the header, which breaks ties in the client's stated order
*/
public record GraphQlAcceptHeader(
String type, String subtype, double quality, int specificity, int order) {
private static final double DEFAULT_QUALITY = 1.0;
private static final String WILDCARD = "*";
// Precompiled with an explicit limit: String.split drops trailing empty results, which would
// silently change how a header ending in a comma is read.
private static final Pattern ENTRY_SEPARATOR = Pattern.compile(",");
private static final Pattern PARAMETER_SEPARATOR = Pattern.compile(";");
/**
* Parses an {@code Accept} header into entries ranked most acceptable first.
*
* <p>Ranked by quality, then by specificity, then by the order the client wrote them. Entries
* with {@code q=0} are dropped, because they are refusals and must never be selectable.
*
* @param accept the raw header, possibly {@code null}
*/
public static List<GraphQlAcceptHeader> parse(String accept) {
if (accept == null || accept.isBlank()) {
return List.of();
}
List<GraphQlAcceptHeader> entries = new ArrayList<>();
String[] parts = ENTRY_SEPARATOR.split(accept, -1);
for (int index = 0; index < parts.length; index++) {
GraphQlAcceptHeader entry = parseEntry(parts[index], index);
if (entry != null && entry.quality() > 0) {
entries.add(entry);
}
}
entries.sort(
Comparator.<GraphQlAcceptHeader>comparingDouble(GraphQlAcceptHeader::quality)
.reversed()
.thenComparing(
Comparator.<GraphQlAcceptHeader>comparingInt(GraphQlAcceptHeader::specificity)
.reversed())
.thenComparingInt(GraphQlAcceptHeader::order));
return List.copyOf(entries);
}
/**
* Whether this entry explicitly refuses a media type.
*
* <p>Only a refusal that names the type or its subtype family counts. A {@code * / *;q=0} entry
* is dropped at parse time and never reaches here.
*/
public static boolean refuses(String accept, String mediaType) {
if (accept == null || accept.isBlank()) {
return false;
}
String[] parts = ENTRY_SEPARATOR.split(accept, -1);
for (int index = 0; index < parts.length; index++) {
GraphQlAcceptHeader entry = parseEntry(parts[index], index);
if (entry != null && entry.quality() == 0 && entry.matches(mediaType)) {
return true;
}
}
return false;
}
/** Whether a concrete media type is covered by this entry. */
public boolean matches(String mediaType) {
if (mediaType == null) {
return false;
}
int separator = mediaType.indexOf('/');
if (separator < 0) {
return false;
}
String candidateType = mediaType.substring(0, separator).strip().toLowerCase(Locale.ROOT);
String candidateSubtype = mediaType.substring(separator + 1).strip().toLowerCase(Locale.ROOT);
return (WILDCARD.equals(type) || type.equals(candidateType))
&& (WILDCARD.equals(subtype) || subtype.equals(candidateSubtype));
}
private static GraphQlAcceptHeader parseEntry(String raw, int order) {
String entry = raw.strip();
if (entry.isEmpty()) {
return null;
}
int parameterStart = entry.indexOf(';');
String base = (parameterStart < 0 ? entry : entry.substring(0, parameterStart)).strip();
int separator = base.indexOf('/');
if (separator < 0) {
return null;
}
String type = base.substring(0, separator).strip().toLowerCase(Locale.ROOT);
String subtype = base.substring(separator + 1).strip().toLowerCase(Locale.ROOT);
if (type.isEmpty() || subtype.isEmpty()) {
return null;
}
// `*/subtype` is not a shape the grammar allows, and treating it as a wildcard would let a
// malformed header match more than a well-formed one.
if (WILDCARD.equals(type) && !WILDCARD.equals(subtype)) {
return null;
}
double quality =
parameterStart < 0 ? DEFAULT_QUALITY : qualityOf(entry.substring(parameterStart + 1));
int specificity = WILDCARD.equals(type) ? 0 : WILDCARD.equals(subtype) ? 1 : 2;
return new GraphQlAcceptHeader(type, subtype, quality, specificity, order);
}
private static double qualityOf(String parameters) {
for (String parameter : PARAMETER_SEPARATOR.split(parameters, -1)) {
String candidate = parameter.strip();
if (!candidate.regionMatches(true, 0, "q=", 0, 2)) {
continue;
}
try {
double value = Double.parseDouble(candidate.substring(2).strip());
// Out-of-range values are not meaningful quality; treating them as the default keeps a
// sloppy client working without letting `q=5` outrank an honest `q=1`.
return value < 0 || value > 1 ? DEFAULT_QUALITY : value;
} catch (NumberFormatException malformed) {
return DEFAULT_QUALITY;
}
}
return DEFAULT_QUALITY;
}
}
@@ -11,8 +11,8 @@ import java.util.Map;
*
* @param query the GraphQL document
* @param operationName selected operation name, or {@code null}
* @param variables variable values, never {@code null}
* @param extensions protocol extensions, never {@code null}
* @param variables variable values, never {@code null}, null entries preserved
* @param extensions protocol extensions, never {@code null}, null entries preserved
*/
public record GraphQlHttpRequestEnvelope(
String query,
@@ -21,8 +21,12 @@ public record GraphQlHttpRequestEnvelope(
Map<String, Object> extensions) {
public GraphQlHttpRequestEnvelope {
variables = variables == null ? Map.of() : Map.copyOf(variables);
extensions = extensions == null ? Map.of() : Map.copyOf(extensions);
// Deep and null-preserving. `Map.copyOf` threw on `{"id": null}` — a legal variables object
// whose explicit null is a different instruction from omitting the key — and left nested maps
// and lists shared with the decoder, so the value a validator checked was not necessarily the
// value an executor later read.
variables = GraphQlJsonValues.immutableObject(variables);
extensions = GraphQlJsonValues.immutableObject(extensions);
}
/** An envelope carrying only a document. */
@@ -0,0 +1,100 @@
package dev.caskeleton.adapter.inbound.graphql.http;
import dev.caskeleton.adapter.inbound.graphql.policy.GraphQlClientPolicy;
import java.util.List;
import java.util.Map;
import java.util.Objects;
/**
* Bounds the shape of decoded {@code variables} and {@code extensions}.
*
* <p>A byte limit bounds how much a client can send; it does not bound what that costs to process.
* Sixty kilobytes of {@code [[[[[…]]]]]} is small on the wire and expensive to walk, coerce and
* validate, and the same bytes as one enormous list turn into one enormous coercion loop. So depth,
* element count and key count get their own budgets.
*
* <p>Diagnostics report the dimension and the two counts. A variable value never appears: these are
* exactly the inputs that carry identifiers, tokens and personal data.
*/
public final class GraphQlJsonStructurePolicy {
/** Stable request-error code for every structural rejection of a JSON input. */
public static final String CODE = "GRAPHQL_INPUT_SHAPE_REJECTED";
private final int maxDepth;
private final int maxListElements;
private final int maxObjectKeys;
/**
* Creates the policy.
*
* @param maxDepth deepest accepted nesting of objects and arrays
* @param maxListElements most accepted elements in one array
* @param maxObjectKeys most accepted keys in one object
*/
public GraphQlJsonStructurePolicy(int maxDepth, int maxListElements, int maxObjectKeys) {
if (maxDepth < 1 || maxListElements < 1 || maxObjectKeys < 1) {
throw new IllegalArgumentException("JSON structure limits must be positive");
}
this.maxDepth = maxDepth;
this.maxListElements = maxListElements;
this.maxObjectKeys = maxObjectKeys;
}
/**
* Derives the policy from a client policy.
*
* <p>Input nesting reuses the document depth budget, because a variable tree and a selection tree
* are walked by the same kind of recursion and there is no reason for a client to need one deeper
* than the other. Key count reuses the list-element budget for the same reason.
*/
public static GraphQlJsonStructurePolicy from(GraphQlClientPolicy policy) {
Objects.requireNonNull(policy, "client policy is required");
return new GraphQlJsonStructurePolicy(
policy.maxDepth(), policy.maxInputListElements(), policy.maxInputListElements());
}
/** The deepest accepted nesting. */
public int maxDepth() {
return maxDepth;
}
/**
* Verifies a decoded JSON object.
*
* @param field field name used in the diagnostic, for example {@code variables}
* @param value the decoded object
* @throws GraphQlRequestFormatException on the first exceeded dimension
*/
public void verify(String field, Map<String, Object> value) {
if (value == null || value.isEmpty()) {
return;
}
walk(field, value, 1);
}
private void walk(String field, Object value, int depth) {
if (depth > maxDepth) {
throw rejection(field, "DEPTH", depth, maxDepth);
}
if (value instanceof Map<?, ?> object) {
if (object.size() > maxObjectKeys) {
throw rejection(field, "OBJECT_KEYS", object.size(), maxObjectKeys);
}
object.values().forEach(entry -> walk(field, entry, depth + 1));
return;
}
if (value instanceof List<?> list) {
if (list.size() > maxListElements) {
throw rejection(field, "LIST_ELEMENTS", list.size(), maxListElements);
}
list.forEach(element -> walk(field, element, depth + 1));
}
}
private static GraphQlRequestFormatException rejection(
String field, String dimension, int observed, int allowed) {
return new GraphQlRequestFormatException(
CODE + " " + field + " " + dimension + ": " + observed + " > " + allowed);
}
}
@@ -0,0 +1,56 @@
package dev.caskeleton.adapter.inbound.graphql.http;
import java.util.ArrayList;
import java.util.Collections;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
/**
* Copies decoded JSON so it is immutable without losing what the client actually sent.
*
* <p>{@code Map.copyOf} cannot be used here, and the reason is a correctness bug rather than a
* style preference: it throws on a null value, and a null variable is legal, meaningful GraphQL
* input. The three cases {@code {"a": 1}}, {@code {"a": null}} and {@code {}} coerce differently —
* a value, an explicit null, and an absent argument that falls back to its default — so collapsing
* the middle one into an exception makes valid requests fail.
*
* <p>The copy is deep. A shallow copy leaves the nested maps and lists shared with whatever decoded
* them, so the envelope a validator inspected and the envelope an executor later reads are not
* guaranteed to be the same value.
*/
public final class GraphQlJsonValues {
private GraphQlJsonValues() {}
/**
* A deep, null-preserving, unmodifiable copy of a decoded JSON object.
*
* @param value the decoded object, or {@code null}
* @return an unmodifiable copy; an empty map when {@code value} is {@code null}
*/
public static Map<String, Object> immutableObject(Map<String, Object> value) {
if (value == null || value.isEmpty()) {
return Map.of();
}
Map<String, Object> copy = new LinkedHashMap<>(value.size());
value.forEach((key, entry) -> copy.put(key, immutableValue(entry)));
return Collections.unmodifiableMap(copy);
}
/** A deep, null-preserving, unmodifiable copy of any decoded JSON value. */
@SuppressWarnings("unchecked")
public static Object immutableValue(Object value) {
if (value instanceof Map<?, ?> map) {
return immutableObject((Map<String, Object>) map);
}
if (value instanceof List<?> list) {
List<Object> copy = new ArrayList<>(list.size());
list.forEach(element -> copy.add(immutableValue(element)));
return Collections.unmodifiableList(copy);
}
// Everything else a JSON decoder produces is already immutable: String, Boolean, the boxed
// numbers, BigDecimal, BigInteger — and null, which must survive as null.
return value;
}
}
@@ -1,7 +1,9 @@
package dev.caskeleton.adapter.inbound.graphql.http;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Locale;
import java.util.Set;
/**
* Media types of the Stable HTTP profile (design §9.1).
@@ -39,6 +41,21 @@ public final class GraphQlMediaTypes {
/**
* Chooses the response media type for an {@code Accept} header.
*
* <p>The client's ranking decides, not the server's. Walking the server's preference list first
* and returning the first type named anywhere in the header ignored both quality and refusal, so
* {@code application/graphql-response+json;q=0, application/json} — a client saying "anything but
* that one" — was answered with exactly the refused type.
*
* <p>Absent or blank {@code Accept} means no constraint, so the profile's preferred type is
* returned. A wildcard is matched like any other entry, at its own quality and specificity, which
* is what lets {@code * / *;q=0.1, application/json;q=0.9} pick JSON rather than the wildcard.
*
* <p>Where the client ranked two producible types equally — same quality, same specificity, as in
* a plain {@code application/json, application/graphql-response+json} — it has expressed no
* preference between them, and the server's own preference breaks the tie. That is the one place
* server preference still applies, and it applies only after the client's ranking has been
* exhausted.
*
* @param accept raw {@code Accept} header, possibly {@code null}
* @return the negotiated media type, or {@code null} when nothing acceptable was offered
*/
@@ -46,21 +63,33 @@ public final class GraphQlMediaTypes {
if (accept == null || accept.isBlank()) {
return GRAPHQL_RESPONSE_JSON;
}
List<String> offered = List.of(accept.split(","));
for (String candidate : PRODUCIBLE) {
for (String entry : offered) {
if (baseType(entry).equals(candidate)) {
return candidate;
}
Set<String> bestTier = new LinkedHashSet<>();
double tierQuality = 0;
int tierSpecificity = -1;
for (GraphQlAcceptHeader entry : GraphQlAcceptHeader.parse(accept)) {
List<String> matches =
PRODUCIBLE.stream()
// A concrete refusal outranks a wildcard acceptance: `*/*, application/json;q=0`
// accepts everything and then names one exception, and the exception is the specific
// instruction.
.filter(candidate -> entry.matches(candidate))
.filter(candidate -> !GraphQlAcceptHeader.refuses(accept, candidate))
.toList();
if (matches.isEmpty()) {
continue;
}
}
for (String entry : offered) {
String base = baseType(entry);
if ("*/*".equals(base) || "application/*".equals(base)) {
return GRAPHQL_RESPONSE_JSON;
if (bestTier.isEmpty()) {
tierQuality = entry.quality();
tierSpecificity = entry.specificity();
} else if (entry.quality() != tierQuality || entry.specificity() != tierSpecificity) {
break;
}
bestTier.addAll(matches);
}
return null;
return PRODUCIBLE.stream().filter(bestTier::contains).findFirst().orElse(null);
}
private static String baseType(String mediaType) {
@@ -1,6 +1,8 @@
package dev.caskeleton.adapter.inbound.graphql.http;
import java.nio.charset.StandardCharsets;
import java.util.List;
import java.util.Map;
/**
* Measured sizes of one request envelope, in bytes.
@@ -36,6 +38,50 @@ public record GraphQlRequestSize(int documentBytes, int variablesBytes, int exte
return documentBytes + variablesBytes + extensionsBytes;
}
/**
* The UTF-8 size of a decoded JSON value, as a canonical encoding without whitespace.
*
* <p>Computed by walking the decoded value rather than by re-serialising it, because this module
* is framework free and must not acquire a JSON library to measure one. The number is what the
* content costs in memory, which is the quantity the limit is protecting; it is deliberately not
* a claim about the exact bytes the client sent, since escaping and whitespace are the encoder's
* business and neither is attacker-controlled in a way the count would miss.
*
* @param value a decoded JSON value, or {@code null}
*/
public static int jsonBytes(Object value) {
if (value == null) {
return 4; // "null"
}
if (value instanceof String text) {
return utf8Length(text) + 2; // surrounding quotes
}
if (value instanceof Map<?, ?> object) {
int bytes = 2; // braces
boolean first = true;
for (Map.Entry<?, ?> entry : object.entrySet()) {
if (!first) {
bytes++; // comma
}
first = false;
bytes += utf8Length(String.valueOf(entry.getKey())) + 3; // quotes and colon
bytes += jsonBytes(entry.getValue());
}
return bytes;
}
if (value instanceof List<?> list) {
int bytes = 2; // brackets
for (int index = 0; index < list.size(); index++) {
if (index > 0) {
bytes++; // comma
}
bytes += jsonBytes(list.get(index));
}
return bytes;
}
return utf8Length(String.valueOf(value));
}
private static int utf8Length(String value) {
return value == null ? 0 : value.getBytes(StandardCharsets.UTF_8).length;
}
@@ -1,80 +0,0 @@
package dev.caskeleton.adapter.inbound.graphql.http.mvc;
import dev.caskeleton.adapter.inbound.graphql.http.GraphQlHttpExecutor;
import dev.caskeleton.adapter.inbound.graphql.http.GraphQlHttpProfile;
import dev.caskeleton.adapter.inbound.graphql.http.GraphQlRequestEnvelopeValidator;
import dev.caskeleton.adapter.inbound.graphql.policy.GraphQlClientPolicy;
import java.time.Clock;
import java.util.concurrent.ExecutorService;
import org.springframework.boot.autoconfigure.condition.ConditionalOnBean;
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 the blocking MVC transport when the application runs the servlet stack.
*
* <p>Conditional on a servlet web application and on {@code BLOCKING_MVC} being the selected
* execution profile, so a reactive deployment never gets a blocking transport by accident. Every
* bean backs off if the application defines its own.
*/
@Configuration(proxyBeanMethods = false)
@ConditionalOnWebApplication(type = ConditionalOnWebApplication.Type.SERVLET)
@ConditionalOnProperty(
prefix = "backend.graphql",
name = "execution-profile",
havingValue = "BLOCKING_MVC",
matchIfMissing = true)
public class GraphQlMvcAutoConfiguration {
/** Default bounded pool size when virtual threads are not in use. */
public static final int DEFAULT_BOUNDED_POOL_SIZE = 64;
/**
* The thread policy for resolver work.
*
* <p>Virtual threads by default: the blocking profile exists for JPA and blocking SDK work, and a
* thread-per-request model with virtual threads is what makes that affordable on Java 21.
*/
@Bean
@ConditionalOnMissingBean
public GraphQlMvcExecutorPolicy graphQlMvcExecutorPolicy() {
return GraphQlMvcExecutorPolicy.VIRTUAL_THREAD;
}
/** The executor resolver work runs on. */
@Bean(destroyMethod = "shutdown")
@ConditionalOnMissingBean(name = "graphQlMvcExecutorService")
public ExecutorService graphQlMvcExecutorService(GraphQlMvcExecutorPolicy policy) {
return policy.createExecutor(DEFAULT_BOUNDED_POOL_SIZE);
}
/** Pre-parse envelope limits derived from the client policy. */
@Bean
@ConditionalOnMissingBean
@ConditionalOnBean(GraphQlClientPolicy.class)
public GraphQlRequestEnvelopeValidator graphQlRequestEnvelopeValidator(
GraphQlClientPolicy clientPolicy) {
return GraphQlRequestEnvelopeValidator.forPolicy(clientPolicy);
}
/** The MVC transport adapter. */
@Bean
@ConditionalOnMissingBean
@ConditionalOnBean({GraphQlHttpExecutor.class, GraphQlRequestEnvelopeValidator.class})
public GraphQlMvcTransportAdapter graphQlMvcTransportAdapter(
GraphQlRequestEnvelopeValidator validator,
GraphQlHttpExecutor executor,
ExecutorService graphQlMvcExecutorService,
GraphQlMvcExecutorPolicy policy) {
return new GraphQlMvcTransportAdapter(
GraphQlHttpProfile.V1,
validator,
executor,
graphQlMvcExecutorService,
policy,
Clock.systemUTC());
}
}
@@ -1,49 +0,0 @@
package dev.caskeleton.adapter.inbound.graphql.http.mvc;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.ThreadFactory;
/**
* How the blocking MVC profile provides threads for resolver work (design §10).
*
* <p>Both options allow blocking resolvers, which is the entire point of the {@code BLOCKING_MVC}
* profile: JPA, blocking Mongo and blocking SDKs are legitimate here. What is not allowed is
* unbounded concurrency — a virtual thread per request still has a bounded connection pool behind
* it, and a platform-thread executor is explicitly bounded.
*/
public enum GraphQlMvcExecutorPolicy {
/** Java 21 virtual threads: one carrier-light thread per request. */
VIRTUAL_THREAD(true),
/** A bounded platform-thread pool, for deployments not yet on virtual threads. */
BOUNDED_PLATFORM_THREAD(true);
private final boolean blockingAllowed;
GraphQlMvcExecutorPolicy(boolean blockingAllowed) {
this.blockingAllowed = blockingAllowed;
}
/** Whether a blocking resolver may run under this policy. */
public boolean blockingAllowed() {
return blockingAllowed;
}
/**
* Creates the executor this policy describes.
*
* @param boundedPoolSize thread count used by {@link #BOUNDED_PLATFORM_THREAD}
*/
public ExecutorService createExecutor(int boundedPoolSize) {
if (this == VIRTUAL_THREAD) {
return Executors.newVirtualThreadPerTaskExecutor();
}
if (boundedPoolSize < 1) {
throw new IllegalArgumentException("bounded pool size must be positive");
}
ThreadFactory threadFactory = Thread.ofPlatform().name("graphql-mvc-", 0).factory();
return Executors.newFixedThreadPool(boundedPoolSize, threadFactory);
}
}
@@ -1,157 +0,0 @@
package dev.caskeleton.adapter.inbound.graphql.http.mvc;
import dev.caskeleton.adapter.inbound.graphql.context.GraphQlRequestContext;
import dev.caskeleton.adapter.inbound.graphql.http.GraphQlExecutionOutcome;
import dev.caskeleton.adapter.inbound.graphql.http.GraphQlHttpContractException;
import dev.caskeleton.adapter.inbound.graphql.http.GraphQlHttpExecutor;
import dev.caskeleton.adapter.inbound.graphql.http.GraphQlHttpProfile;
import dev.caskeleton.adapter.inbound.graphql.http.GraphQlHttpRequestEnvelope;
import dev.caskeleton.adapter.inbound.graphql.http.GraphQlHttpResponse;
import dev.caskeleton.adapter.inbound.graphql.http.GraphQlHttpResponseFactory;
import dev.caskeleton.adapter.inbound.graphql.http.GraphQlRequestEnvelopeValidator;
import java.time.Clock;
import java.time.Duration;
import java.util.List;
import java.util.Map;
import java.util.concurrent.CancellationException;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Future;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.TimeoutException;
/**
* The blocking MVC transport (design §10, Stable plan Task 18).
*
* <p>Runs execution on the configured executor and bounds it by the request deadline rather than
* waiting indefinitely. When the deadline passes the task is cancelled with an interrupt, so a
* resolver that respects interruption stops; the design is explicit that interruption alone is not
* sufficient, which is why the deadline is also propagated down to the database and HTTP client
* budgets.
*
* <p>No Reactor or WebFlux type appears in this contract, and no transaction is opened here — the
* transaction belongs to the Application service the resolver calls.
*/
public final class GraphQlMvcTransportAdapter {
private final GraphQlHttpProfile profile;
private final GraphQlRequestEnvelopeValidator validator;
private final GraphQlHttpExecutor executor;
private final ExecutorService executorService;
private final GraphQlMvcExecutorPolicy executorPolicy;
private final Clock clock;
/**
* Creates the adapter.
*
* @param profile transport profile in force
* @param validator pre-parse envelope limits
* @param executor GraphQL execution seam
* @param executorService threads resolver work runs on
* @param executorPolicy which thread policy {@code executorService} implements
* @param clock clock used to compute the remaining request budget
*/
public GraphQlMvcTransportAdapter(
GraphQlHttpProfile profile,
GraphQlRequestEnvelopeValidator validator,
GraphQlHttpExecutor executor,
ExecutorService executorService,
GraphQlMvcExecutorPolicy executorPolicy,
Clock clock) {
if (profile == null
|| validator == null
|| executor == null
|| executorService == null
|| executorPolicy == null
|| clock == null) {
throw new IllegalArgumentException("MVC transport adapter dependencies are required");
}
if (!executorPolicy.blockingAllowed()) {
throw new IllegalArgumentException("the MVC transport requires a blocking-capable executor");
}
this.profile = profile;
this.validator = validator;
this.executor = executor;
this.executorService = executorService;
this.executorPolicy = executorPolicy;
this.clock = clock;
}
/** The thread policy resolver work runs under. */
public GraphQlMvcExecutorPolicy executorPolicy() {
return executorPolicy;
}
/**
* Handles one HTTP exchange.
*
* <p>Never throws for a client-caused failure: a transport violation becomes a response with the
* status the profile mandates, so the error contract stays in one place.
*/
public GraphQlHttpResponse handle(
String method,
String contentType,
String accept,
GraphQlHttpRequestEnvelope envelope,
GraphQlRequestContext context) {
GraphQlHttpResponseFactory responses = GraphQlHttpResponseFactory.preferredV1();
try {
profile.validateMethod(method);
profile.validateContentType(contentType);
String negotiated = profile.negotiateResponseContentType(accept);
responses = GraphQlHttpResponseFactory.v1(negotiated);
validator.validateEnvelope(envelope);
GraphQlExecutionOutcome outcome = executeWithinDeadline(envelope, context);
return outcome.failed()
? responses.fieldError(outcome.data(), outcome.errors())
: responses.success(outcome.data());
} catch (GraphQlHttpContractException ex) {
return responses.requestError(ex);
}
}
private GraphQlExecutionOutcome executeWithinDeadline(
GraphQlHttpRequestEnvelope envelope, GraphQlRequestContext context) {
Duration remaining = context.deadline().remaining(clock);
if (remaining.isZero() || remaining.isNegative()) {
return timedOut();
}
Future<GraphQlExecutionOutcome> pending =
executorService.submit(() -> executor.execute(envelope, context));
try {
return pending.get(remaining.toMillis(), TimeUnit.MILLISECONDS);
} catch (TimeoutException ex) {
// Interrupt the in-flight work; the propagated deadline is what actually stops the
// downstream database and HTTP calls.
pending.cancel(true);
return timedOut();
} catch (CancellationException ex) {
return timedOut();
} catch (ExecutionException ex) {
Throwable cause = ex.getCause();
if (cause instanceof GraphQlHttpContractException contractFailure) {
throw contractFailure;
}
throw new IllegalStateException("GraphQL execution failed", cause);
} catch (InterruptedException ex) {
Thread.currentThread().interrupt();
pending.cancel(true);
return timedOut();
}
}
private static GraphQlExecutionOutcome timedOut() {
return GraphQlExecutionOutcome.partial(
Map.of(),
List.of(
Map.of(
"message",
"요청을 처리할 수 없습니다.",
"extensions",
Map.of("code", "REQUEST_TIMEOUT", "category", "TIMEOUT", "retryable", true))));
}
}
@@ -1,55 +0,0 @@
package dev.caskeleton.adapter.inbound.graphql.http.webflux;
import dev.caskeleton.adapter.inbound.graphql.execution.GraphQlExecutionProfileException;
import dev.caskeleton.adapter.inbound.graphql.policy.ResolverExecutionType;
/**
* Refuses blocking resolver work on a reactive event loop (design §10).
*
* <p>A blocking call on an event-loop thread does not fail — it holds one of a handful of threads
* that serve every connection, so the symptom is latency across unrelated requests rather than an
* error on the offending one. The guard turns that into an explicit failure at the point of
* registration or dispatch, and the only way past it is an approved scheduler bridge that moves the
* work off the loop.
*/
public final class GraphQlEventLoopGuard {
private GraphQlEventLoopGuard() {}
/**
* Verifies one resolver dispatch.
*
* @param type how the resolver executes
* @param eventLoopThread whether the current thread is a reactive event-loop thread
* @param approvedBridge whether an approved executor or scheduler bridge is in place
* @throws GraphQlExecutionProfileException when blocking work would run on the loop unbridged
*/
public static void verify(
ResolverExecutionType type, boolean eventLoopThread, boolean approvedBridge) {
if (eventLoopThread && type == ResolverExecutionType.BLOCKING && !approvedBridge) {
throw new GraphQlExecutionProfileException("blocking resolver on event loop");
}
}
/**
* Whether a thread name belongs to a known reactive event loop.
*
* <p>Name-based detection keeps the guard usable from a module that does not depend on a specific
* server: Reactor Netty, Netty and Undertow all name their loop threads predictably.
*/
public static boolean isEventLoopThread(String threadName) {
if (threadName == null) {
return false;
}
return threadName.startsWith("reactor-http-nio")
|| threadName.startsWith("reactor-tcp-nio")
|| threadName.startsWith("nioEventLoopGroup")
|| threadName.startsWith("XNIO")
|| threadName.contains("-eventLoop-");
}
/** Whether the calling thread is a reactive event-loop thread. */
public static boolean onEventLoop() {
return isEventLoopThread(Thread.currentThread().getName());
}
}
@@ -1,64 +0,0 @@
package dev.caskeleton.adapter.inbound.graphql.http.webflux;
import dev.caskeleton.adapter.inbound.graphql.context.GraphQlRequestContext;
import dev.caskeleton.adapter.inbound.graphql.http.GraphQlExecutionOutcome;
import dev.caskeleton.adapter.inbound.graphql.http.GraphQlHttpExecutor;
import dev.caskeleton.adapter.inbound.graphql.http.GraphQlHttpProfile;
import dev.caskeleton.adapter.inbound.graphql.http.GraphQlHttpRequestEnvelope;
import dev.caskeleton.adapter.inbound.graphql.http.GraphQlRequestEnvelopeValidator;
import java.time.Clock;
import java.util.function.BiFunction;
import org.springframework.boot.autoconfigure.condition.ConditionalOnBean;
import org.springframework.boot.autoconfigure.condition.ConditionalOnClass;
import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.boot.autoconfigure.condition.ConditionalOnWebApplication;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.reactive.function.server.ServerResponse;
import reactor.core.publisher.Mono;
/**
* Wires the reactive transport when the application runs the WebFlux stack.
*
* <p>Conditional on a reactive web application, on WebFlux being present and on {@code
* REACTIVE_WEBFLUX} being the selected execution profile — a blocking deployment must never acquire
* a reactive transport implicitly, and vice versa.
*/
@Configuration(proxyBeanMethods = false)
@ConditionalOnClass(ServerResponse.class)
@ConditionalOnWebApplication(type = ConditionalOnWebApplication.Type.REACTIVE)
@ConditionalOnProperty(
prefix = "backend.graphql",
name = "execution-profile",
havingValue = "REACTIVE_WEBFLUX")
public class GraphQlWebFluxAutoConfiguration {
/**
* Adapts the blocking execution seam onto a reactive one.
*
* <p>Only registered when the application supplied a {@link GraphQlHttpExecutor} and no reactive
* seam of its own. Execution is deferred rather than invoked eagerly, so subscription — and
* therefore cancellation — controls when the work starts.
*/
@Bean
@ConditionalOnMissingBean(name = "graphQlReactiveExecutor")
@ConditionalOnBean(GraphQlHttpExecutor.class)
public BiFunction<
GraphQlHttpRequestEnvelope, GraphQlRequestContext, Mono<GraphQlExecutionOutcome>>
graphQlReactiveExecutor(GraphQlHttpExecutor executor) {
return (envelope, context) -> Mono.fromCallable(() -> executor.execute(envelope, context));
}
/** The reactive transport adapter. */
@Bean
@ConditionalOnMissingBean
@ConditionalOnBean(GraphQlRequestEnvelopeValidator.class)
public GraphQlWebFluxTransportAdapter graphQlWebFluxTransportAdapter(
GraphQlRequestEnvelopeValidator validator,
BiFunction<GraphQlHttpRequestEnvelope, GraphQlRequestContext, Mono<GraphQlExecutionOutcome>>
graphQlReactiveExecutor) {
return new GraphQlWebFluxTransportAdapter(
GraphQlHttpProfile.V1, validator, graphQlReactiveExecutor, Clock.systemUTC());
}
}
@@ -1,110 +0,0 @@
package dev.caskeleton.adapter.inbound.graphql.http.webflux;
import dev.caskeleton.adapter.inbound.graphql.context.GraphQlRequestContext;
import dev.caskeleton.adapter.inbound.graphql.http.GraphQlExecutionOutcome;
import dev.caskeleton.adapter.inbound.graphql.http.GraphQlHttpContractException;
import dev.caskeleton.adapter.inbound.graphql.http.GraphQlHttpProfile;
import dev.caskeleton.adapter.inbound.graphql.http.GraphQlHttpRequestEnvelope;
import dev.caskeleton.adapter.inbound.graphql.http.GraphQlHttpResponse;
import dev.caskeleton.adapter.inbound.graphql.http.GraphQlHttpResponseFactory;
import dev.caskeleton.adapter.inbound.graphql.http.GraphQlRequestEnvelopeValidator;
import java.time.Clock;
import java.time.Duration;
import java.util.List;
import java.util.Map;
import java.util.function.BiFunction;
import reactor.core.publisher.Mono;
/**
* The reactive transport (design §10, Stable plan Task 19).
*
* <p>Reactive rather than blocking all the way through: the request deadline is applied with {@code
* timeout}, which cancels the upstream chain, and cancellation is what actually reaches reactive
* data fetchers and downstream publishers. A blocking {@code Future.get} would leave that work
* running after the client had already been answered.
*
* <p>{@code block()} is never called here, and the request context is carried in the Reactor
* context so it survives operator boundaries and thread hops.
*/
public final class GraphQlWebFluxTransportAdapter {
private final GraphQlHttpProfile profile;
private final GraphQlRequestEnvelopeValidator validator;
private final BiFunction<
GraphQlHttpRequestEnvelope, GraphQlRequestContext, Mono<GraphQlExecutionOutcome>>
executor;
private final Clock clock;
/**
* Creates the adapter.
*
* @param profile transport profile in force
* @param validator pre-parse envelope limits
* @param executor reactive GraphQL execution seam
* @param clock clock used to compute the remaining request budget
*/
public GraphQlWebFluxTransportAdapter(
GraphQlHttpProfile profile,
GraphQlRequestEnvelopeValidator validator,
BiFunction<GraphQlHttpRequestEnvelope, GraphQlRequestContext, Mono<GraphQlExecutionOutcome>>
executor,
Clock clock) {
if (profile == null || validator == null || executor == null || clock == null) {
throw new IllegalArgumentException("reactive transport adapter dependencies are required");
}
this.profile = profile;
this.validator = validator;
this.executor = executor;
this.clock = clock;
}
/**
* Handles one HTTP exchange reactively.
*
* <p>A transport violation becomes a response rather than an error signal, so the status contract
* stays identical to the MVC transport.
*/
public Mono<GraphQlHttpResponse> handle(
String method,
String contentType,
String accept,
GraphQlHttpRequestEnvelope envelope,
GraphQlRequestContext context) {
GraphQlHttpResponseFactory preferred = GraphQlHttpResponseFactory.preferredV1();
GraphQlHttpResponseFactory responses;
try {
profile.validateMethod(method);
profile.validateContentType(contentType);
responses = GraphQlHttpResponseFactory.v1(profile.negotiateResponseContentType(accept));
validator.validateEnvelope(envelope);
} catch (GraphQlHttpContractException ex) {
return Mono.just(preferred.requestError(ex));
}
GraphQlHttpResponseFactory negotiated = responses;
Duration remaining = context.deadline().remaining(clock);
if (remaining.isZero() || remaining.isNegative()) {
return Mono.just(negotiated.fieldError(Map.of(), timeoutErrors()));
}
return Mono.defer(() -> executor.apply(envelope, context))
.timeout(remaining, Mono.just(GraphQlExecutionOutcome.partial(Map.of(), timeoutErrors())))
.map(
outcome ->
outcome.failed()
? negotiated.fieldError(outcome.data(), outcome.errors())
: negotiated.success(outcome.data()))
.contextWrite(
reactorContext -> reactorContext.put(GraphQlRequestContext.CONTEXT_KEY, context));
}
private static List<Map<String, Object>> timeoutErrors() {
return List.of(
Map.of(
"message",
"요청을 처리할 수 없습니다.",
"extensions",
Map.of("code", "REQUEST_TIMEOUT", "category", "TIMEOUT", "retryable", true)));
}
}
@@ -0,0 +1,156 @@
package dev.caskeleton.adapter.inbound.graphql.moduleboundary;
import java.util.Arrays;
import java.util.Map;
import java.util.Set;
import java.util.TreeMap;
import java.util.stream.Collectors;
/**
* The Advanced GraphQL capability modules and their allowed internal dependencies.
*
* <p>Advanced consumes Stable, never the other way round. If a Stable module depended on an
* Advanced one, every Stable deployment would carry the Advanced capability's code and
* configuration surface, and the feature flag would be the only thing standing between an ordinary
* service and a subscription runtime. {@code GraphQlAdvancedDependencyRules} checks that direction
* against this declaration; {@code GraphQlModuleBoundaryTest} checks it against the real imports.
*
* <p>Advanced modules are {@link GraphQlModulePurity#CORE} by default: the capabilities are state
* machines and policies, and the transport binding for the ones that need it stays in the Stable
* {@code http} seam. Exceptions are declared per module rather than assumed — {@code
* advanced.codegen} is the one that has to compile a schema to do its job.
*/
public enum GraphQlAdvancedModule {
/** Persisted operation administration and removal gating. */
ADMIN("advanced.admin", "advanced.admin", "advanced.persisted"),
/** Advanced capability grades, feature flags and the activation guard. */
BOOTSTRAP("advanced.bootstrap", "advanced.bootstrap", "moduleboundary"),
/** DataLoader chaining and cycle detection. */
CHAINING("advanced.chaining", "advanced.chaining", "advanced.bootstrap"),
/**
* Client code generation planning, operation validation and generated-source boundary rules.
*
* <p>The one Advanced module that is not framework free. Validating a client operation means
* compiling the schema and running GraphQL Java's validator — there is no way to answer "does
* this field exist" without the type system, and a hand-rolled approximation would be the kind of
* check that passes on exactly the documents that break.
*/
CODEGEN("advanced.codegen", "advanced.codegen", GraphQlModulePurity.FRAMEWORK_BOUND, "compat"),
/** Federated schema composition gating. */
COMPOSITION("advanced.composition", "advanced.composition", "advanced.federation"),
/** Federation entity resolution. */
FEDERATION("advanced.federation", "advanced.federation", "advanced.bootstrap"),
/** The draft HTTP GET operation profile. */
GET("advanced.get", "advanced.get", "http"),
/** Incremental delivery (`@defer`/`@stream`) compatibility gating. */
INCREMENTAL("advanced.incremental", "advanced.incremental", "cost", "execution"),
/** Persisted operation registry and the interceptor that enforces it. */
PERSISTED(
"advanced.persisted", "advanced.persisted", "advanced.bootstrap", "execution", "policy"),
/** The Advanced release gate. */
RELEASE("advanced.release", "advanced.release", "advanced.bootstrap", "release"),
/** Subscription snapshot replay and live handoff. */
REPLAY(
"advanced.replay",
"advanced.replay",
"advanced.security",
"advanced.subscription",
"pagination"),
/** The experimental RSocket transport route policy. */
RSOCKET("advanced.rsocket", "advanced.rsocket", "advanced.bootstrap", "error", "policy"),
/** Transport authentication for long-lived Advanced connections. */
SECURITY("advanced.security", "advanced.security"),
/** Server-sent event connection policy. */
SSE("advanced.sse", "advanced.sse", "advanced.bootstrap", "advanced.subscription", "http"),
/** Subscription execution policy, buffering and backpressure. */
SUBSCRIPTION(
"advanced.subscription",
"advanced.subscription",
"advanced.security",
"api",
"http",
"security"),
/** The GraphQL over WebSocket protocol state machine. */
WEBSOCKET("advanced.websocket", "advanced.websocket", "advanced.bootstrap");
private final String id;
private final String packageSuffix;
private final GraphQlModulePurity purity;
/**
* Populated only from {@link Set#of}, which is genuinely immutable. Error Prone's {@code
* ImmutableEnumChecker} recognises Guava's {@code ImmutableSet} but not the JDK's unmodifiable
* factories, and this leaf has no Guava dependency to add for one field.
*/
@SuppressWarnings("ImmutableEnumChecker")
private final Set<String> allowedDependencies;
GraphQlAdvancedModule(String id, String packageSuffix, String... allowedDependencies) {
this(id, packageSuffix, GraphQlModulePurity.CORE, allowedDependencies);
}
GraphQlAdvancedModule(
String id, String packageSuffix, GraphQlModulePurity purity, String... allowedDependencies) {
this.id = id;
this.packageSuffix = packageSuffix;
this.purity = purity;
this.allowedDependencies = Set.of(allowedDependencies);
}
/** The module identifier used on both sides of a declared dependency edge. */
public String id() {
return id;
}
/** The fully qualified package that carries this module, including its sub-packages. */
public String packageName() {
return GraphQlModuleBoundary.PACKAGE_ROOT + "." + packageSuffix;
}
/**
* Whether this capability may reference framework types.
*
* <p>Advanced capabilities are policy and state machines, so they are framework free by default
* and the exceptions are declared one by one rather than assumed.
*/
public GraphQlModulePurity purity() {
return purity;
}
/** The module identifiers this module is allowed to import. */
public Set<String> allowedDependencies() {
return allowedDependencies;
}
/** Every Advanced module identifier. */
public static Set<String> moduleIds() {
return Arrays.stream(values())
.map(GraphQlAdvancedModule::id)
.collect(Collectors.toUnmodifiableSet());
}
/** The declared Advanced dependency edges, keyed by module identifier in deterministic order. */
public static Map<String, Set<String>> dependencyEdges() {
Map<String, Set<String>> edges = new TreeMap<>();
for (GraphQlAdvancedModule module : values()) {
edges.put(module.id(), module.allowedDependencies());
}
return Map.copyOf(edges);
}
}
@@ -0,0 +1,126 @@
package dev.caskeleton.adapter.inbound.graphql.moduleboundary;
import java.util.Arrays;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.Optional;
import java.util.Set;
import java.util.TreeMap;
import java.util.stream.Collectors;
import java.util.stream.Stream;
/**
* The declared module map of the GraphQL platform: identities, purity grades and allowed edges.
*
* <p>This type is the read side of {@link GraphQlStableModule} and {@link GraphQlAdvancedModule}.
* It answers the two questions every boundary check needs — "which module owns this package?" and
* "is this edge declared?" — so the rules never re-implement package-prefix arithmetic.
*
* <p>Note what it deliberately does not do: it never reads the source tree. Scanning the checkout
* is build-time work and lives in the test source set, because a running application cannot
* meaningfully react to its own source layout and a runtime scan only fails in the environments
* where the sources are absent.
*/
public final class GraphQlModuleBoundary {
/** The package that carries the whole platform. */
public static final String PACKAGE_ROOT = deriveRootPackage();
private GraphQlModuleBoundary() {}
/** Every declared module identifier, Stable and Advanced. */
public static Set<String> allModuleIds() {
return Stream.concat(
GraphQlStableModule.moduleIds().stream(), GraphQlAdvancedModule.moduleIds().stream())
.collect(Collectors.toUnmodifiableSet());
}
/** Every declared dependency edge, Stable and Advanced, in deterministic order. */
public static Map<String, Set<String>> dependencyEdges() {
Map<String, Set<String>> edges = new TreeMap<>();
edges.putAll(GraphQlStableModule.dependencyEdges());
edges.putAll(GraphQlAdvancedModule.dependencyEdges());
return Map.copyOf(edges);
}
/** Every module that must not reference a framework type. */
public static Set<String> coreModuleIds() {
return Stream.concat(
GraphQlStableModule.coreModuleIds().stream(),
Arrays.stream(GraphQlAdvancedModule.values())
.filter(module -> module.purity() == GraphQlModulePurity.CORE)
.map(GraphQlAdvancedModule::id))
.collect(Collectors.toUnmodifiableSet());
}
/** The declared package of every module, keyed by identifier. */
public static Map<String, String> packagesById() {
Map<String, String> packages = new LinkedHashMap<>(GraphQlStableModule.packagesById());
for (GraphQlAdvancedModule module : GraphQlAdvancedModule.values()) {
packages.put(module.id(), module.packageName());
}
return Map.copyOf(packages);
}
/**
* The module owning a package.
*
* <p>Ownership is by longest declared package prefix, so {@code ...graphql.http.webflux} belongs
* to {@code http} and {@code ...graphql.advanced.sse} belongs to {@code advanced.sse} rather than
* to a hypothetical {@code advanced} module.
*
* <p>The root module is the exception: it owns the platform root package itself and nothing below
* it. Letting it absorb descendants would make every possible package "declared", and the
* unregistered-package rule — the one that forces a new sub-package to be given an identity and
* an edge set before it can ship — would silently never fire.
*
* @param packageName a fully qualified package name
* @return the owning module identifier, or empty when the package is outside the platform or
* belongs to no declared module
*/
public static Optional<String> moduleIdForPackage(String packageName) {
if (packageName == null || !insidePlatform(packageName)) {
return Optional.empty();
}
String best = null;
String bestPackage = null;
for (Map.Entry<String, String> candidate : packagesById().entrySet()) {
String candidatePackage = candidate.getValue();
boolean owns =
candidatePackage.equals(PACKAGE_ROOT)
? packageName.equals(PACKAGE_ROOT)
: matches(packageName, candidatePackage);
if (!owns) {
continue;
}
if (bestPackage == null || candidatePackage.length() > bestPackage.length()) {
best = candidate.getKey();
bestPackage = candidatePackage;
}
}
return Optional.ofNullable(best);
}
/** Whether a package name belongs to the platform at all. */
public static boolean insidePlatform(String packageName) {
return packageName != null && matches(packageName, PACKAGE_ROOT);
}
/** Whether an edge between two declared modules is allowed. */
public static boolean edgeAllowed(String from, String to) {
if (from == null || to == null || from.equals(to)) {
return true;
}
Set<String> allowed = dependencyEdges().get(from);
return allowed != null && allowed.contains(to);
}
private static boolean matches(String packageName, String candidate) {
return packageName.equals(candidate) || packageName.startsWith(candidate + ".");
}
private static String deriveRootPackage() {
String self = GraphQlModuleBoundary.class.getPackageName();
return self.substring(0, self.lastIndexOf('.'));
}
}
@@ -0,0 +1,19 @@
package dev.caskeleton.adapter.inbound.graphql.moduleboundary;
/**
* Whether a platform module is allowed to reference transport, framework or GraphQL engine types.
*
* <p>The split is what keeps the policy model portable. A {@link #CORE} module holds the decision
* ("this document is too deep", "this cursor is out of scope") as plain Java, so the same rule can
* be exercised by a unit test, reused from a different transport, or promoted to its own leaf
* without dragging a servlet container along. A {@link #FRAMEWORK_BOUND} module is the seam where
* that decision meets Spring, GraphQL Java or Reactor.
*/
public enum GraphQlModulePurity {
/** Java standard library only: no Spring, GraphQL Java, Reactor, Micrometer or Jakarta types. */
CORE,
/** May bind to the framework, because it is the adapter seam that has to. */
FRAMEWORK_BOUND
}
@@ -0,0 +1,224 @@
package dev.caskeleton.adapter.inbound.graphql.moduleboundary;
import java.util.Arrays;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.Set;
import java.util.TreeMap;
import java.util.stream.Collectors;
/**
* The Stable GraphQL platform modules, their purity grade and their allowed internal dependencies.
*
* <p>The platform splits into bounded sub-packages inside one registered leaf rather than into
* Gradle leaves (see {@code CLAUDE.md}). That choice only holds if the boundaries are machine
* checked, so this enum is the declared identity: each constant names a module, the package that
* carries it, whether it may touch the framework, and exactly which other modules it may import.
* {@code GraphQlModuleBoundaryTest} scans the real source tree and fails when the tree and this
* declaration disagree in either direction — an undeclared edge, or an undeclared package.
*
* <p>Declaring the edge set is what makes the leaf split reversible: each constant is already
* shaped like a leaf specification, so promoting a module to its own Gradle path is a registry edit
* rather than an archaeology exercise.
*/
public enum GraphQlStableModule {
/** Skeleton transport surface: health schema controller and the Spring exception resolver. */
TRANSPORT_ROOT("root", "", GraphQlModulePurity.FRAMEWORK_BOUND),
/** Shared identifiers and value types every other module is allowed to speak. */
API("api", "api", GraphQlModulePurity.CORE),
/** Resolver and controller boundary rules enforced by reflection over the compiled package. */
ARCHITECTURE("architecture", "architecture", GraphQlModulePurity.FRAMEWORK_BOUND),
/** Spring auto-configuration, configuration properties and startup validation. */
AUTOCONFIGURE(
"autoconfigure",
"autoconfigure",
GraphQlModulePurity.FRAMEWORK_BOUND,
"api",
"architecture",
"context",
"cost",
"error",
"execution",
"http",
"observation",
"policy",
"runtime",
"scalar",
"schema",
"security"),
/** Schema compatibility comparison and deprecation gating. */
COMPAT("compat", "compat", GraphQlModulePurity.FRAMEWORK_BOUND, "schema"),
/** Per-request execution context: actor, tenant and deadline. */
CONTEXT("context", "context", GraphQlModulePurity.CORE, "api"),
/** Document shape analysis, complexity scoring and runtime budget tracking. */
COST("cost", "cost", GraphQlModulePurity.FRAMEWORK_BOUND, "api", "execution", "policy"),
/** Batch loading, chunking and per-request DataLoader registry. */
DATALOADER("dataloader", "dataloader", GraphQlModulePurity.CORE, "context"),
/** Wire error shape, error categories and null propagation contract. */
ERROR("error", "error", GraphQlModulePurity.CORE, "api", "http"),
/** Execution pipeline stages, operation naming, timeouts and document caching. */
EXECUTION("execution", "execution", GraphQlModulePurity.CORE, "api", "context", "policy"),
/** Fetch profile classification and registry. */
FETCH("fetch", "fetch", GraphQlModulePurity.CORE, "api"),
/**
* HTTP profile, request envelope validation, media negotiation and status mapping.
*
* <p>{@code CORE} since the custom MVC and WebFlux transport adapters were removed: what remains
* is the platform's opinion about the HTTP contract, expressed as plain values. The route belongs
* to Spring, and the seam that applies these decisions to it lives in {@code runtime}.
*/
HTTP("http", "http", GraphQlModulePurity.CORE, "api", "context", "execution", "policy"),
/** This module: the declared module identities and their allowed edges. */
MODULE_BOUNDARY("moduleboundary", "moduleboundary", GraphQlModulePurity.CORE),
/** Mutation idempotency context and result mapping. */
MUTATION("mutation", "mutation", GraphQlModulePurity.CORE, "api", "context", "http"),
/** Observation conventions, metric cardinality policy and sensitive attribute filtering. */
OBSERVATION(
"observation",
"observation",
GraphQlModulePurity.CORE,
"api",
"cost",
"dataloader",
"policy"),
/** Connection assembly and signed keyset cursors. */
PAGINATION("pagination", "pagination", GraphQlModulePurity.CORE, "policy"),
/** Client and operation policy values shared by the enforcement modules. */
POLICY("policy", "policy", GraphQlModulePurity.CORE, "api"),
/** Release gate, performance and fault scenario catalogues. */
RELEASE("release", "release", GraphQlModulePurity.CORE),
/** The executable pipeline and the Spring GraphQL seams that run it on a real request. */
RUNTIME(
"runtime",
"runtime",
GraphQlModulePurity.FRAMEWORK_BOUND,
"api",
"context",
"cost",
"dataloader",
"error",
"execution",
"http",
"policy",
"security"),
/** Custom scalar coercions and the runtime wiring configurer that registers them. */
SCALAR("scalar", "scalar", GraphQlModulePurity.FRAMEWORK_BOUND, "schema"),
/** Schema assembly, mapping inspection and scalar manifest. */
SCHEMA("schema", "schema", GraphQlModulePurity.FRAMEWORK_BOUND, "api"),
/** Authentication context, authorization policy and tenant isolation. */
SECURITY("security", "security", GraphQlModulePurity.CORE, "api", "context", "error"),
/** Cross-module contract suites and integration fixtures. */
TESTKIT(
"testkit",
"testkit",
GraphQlModulePurity.CORE,
"api",
"compat",
"context",
"dataloader",
"error",
"execution",
"http",
"pagination",
"policy",
"schema",
"security");
private final String id;
private final String packageSuffix;
private final GraphQlModulePurity purity;
/**
* Populated only from {@link Set#of}, which is genuinely immutable. Error Prone's {@code
* ImmutableEnumChecker} recognises Guava's {@code ImmutableSet} but not the JDK's unmodifiable
* factories, and this leaf has no Guava dependency to add for one field.
*/
@SuppressWarnings("ImmutableEnumChecker")
private final Set<String> allowedDependencies;
GraphQlStableModule(
String id, String packageSuffix, GraphQlModulePurity purity, String... allowedDependencies) {
this.id = id;
this.packageSuffix = packageSuffix;
this.purity = purity;
this.allowedDependencies = Set.of(allowedDependencies);
}
/** The module identifier used on both sides of a declared dependency edge. */
public String id() {
return id;
}
/** The fully qualified package that carries this module, including its sub-packages. */
public String packageName() {
return packageSuffix.isEmpty()
? GraphQlModuleBoundary.PACKAGE_ROOT
: GraphQlModuleBoundary.PACKAGE_ROOT + "." + packageSuffix;
}
/** Whether this module may reference framework types. */
public GraphQlModulePurity purity() {
return purity;
}
/** The module identifiers this module is allowed to import. */
public Set<String> allowedDependencies() {
return allowedDependencies;
}
/** Every Stable module identifier. */
public static Set<String> moduleIds() {
return Arrays.stream(values())
.map(GraphQlStableModule::id)
.collect(Collectors.toUnmodifiableSet());
}
/** The declared Stable dependency edges, keyed by module identifier in deterministic order. */
public static Map<String, Set<String>> dependencyEdges() {
Map<String, Set<String>> edges = new TreeMap<>();
for (GraphQlStableModule module : values()) {
edges.put(module.id(), module.allowedDependencies());
}
return Map.copyOf(edges);
}
/** Every Stable module that must not reference a framework type. */
public static Set<String> coreModuleIds() {
return Arrays.stream(values())
.filter(module -> module.purity() == GraphQlModulePurity.CORE)
.map(GraphQlStableModule::id)
.collect(Collectors.toUnmodifiableSet());
}
/** The declared package name of every Stable module, keyed by identifier. */
public static Map<String, String> packagesById() {
Map<String, String> packages = new LinkedHashMap<>();
for (GraphQlStableModule module : values()) {
packages.put(module.id(), module.packageName());
}
return Map.copyOf(packages);
}
}
@@ -0,0 +1,87 @@
package dev.caskeleton.adapter.inbound.graphql.mutation;
import java.math.BigDecimal;
import java.util.List;
import java.util.Map;
import java.util.TreeMap;
/**
* The one canonical serialization a mutation input is fingerprinted from.
*
* <p>The previous form sorted top-level keys and joined {@code key=value;}, which two different
* inputs could produce identically: {@code {a: "b;c=d"}} and {@code {a: "b", c: "d"}} both
* canonicalise to {@code a=b;c=d;}. Two different requests sharing a fingerprint is an idempotency
* collision — the second one is answered with the first one's result.
*
* <p>Three properties remove the ambiguity. Every value carries a type tag, so the string {@code
* "1"} and the number {@code 1} never collide. Every string is length-prefixed, so no character is
* a separator and no value can imitate the framing. And nesting is sorted recursively, so a map
* inside a list inside a map still canonicalises the same way whatever order it arrived in.
*
* <p>Numbers normalise through {@link BigDecimal}, because {@code 1}, {@code 1.0} and {@code 1e0}
* are the same value and a client library is free to pick any of them for the same field.
*/
public final class GraphQlCanonicalInput {
/** Deepest input nesting this serializer will walk before refusing. */
public static final int MAXIMUM_DEPTH = 64;
private GraphQlCanonicalInput() {}
/**
* Canonicalises a decoded input value.
*
* @param value a decoded JSON value: map, list, string, number, boolean or {@code null}
* @throws GraphQlMutationContractException when nesting exceeds {@link #MAXIMUM_DEPTH}
*/
public static String of(Object value) {
StringBuilder canonical = new StringBuilder();
write(canonical, value, 0);
return canonical.toString();
}
private static void write(StringBuilder out, Object value, int depth) {
if (depth > MAXIMUM_DEPTH) {
throw new GraphQlMutationContractException(
"mutation input nesting exceeds the canonical limit");
}
if (value == null) {
out.append("z;");
return;
}
if (value instanceof Map<?, ?> map) {
out.append("m").append(map.size()).append(';');
// Recursive, not just top level: an unsorted nested map would fingerprint differently for
// the same request depending on the client's serialization order.
Map<String, Object> sorted = new TreeMap<>();
map.forEach((key, entry) -> sorted.put(String.valueOf(key), entry));
sorted.forEach(
(key, entry) -> {
writeString(out, key);
write(out, entry, depth + 1);
});
return;
}
if (value instanceof List<?> list) {
// Order-sensitive on purpose: a list is a sequence, and two orders are two different inputs.
out.append("l").append(list.size()).append(';');
list.forEach(element -> write(out, element, depth + 1));
return;
}
if (value instanceof Boolean flag) {
out.append('b').append(flag ? '1' : '0').append(';');
return;
}
if (value instanceof Number number) {
out.append('n');
writeString(out, new BigDecimal(number.toString()).stripTrailingZeros().toPlainString());
return;
}
out.append('s');
writeString(out, String.valueOf(value));
}
private static void writeString(StringBuilder out, String value) {
out.append(value.length()).append(':').append(value).append(';');
}
}
@@ -23,12 +23,16 @@ public final class GraphQlMutationContractValidator {
*/
public static void requireSingleUseCase(
GraphQlMutationCoordinate coordinate, int useCaseInvocations) {
if (useCaseInvocations > 1) {
// Exactly one, not "at most one". Zero invocations means the mutation resolved without going
// through the Application at all, which is the shape where transaction and authorization
// decisions end up in the resolver — the thing this contract exists to prevent. It read as a
// passing check because the interesting number is on the other side of the boundary.
if (useCaseInvocations != 1) {
throw new GraphQlMutationContractException(
coordinate.value()
+ " calls "
+ useCaseInvocations
+ " use cases; model the atomic operation as one use case instead");
+ " use cases; an atomic mutation is exactly one use case in the Application layer");
}
}
@@ -5,7 +5,6 @@ import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.util.HexFormat;
import java.util.Map;
import java.util.TreeMap;
/**
* A fingerprint of a mutation's normalised input.
@@ -25,12 +24,16 @@ public record GraphQlMutationFingerprint(String value) {
}
}
/** Fingerprints a normalised input map. */
/**
* Fingerprints a decoded input map.
*
* <p>Canonicalised by {@link GraphQlCanonicalInput}, which type-tags and length-frames every
* value and sorts nesting recursively. The previous form joined {@code key=value;} over top-level
* keys only, so {@code {a: "b;c=d"}} and {@code {a: "b", c: "d"}} produced the same fingerprint —
* and an idempotent retry of one returned the other's result.
*/
public static GraphQlMutationFingerprint of(Map<String, ?> normalizedInput) {
StringBuilder canonical = new StringBuilder();
new TreeMap<>(normalizedInput)
.forEach((key, value) -> canonical.append(key).append('=').append(value).append(';'));
return sha256(canonical.toString());
return sha256(GraphQlCanonicalInput.of(normalizedInput));
}
/** Fingerprints already-canonical text. */
@@ -3,22 +3,32 @@ package dev.caskeleton.adapter.inbound.graphql.mutation;
/**
* The full scope one idempotency key applies to (design §15).
*
* <p>Scoped by actor, mutation and normalised input together — not by key alone. A key scoped only
* to itself would let one client's retry return another client's result, and would let the same key
* stand for two different requests.
* <p>Scoped by actor, tenant, mutation, contract version and normalised input together — not by key
* alone. A key scoped only to itself would let one client's retry return another client's result,
* and would let the same key stand for two different requests.
*
* <p>Tenant is part of the scope because actor identity does not imply it: the same service account
* acting for two tenants would otherwise share one idempotency namespace, and a retry issued for
* one tenant could be answered with the other tenant's stored result. Contract version is part of
* it because a mutation whose input or semantics changed is a different operation — replaying the
* old result against the new contract is the silent wrong answer versioning exists to prevent.
*
* <p>This is context handed to the Application's idempotency capability. The platform does not
* implement replay, record storage or locking: those need transactional guarantees the transport
* layer cannot give.
*
* @param actorFingerprint non-reversible actor identity
* @param tenantFingerprint non-reversible tenant identity
* @param coordinate the mutation the key belongs to
* @param contractVersion the mutation contract version the key was issued under
* @param key the client-supplied key
* @param fingerprint fingerprint of the normalised input
*/
public record GraphQlMutationIdempotencyContext(
String actorFingerprint,
String tenantFingerprint,
GraphQlMutationCoordinate coordinate,
String contractVersion,
GraphQlIdempotencyKey key,
GraphQlMutationFingerprint fingerprint) {
@@ -26,6 +36,12 @@ public record GraphQlMutationIdempotencyContext(
if (actorFingerprint == null || actorFingerprint.isBlank()) {
throw new IllegalArgumentException("actor fingerprint is required");
}
if (tenantFingerprint == null || tenantFingerprint.isBlank()) {
throw new IllegalArgumentException("tenant fingerprint is required");
}
if (contractVersion == null || contractVersion.isBlank()) {
throw new IllegalArgumentException("mutation contract version is required");
}
if (coordinate == null || key == null || fingerprint == null) {
throw new IllegalArgumentException(
"idempotency scope requires coordinate, key and fingerprint");
@@ -35,10 +51,13 @@ public record GraphQlMutationIdempotencyContext(
/** Creates the scope. */
public static GraphQlMutationIdempotencyContext of(
String actorFingerprint,
String tenantFingerprint,
GraphQlMutationCoordinate coordinate,
String contractVersion,
GraphQlIdempotencyKey key,
GraphQlMutationFingerprint fingerprint) {
return new GraphQlMutationIdempotencyContext(actorFingerprint, coordinate, key, fingerprint);
return new GraphQlMutationIdempotencyContext(
actorFingerprint, tenantFingerprint, coordinate, contractVersion, key, fingerprint);
}
/**
@@ -55,9 +74,22 @@ public record GraphQlMutationIdempotencyContext(
/**
* The storage scope for the Application's idempotency record.
*
* <p>Uses the actor fingerprint rather than the actor, so the scope can be persisted and logged.
* <p>Uses fingerprints rather than the raw actor and tenant, so the scope can be persisted and
* logged. Length-framed for the same reason the canonical input form is: joining five
* caller-influenced values with a separator lets one of them contain the separator and collide
* with a different scope.
*/
public String scope() {
return actorFingerprint + "|" + coordinate.value() + "|" + key.value();
StringBuilder scope = new StringBuilder();
frame(scope, actorFingerprint);
frame(scope, tenantFingerprint);
frame(scope, coordinate.value());
frame(scope, contractVersion);
frame(scope, key.value());
return scope.toString();
}
private static void frame(StringBuilder out, String value) {
out.append(value.length()).append(':').append(value).append('|');
}
}
@@ -31,6 +31,7 @@ public final class GraphQlMutationIdempotencyInterceptor {
public static Optional<GraphQlMutationIdempotencyContext> from(
GraphQlRequestContext context,
GraphQlMutationCoordinate coordinate,
String contractVersion,
Map<String, Object> extensions,
Map<String, ?> normalizedInput) {
@@ -45,7 +46,9 @@ public final class GraphQlMutationIdempotencyInterceptor {
return Optional.of(
GraphQlMutationIdempotencyContext.of(
context.actor().fingerprint(),
context.tenant().fingerprint(),
coordinate,
contractVersion,
new GraphQlIdempotencyKey(key),
GraphQlMutationFingerprint.of(normalizedInput)));
}
@@ -0,0 +1,74 @@
package dev.caskeleton.adapter.inbound.graphql.observation;
import dev.caskeleton.adapter.inbound.graphql.api.GraphQlOperationName;
import java.util.Set;
/**
* Decides which operation names are allowed to become metric labels.
*
* <p>{@link GraphQlOperationName} bounds an operation name's <em>syntax</em> and length, which is a
* different property from bounding how many distinct ones exist. A client is free to send {@code
* Query0000001}, {@code Query0000002} and so on indefinitely: every one is valid, and every one
* used to become its own time series. That is a metrics backend brought down by a well-formed
* client, and the tag that did it looked bounded because a regular expression was checking it.
*
* <p>So the label is drawn from a set the deployment declares, not from the request. Anything
* outside it collapses to {@link #UNREGISTERED} — the request is still counted, still timed and
* still attributed to its type, profile and outcome; only the one unbounded coordinate is dropped.
*
* <p>The default is an empty registry, which collapses every named operation. Defaulting the other
* way would mean every deployment that never thought about this ships the unbounded behaviour, and
* a cardinality bound that is opt-in is a cardinality bound nobody has.
*/
public final class GraphQlOperationNameCardinality {
/** The label used for any operation the deployment did not register. */
public static final String UNREGISTERED = "other";
private final Set<String> registered;
/**
* Creates the policy.
*
* @param registered operation names the deployment knows, typically the persisted registry's
*/
public GraphQlOperationNameCardinality(Set<String> registered) {
if (registered == null) {
throw new IllegalArgumentException("registered operation names are required");
}
this.registered = Set.copyOf(registered);
}
/** A policy that collapses every named operation, for a deployment with no registry. */
public static GraphQlOperationNameCardinality collapsingAll() {
return new GraphQlOperationNameCardinality(Set.of());
}
/**
* The bounded label for one operation.
*
* @param operationName the validated name, or {@code null} for a permitted anonymous operation
* @return the registered name, the anonymous value, or {@link #UNREGISTERED}
*/
public String labelFor(GraphQlOperationName operationName) {
if (operationName == null) {
return GraphQlOperationName.ANONYMOUS_OBSERVATION_VALUE;
}
return registered.contains(operationName.value()) ? operationName.value() : UNREGISTERED;
}
/**
* The number of distinct labels this policy can ever produce.
*
* <p>Stated as a number because "bounded" is a claim an operator should be able to check against
* their backend's series budget before enabling the tag.
*/
public int distinctLabels() {
return registered.size() + 2;
}
/** The registered operation names. */
public Set<String> registered() {
return registered;
}
}
@@ -10,32 +10,57 @@ import java.util.Map;
/**
* Tags for the {@code graphql.request} observation (design §22).
*
* <p>Everything here is bounded by construction: a validated operation name, an enum, a bounded
* client profile and pre-computed buckets. Depth and complexity are bucketed rather than reported
* exactly, because the exact numbers are effectively continuous and would create a new time series
* per request.
* <p>Everything here is bounded by construction: an enum, a bounded client profile, pre-computed
* buckets, and an operation name drawn from the deployment's registry rather than from the request.
* Depth and complexity are bucketed rather than reported exactly, because the exact numbers are
* effectively continuous and would create a new time series per request.
*
* <p>The operation name was the exception, and it was the one that mattered: it was tagged raw, on
* the strength of a regular expression that bounds its syntax and says nothing about how many
* distinct names a client may invent. {@link GraphQlOperationNameCardinality} closes it.
*/
public final class GraphQlRequestObservationConvention {
private final GraphQlSensitiveAttributeFilter filter;
private final GraphQlOperationNameCardinality operationNames;
/**
* Creates the convention, collapsing every operation name.
*
* @param filter attribute allowlist and sensitivity filter
*/
public GraphQlRequestObservationConvention(GraphQlSensitiveAttributeFilter filter) {
this(filter, GraphQlOperationNameCardinality.collapsingAll());
}
/**
* Creates the convention.
*
* @param filter attribute allowlist and sensitivity filter
* @param operationNames which operation names may become labels
*/
public GraphQlRequestObservationConvention(GraphQlSensitiveAttributeFilter filter) {
public GraphQlRequestObservationConvention(
GraphQlSensitiveAttributeFilter filter, GraphQlOperationNameCardinality operationNames) {
if (filter == null) {
throw new IllegalArgumentException("attribute filter is required");
}
if (operationNames == null) {
throw new IllegalArgumentException("operation name cardinality policy is required");
}
this.filter = filter;
this.operationNames = operationNames;
}
/** A convention using the standard filter. */
/** A convention using the standard filter and no registered operation names. */
public static GraphQlRequestObservationConvention standard() {
return new GraphQlRequestObservationConvention(GraphQlSensitiveAttributeFilter.standard());
}
/** How many distinct operation-name labels this convention can produce. */
public int distinctOperationNameLabels() {
return operationNames.distinctLabels();
}
/** The observation name. */
public String name() {
return GraphQlObservationNames.REQUEST;
@@ -65,11 +90,7 @@ public final class GraphQlRequestObservationConvention {
int depth) {
Map<String, String> tags = new LinkedHashMap<>();
tags.put(
"graphql.operation.name",
operationName == null
? GraphQlOperationName.ANONYMOUS_OBSERVATION_VALUE
: operationName.value());
tags.put("graphql.operation.name", operationNames.labelFor(operationName));
tags.put("graphql.operation.type", operationType.name());
tags.put("graphql.client.profile", clientProfile.value());
tags.put("graphql.persisted", Boolean.toString(persisted));
@@ -17,6 +17,7 @@ public final class GraphQlConnectionAssembler {
private final GraphQlCursorCodec codec;
private final String queryProfile;
private final String filterFingerprint;
private final String tenantScope;
/**
* Creates the assembler.
@@ -24,23 +25,14 @@ public final class GraphQlConnectionAssembler {
* @param codec signs the cursors it issues
* @param queryProfile the query cursors will be bound to
* @param filterFingerprint the filter cursors will be bound to
* @param tenantScope opaque fingerprint of the caller scope cursors will be bound to
*/
public GraphQlConnectionAssembler(
GraphQlCursorCodec codec, String queryProfile, String filterFingerprint) {
GraphQlCursorCodec codec, String queryProfile, String filterFingerprint, String tenantScope) {
this.codec = Objects.requireNonNull(codec);
this.queryProfile = Objects.requireNonNull(queryProfile);
this.filterFingerprint = Objects.requireNonNull(filterFingerprint);
}
/** An assembler with a fixed test key, for contract tests. */
public static GraphQlConnectionAssembler forTests() {
return new GraphQlConnectionAssembler(
HmacGraphQlCursorCodec.testCodec(
GraphQlCursorPayload.DEFAULT_KEY_ID,
"test-cursor-secret-test-cursor-secret"
.getBytes(java.nio.charset.StandardCharsets.UTF_8)),
"test-profile",
"test-filter");
this.tenantScope = Objects.requireNonNull(tenantScope, "cursor tenant scope is required");
}
/**
@@ -93,16 +85,24 @@ public final class GraphQlConnectionAssembler {
private <T> String cursorFor(
T node, Function<T, Map<String, String>> keysetOf, String direction) {
return codec.encode(
GraphQlCursorPayload.of(queryProfile, direction, keysetOf.apply(node), filterFingerprint));
GraphQlCursorPayload.issue(
queryProfile, direction, keysetOf.apply(node), filterFingerprint, tenantScope));
}
/**
* Decodes the cursor a request supplied, checking it belongs to this query and filter.
* Decodes the cursor a request supplied, checking it belongs to this query, filter, direction and
* caller scope.
*
* <p>The direction comes from the request rather than from the cursor: a forward cursor replayed
* on a backward request used to be accepted, and the page it resumed from was the wrong side of
* the boundary.
*
* @throws GraphQlCursorException when it does not
*/
public java.util.Optional<GraphQlCursorPayload> decodeRequestCursor(
GraphQlConnectionRequest request) {
return request.cursor().map(cursor -> codec.decode(cursor, queryProfile, filterFingerprint));
GraphQlCursorScope expected =
new GraphQlCursorScope(queryProfile, filterFingerprint, request.direction(), tenantScope);
return request.cursor().map(cursor -> codec.decode(cursor, expected));
}
}
@@ -3,23 +3,25 @@ package dev.caskeleton.adapter.inbound.graphql.pagination;
/**
* Encodes and decodes cursors.
*
* <p>Decoding takes the expected query profile and filter fingerprint, because verifying a
* signature only proves the server issued the cursor — not that it issued it for <em>this</em>
* query. Both checks together are what make a cursor safe to accept.
* <p>Decoding takes the whole expected scope, because verifying a signature only proves the server
* issued the cursor — not that it issued it for this query, this filter, this direction and this
* tenant. The signature and the scope together are what make a cursor safe to accept.
*/
public interface GraphQlCursorCodec {
/** Encodes and signs a payload into an opaque cursor. */
/**
* Encodes and signs a payload into an opaque cursor.
*
* <p>The signing key is the codec's active one; a payload cannot choose it.
*/
String encode(GraphQlCursorPayload payload);
/**
* Verifies and decodes a cursor.
*
* @param cursor the opaque cursor
* @param expectedQueryProfile the query the cursor is being used for
* @param expectedFilterFingerprint the filter the cursor is being used under
* @throws GraphQlCursorException on any mismatch, bad signature or unknown version or key
* @param expected what this request requires the cursor to have been issued for
* @throws GraphQlCursorException on any mismatch, bad signature, unknown version or unknown key
*/
GraphQlCursorPayload decode(
String cursor, String expectedQueryProfile, String expectedFilterFingerprint);
GraphQlCursorPayload decode(String cursor, GraphQlCursorScope expected);
}
@@ -0,0 +1,67 @@
package dev.caskeleton.adapter.inbound.graphql.pagination;
import java.util.ArrayList;
import java.util.List;
/**
* Length-prefixed framing for the signed cursor envelope.
*
* <p>The v1 envelope joined fields with {@code |} and pairs with {@code ;} and {@code =}, escaping
* those characters inside keyset values. It did not survive a round trip: the decoder split on the
* delimiters <em>before</em> unescaping, so an escaped separator in a sort value tore the field
* apart, and three fields — query profile, filter fingerprint, key id — were never escaped at all.
* A sort value containing a pipe was enough to make a legitimately issued cursor unreadable.
*
* <p>Framing removes the problem rather than escaping around it. Each field is written as its
* length, a colon, and the value, so the reader knows exactly how far to read and no character is
* special. Lengths count {@code char} units, which is what {@code substring} consumes, so a
* surrogate pair frames and reads back identically.
*/
public final class GraphQlCursorFraming {
/** Ceiling on fields in one envelope, so a hostile token cannot allocate without bound. */
public static final int MAXIMUM_FIELDS = 256;
private GraphQlCursorFraming() {}
/** Appends one length-prefixed field. */
public static void write(StringBuilder out, String value) {
String safe = value == null ? "" : value;
out.append(safe.length()).append(':').append(safe);
}
/**
* Reads every length-prefixed field, requiring the body to be consumed exactly.
*
* @param framed the framed body, without the version prefix
* @throws GraphQlCursorException when the framing is malformed, truncated or over-long
*/
public static List<String> readAll(String framed) {
List<String> fields = new ArrayList<>();
int cursor = 0;
while (cursor < framed.length()) {
if (fields.size() == MAXIMUM_FIELDS) {
throw new GraphQlCursorException("cursor envelope has too many fields");
}
int separator = framed.indexOf(':', cursor);
if (separator < 0) {
throw new GraphQlCursorException("malformed cursor");
}
int length;
try {
length = Integer.parseInt(framed.substring(cursor, separator));
} catch (NumberFormatException malformed) {
throw new GraphQlCursorException("malformed cursor");
}
int valueStart = separator + 1;
// A declared length longer than what remains is the truncation case; reading it would throw
// StringIndexOutOfBounds instead of rejecting the token.
if (length < 0 || valueStart + length > framed.length()) {
throw new GraphQlCursorException("malformed cursor");
}
fields.add(framed.substring(valueStart, valueStart + length));
cursor = valueStart + length;
}
return List.copyOf(fields);
}
}
@@ -55,9 +55,14 @@ public final class GraphQlCursorKeyRing {
return activeKeyId;
}
/** Key identities that can still verify a cursor. */
/**
* Key identities that can still verify a cursor.
*
* <p>A copy. {@code keySet()} is a live view of the backing map, so handing it out let a caller
* remove a key identity from the ring — retiring a signing key by accident, through a getter.
*/
public Set<String> keyIds() {
return keys.keySet();
return Set.copyOf(keys.keySet());
}
/**
@@ -12,12 +12,19 @@ import java.util.Map;
*
* <p>The payload deliberately holds sort values and identifiers only: no credential, no raw tenant.
*
* <p>{@code tenantScope} binds the cursor to the caller it was issued for. Without it a cursor is a
* position in a result set and nothing more, so one tenant's cursor replayed by another resumes a
* scan the second tenant was never entitled to start. It is an opaque fingerprint supplied by the
* caller, never a raw tenant identifier: this module must stay free of the context types, and a
* cursor is a value a client holds and can read.
*
* @param version envelope version
* @param queryProfile the query this cursor belongs to
* @param direction {@code FORWARD} or {@code BACKWARD}
* @param keyset sort position
* @param filterFingerprint fingerprint of the filter the cursor was issued under
* @param keyId signing key identity, so keys can rotate
* @param tenantScope opaque fingerprint of the tenant and actor scope the cursor was issued for
* @param keyId signing key identity, stamped by the codec so keys can rotate
*/
public record GraphQlCursorPayload(
int version,
@@ -25,6 +32,7 @@ public record GraphQlCursorPayload(
String direction,
Map<String, String> keyset,
String filterFingerprint,
String tenantScope,
String keyId) {
/** Forward pagination. */
@@ -47,52 +55,69 @@ public record GraphQlCursorPayload(
if (filterFingerprint == null || filterFingerprint.isBlank()) {
throw new GraphQlCursorException("cursor filter fingerprint is required");
}
if (tenantScope == null || tenantScope.isBlank()) {
throw new GraphQlCursorException("cursor tenant scope is required");
}
if (keyId == null || keyId.isBlank()) {
throw new GraphQlCursorException("cursor key id is required");
}
keyset = GraphQlCursorKeyset.of(keyset).values();
}
/** Creates a current-version payload signed by the default key. */
public static GraphQlCursorPayload of(
String queryProfile, String direction, Map<String, String> keyset, String filterFingerprint) {
return new GraphQlCursorPayload(
GraphQlCursorVersion.CURRENT,
queryProfile,
direction,
Map.copyOf(keyset),
filterFingerprint,
DEFAULT_KEY_ID);
}
/** Creates a current-version payload signed by a named key. */
public static GraphQlCursorPayload of(
/**
* Creates a payload to be issued.
*
* <p>No key identity: the codec stamps the active one. A caller that could name the signing key
* could pin every cursor to a retired key and quietly opt out of rotation.
*/
public static GraphQlCursorPayload issue(
String queryProfile,
String direction,
Map<String, String> keyset,
String filterFingerprint,
String keyId) {
String tenantScope) {
return new GraphQlCursorPayload(
GraphQlCursorVersion.CURRENT,
queryProfile,
direction,
Map.copyOf(keyset),
filterFingerprint,
keyId);
tenantScope,
PENDING_KEY_ID);
}
/** Deterministic encoding of everything the signature covers. */
public String canonicalForm() {
return version
+ "|"
+ queryProfile
+ "|"
+ direction
+ "|"
+ new GraphQlCursorKeyset(keyset).canonicalForm()
+ "|"
+ filterFingerprint
+ "|"
+ keyId;
/** Placeholder key identity on a payload the codec has not signed yet. */
public static final String PENDING_KEY_ID = "pending";
/** Returns a copy stamped with the key that signed it. */
public GraphQlCursorPayload signedWith(String activeKeyId) {
return new GraphQlCursorPayload(
version, queryProfile, direction, keyset, filterFingerprint, tenantScope, activeKeyId);
}
/**
* Deterministic encoding of everything the signature covers.
*
* <p>Length-prefixed rather than delimiter-joined, so no field needs escaping and none can be
* confused with the framing. Keyset entries are sorted by {@link GraphQlCursorKeyset}, so the
* same position always signs to the same bytes.
*/
public String canonicalForm() {
StringBuilder canonical = new StringBuilder();
canonical.append(version).append('|');
GraphQlCursorFraming.write(canonical, queryProfile);
GraphQlCursorFraming.write(canonical, direction);
GraphQlCursorFraming.write(canonical, filterFingerprint);
GraphQlCursorFraming.write(canonical, tenantScope);
GraphQlCursorFraming.write(canonical, keyId);
keyset.forEach(
(key, value) -> {
GraphQlCursorFraming.write(canonical, key);
GraphQlCursorFraming.write(canonical, value);
});
return canonical.toString();
}
/** How many framed fields precede the keyset pairs. */
static final int FIXED_FIELDS = 5;
}
@@ -0,0 +1,57 @@
package dev.caskeleton.adapter.inbound.graphql.pagination;
import java.util.Objects;
/**
* Everything a presented cursor has to match before it may be used.
*
* <p>Verifying the signature proves the server issued the cursor. It does not prove the server
* issued it for <em>this</em> request, and each field here is a way that gap was exploitable: a
* forward cursor replayed as a backward one walks the page boundary in the wrong direction, and a
* cursor from one tenant replayed by another resumes a scan the second tenant could never have
* started. Passing the whole expectation as one value is what stops a new caller from checking
* three of the four and looking correct.
*
* @param queryProfile the query the cursor is being used for
* @param filterFingerprint the filter the cursor is being used under
* @param direction the direction this request is paginating in
* @param tenantScope opaque fingerprint of the caller's tenant and actor scope
*/
public record GraphQlCursorScope(
String queryProfile, String filterFingerprint, String direction, String tenantScope) {
/**
* Tenant scope carried by a v1 cursor, which bound none.
*
* <p>A distinct value rather than {@code null}, so a v1 cursor presented against a real scope
* fails the comparison like any other mismatch instead of skipping the check.
*/
public static final String LEGACY_UNSCOPED = "legacy-unscoped";
public GraphQlCursorScope {
Objects.requireNonNull(queryProfile, "query profile is required");
Objects.requireNonNull(filterFingerprint, "filter fingerprint is required");
Objects.requireNonNull(direction, "direction is required");
Objects.requireNonNull(tenantScope, "tenant scope is required");
}
/**
* Verifies a decoded payload against this scope.
*
* @throws GraphQlCursorException naming the first field that does not match
*/
public void verify(GraphQlCursorPayload payload) {
if (!payload.queryProfile().equals(queryProfile)) {
throw new GraphQlCursorException("cursor was issued for a different query profile");
}
if (!payload.filterFingerprint().equals(filterFingerprint)) {
throw new GraphQlCursorException("cursor was issued for a different filter");
}
if (!payload.direction().equals(direction)) {
throw new GraphQlCursorException("cursor was issued for a different pagination direction");
}
if (!payload.tenantScope().equals(tenantScope)) {
throw new GraphQlCursorException("cursor was issued for a different tenant scope");
}
}
}
@@ -11,11 +11,24 @@ import java.util.Set;
*/
public final class GraphQlCursorVersion {
/** Current envelope version. */
public static final int CURRENT = 1;
/**
* Current envelope version: length-prefixed framing, tenant scope, codec-stamped key.
*
* <p>Only this version is issued.
*/
public static final int CURRENT = 2;
/**
* The delimiter-escaped envelope, still decodable during migration.
*
* <p>Kept readable because cursors already in clients' hands outlive a deploy. It is never
* issued: its framing could not round-trip a sort value containing a delimiter, and it binds no
* tenant scope.
*/
public static final int LEGACY_DELIMITED = 1;
/** Versions this deployment will still decode. */
public static final Set<Integer> SUPPORTED = Set.of(CURRENT);
public static final Set<Integer> SUPPORTED = Set.of(LEGACY_DELIMITED, CURRENT);
private GraphQlCursorVersion() {}
@@ -4,6 +4,7 @@ import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.util.Base64;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import javax.crypto.Mac;
@@ -18,9 +19,15 @@ import javax.crypto.spec.SecretKeySpec;
*
* <p>Signatures are compared with {@link MessageDigest#isEqual}, whose timing does not depend on
* where the first differing byte is — a normal string comparison would leak that position.
*
* <p>The codec chooses the signing key. Letting the payload name it meant a caller could pin every
* new cursor to a key that was being retired, which is rotation that never completes.
*/
public final class HmacGraphQlCursorCodec implements GraphQlCursorCodec {
/** Largest cursor token accepted, before any decoding work is done. */
public static final int MAXIMUM_CURSOR_CHARS = 4096;
private static final String ALGORITHM = "HmacSHA256";
private static final Base64.Encoder ENCODER = Base64.getUrlEncoder().withoutPadding();
private static final Base64.Decoder DECODER = Base64.getUrlDecoder();
@@ -36,27 +43,29 @@ public final class HmacGraphQlCursorCodec implements GraphQlCursorCodec {
this.keyRing = Objects.requireNonNull(keyRing);
}
/** A single-key codec for tests and single-key deployments. */
public static HmacGraphQlCursorCodec testCodec(String keyId, byte[] secret) {
return new HmacGraphQlCursorCodec(GraphQlCursorKeyRing.single(keyId, secret));
}
@Override
public String encode(GraphQlCursorPayload payload) {
String canonical = payload.canonicalForm();
byte[] signature = sign(canonical, keyRing.secret(payload.keyId()));
if (payload.version() != GraphQlCursorVersion.CURRENT) {
throw new GraphQlCursorException("only the current cursor version is issued");
}
GraphQlCursorPayload signed = payload.signedWith(keyRing.activeKeyId());
String canonical = signed.canonicalForm();
byte[] signature = sign(canonical, keyRing.secret(signed.keyId()));
return ENCODER.encodeToString(canonical.getBytes(StandardCharsets.UTF_8))
+ "."
+ ENCODER.encodeToString(signature);
}
@Override
public GraphQlCursorPayload decode(
String cursor, String expectedQueryProfile, String expectedFilterFingerprint) {
public GraphQlCursorPayload decode(String cursor, GraphQlCursorScope expected) {
Objects.requireNonNull(expected, "expected cursor scope is required");
if (cursor == null || cursor.isBlank()) {
throw new GraphQlCursorException("cursor is required");
}
// Bounded before any decode: an oversized token is refused without allocating a copy of it.
if (cursor.length() > MAXIMUM_CURSOR_CHARS) {
throw new GraphQlCursorException("cursor is too large");
}
int separator = cursor.indexOf('.');
if (separator < 0) {
throw new GraphQlCursorException("malformed cursor");
@@ -68,7 +77,7 @@ public final class HmacGraphQlCursorCodec implements GraphQlCursorCodec {
canonical =
new String(DECODER.decode(cursor.substring(0, separator)), StandardCharsets.UTF_8);
presentedSignature = DECODER.decode(cursor.substring(separator + 1));
} catch (IllegalArgumentException ex) {
} catch (IllegalArgumentException malformed) {
throw new GraphQlCursorException("malformed cursor");
}
@@ -77,34 +86,63 @@ public final class HmacGraphQlCursorCodec implements GraphQlCursorCodec {
if (!MessageDigest.isEqual(expectedSignature, presentedSignature)) {
throw new GraphQlCursorException("cursor signature mismatch");
}
if (!payload.queryProfile().equals(expectedQueryProfile)) {
throw new GraphQlCursorException("cursor was issued for a different query profile");
}
if (!payload.filterFingerprint().equals(expectedFilterFingerprint)) {
throw new GraphQlCursorException("cursor was issued for a different filter");
}
expected.verify(payload);
return payload;
}
private static GraphQlCursorPayload parse(String canonical) {
String[] parts = canonical.split("\\|", -1);
if (parts.length != 6) {
int versionEnd = canonical.indexOf('|');
if (versionEnd < 0) {
throw new GraphQlCursorException("malformed cursor");
}
int version;
try {
version = Integer.parseInt(parts[0]);
} catch (NumberFormatException ex) {
version = Integer.parseInt(canonical.substring(0, versionEnd));
} catch (NumberFormatException malformed) {
throw new GraphQlCursorException("malformed cursor");
}
GraphQlCursorVersion.require(version);
return new GraphQlCursorPayload(
version, parts[1], parts[2], parseKeyset(parts[3]), parts[4], parts[5]);
String body = canonical.substring(versionEnd + 1);
return version == GraphQlCursorVersion.CURRENT ? parseFramed(body) : parseLegacy(body);
}
private static Map<String, String> parseKeyset(String encoded) {
private static GraphQlCursorPayload parseFramed(String body) {
List<String> fields = GraphQlCursorFraming.readAll(body);
int pairFields = fields.size() - GraphQlCursorPayload.FIXED_FIELDS;
if (pairFields < 2 || pairFields % 2 != 0) {
throw new GraphQlCursorException("malformed cursor");
}
Map<String, String> keyset = new LinkedHashMap<>();
for (String entry : encoded.split(";", -1)) {
for (int index = GraphQlCursorPayload.FIXED_FIELDS; index < fields.size(); index += 2) {
keyset.put(fields.get(index), fields.get(index + 1));
}
return new GraphQlCursorPayload(
GraphQlCursorVersion.CURRENT,
fields.get(0),
fields.get(1),
keyset,
fields.get(2),
fields.get(3),
fields.get(4));
}
/**
* Reads the delimiter-escaped v1 envelope.
*
* <p>Unescaping happens per field after the split, which is the ordering v1's own decoder got
* wrong. A v1 cursor whose sort value contained a delimiter was never readable, so there is no
* correct behaviour to preserve for that case — it is rejected here rather than mis-parsed.
*
* <p>v1 bound no tenant scope, so the decoded payload carries the sentinel below and any caller
* expecting a real scope rejects it.
*/
private static GraphQlCursorPayload parseLegacy(String body) {
String[] parts = body.split("\\|", -1);
if (parts.length != 5) {
throw new GraphQlCursorException("malformed cursor");
}
Map<String, String> keyset = new LinkedHashMap<>();
for (String entry : parts[2].split(";", -1)) {
if (entry.isBlank()) {
continue;
}
@@ -117,7 +155,14 @@ public final class HmacGraphQlCursorCodec implements GraphQlCursorCodec {
if (keyset.isEmpty()) {
throw new GraphQlCursorException("malformed cursor");
}
return keyset;
return new GraphQlCursorPayload(
GraphQlCursorVersion.LEGACY_DELIMITED,
parts[0],
parts[1],
keyset,
parts[3],
GraphQlCursorScope.LEGACY_UNSCOPED,
parts[4]);
}
private static String unescape(String value) {
@@ -69,6 +69,40 @@ public record GraphQlClientPolicy(
}
}
/**
* The policy an adopter gets before tuning anything, driven by the platform's own settings.
*
* <p>Page size, complexity budget and introspection come from configuration because those are the
* three an operator actually sets; the remaining ceilings are starting points meant to be
* calibrated against measured latency and statement counts, as the design says. They are
* deliberately generous enough that a correct client is never refused and tight enough that a
* hostile document is, which is the only property a default can honestly claim.
*
* @param maximumPageSize largest page size a client may request
* @param maximumComplexity largest accepted pre-execution complexity score
* @param introspectionAllowed whether this deployment answers introspection
*/
public static GraphQlClientPolicy defaults(
int maximumPageSize, long maximumComplexity, boolean introspectionAllowed) {
return new GraphQlClientPolicy(
16 * 1024,
64 * 1024,
12,
500,
50,
50,
1_000,
Math.min(20, Math.max(1, maximumPageSize)),
Math.max(1, maximumPageSize),
Math.max(1, maximumComplexity),
10_000,
5L * 1024 * 1024,
Duration.ofSeconds(10),
introspectionAllowed,
false,
false);
}
/**
* The effective page size for a connection request.
*
@@ -38,8 +38,7 @@ public final class GraphQlStableCapabilityManifest {
"SSE_SUBSCRIPTION",
"FEDERATION_SUBGRAPH",
"DATALOADER_CHAINING",
"CODE_GENERATION",
"SPRING_DATA_COMPAT");
"CODE_GENERATION");
/** Capabilities that remain experimental until a promotion decision is recorded. */
public static final Set<String> EXPERIMENTAL =
@@ -52,6 +51,11 @@ public final class GraphQlStableCapabilityManifest {
"HTTP_ARRAY_BATCH",
"ARBITRARY_JSON_INPUT_GATEWAY",
"PERSISTENCE_ENTITY_AUTO_EXPOSURE",
// Was an allowlisted Advanced capability. An allowlist that lets a repository back a
// GraphQL field is still a controller reaching a repository — the second canonical
// hard-stop in AGENTS.md — and a capability flag cannot make an architectural rule
// conditional. Resolvers reach storage through an application use case or not at all.
"SPRING_DATA_REPOSITORY_AUTO_EXPOSURE",
"REQUEST_WIDE_DB_TRANSACTION",
"DURABLE_SUBSCRIPTION_GUARANTEE",
"EXACTLY_ONCE_SUBSCRIPTION_DELIVERY",
@@ -0,0 +1,105 @@
package dev.caskeleton.adapter.inbound.graphql.runtime;
import dev.caskeleton.adapter.inbound.graphql.context.GraphQlRequestContext;
import dev.caskeleton.adapter.inbound.graphql.dataloader.GraphQlBatchContext;
import dev.caskeleton.adapter.inbound.graphql.dataloader.GraphQlBatchExecutor;
import dev.caskeleton.adapter.inbound.graphql.dataloader.GraphQlBatchTimeoutException;
import dev.caskeleton.adapter.inbound.graphql.dataloader.GraphQlDataLoaderFactory;
import dev.caskeleton.adapter.inbound.graphql.dataloader.GraphQlDataLoaderName;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.function.BiFunction;
import org.springframework.graphql.execution.BatchLoaderRegistry;
/**
* Registers the platform's batch loaders with Spring, so they are the ones a field actually uses.
*
* <p>The platform had a request registry keyed by name holding {@code Object}, and nothing
* connected it to Spring's {@link BatchLoaderRegistry} or to java-dataloader. A field resolving
* through {@code @BatchMapping} or {@code DataLoader} therefore never met the chunking, the budget
* or the scope the platform had defined — the N+1 protection existed as a set of well-tested
* objects that no request could reach.
*
* <p>The chunking, budget and scope arrive as a decorator around the adopter's loader rather than
* as something the adopter has to remember. What the adopter supplies is the downstream call; what
* this adds is everything that makes it safe to run on a shared request budget.
*/
public final class GraphQlBatchLoaderRegistrar {
private final GraphQlDataLoaderFactory factory;
private final GraphQlBlockingBridge blockingBridge;
/**
* Creates a registrar that runs loaders on the calling thread.
*
* <p>No scheduler of its own. Spring has already put the request on a thread; handing the work to
* a second pool adds a queue, a wait and a context hop, and buys nothing on a servlet stack.
*
* @param factory supplies the per-loader batch policy and executor
*/
public GraphQlBatchLoaderRegistrar(GraphQlDataLoaderFactory factory) {
this(factory, null);
}
/**
* Creates a registrar that hands blocking loads to a bounded bridge.
*
* @param factory supplies the per-loader batch policy and executor
* @param blockingBridge the bounded hand-off, for a runtime where blocking in place is unsafe
*/
public GraphQlBatchLoaderRegistrar(
GraphQlDataLoaderFactory factory, GraphQlBlockingBridge blockingBridge) {
this.factory = Objects.requireNonNull(factory, "data loader factory is required");
this.blockingBridge = blockingBridge;
}
/**
* Registers one loader under its platform name.
*
* <p>The registration is per request by construction: Spring builds a fresh {@code
* DataLoaderRegistry} for every execution, so the loader's cache never outlives the request and
* one caller's cached row can never be served to the next.
*
* @param <K> key type
* @param <V> value type
* @param registry Spring's batch loader registry
* @param loaderName the platform loader name, which must have a registered policy
* @param loadChunk the adopter's downstream call for one chunk
*/
public <K, V> void register(
BatchLoaderRegistry registry,
GraphQlDataLoaderName loaderName,
BiFunction<List<K>, GraphQlBatchContext, Map<K, V>> loadChunk) {
GraphQlBatchExecutor executor = factory.executorFor(loaderName);
registry
.<K, V>forName(loaderName.value())
.registerMappedBatchLoader(
(keys, environment) -> {
GraphQlRequestContext context =
environment.getContext() instanceof graphql.GraphQLContext graphQlContext
? graphQlContext.get(GraphQlRequestContext.CONTEXT_KEY)
: null;
if (context == null) {
// Fail closed. A loader running without the platform context has no deadline, no
// tenant and no actor, and would happily batch across whatever the caller was.
return reactor.core.publisher.Mono.error(
new GraphQlBatchTimeoutException(loaderName.value()));
}
GraphQlBatchContext batchContext = factory.batchContext(context);
reactor.core.publisher.Mono<Map<K, V>> load =
reactor.core.publisher.Mono.fromCallable(
() -> executor.load(List.copyOf(keys), batchContext, loadChunk));
// Inline unless an adopter asked for the bridge. `supplyAsync` would have used the
// common ForkJoinPool: a shared, unbounded-admission scheduler with no relationship
// to the request budget, which is the second queue this platform exists to avoid.
return blockingBridge == null
? load
: load.subscribeOn(
reactor.core.scheduler.Schedulers.fromExecutor(
blockingBridge.executorFor(context)));
});
}
}
@@ -0,0 +1,93 @@
package dev.caskeleton.adapter.inbound.graphql.runtime;
import dev.caskeleton.adapter.inbound.graphql.context.GraphQlRequestContext;
import dev.caskeleton.adapter.inbound.graphql.security.GraphQlContextPropagator;
import java.util.Objects;
import java.util.concurrent.ArrayBlockingQueue;
import java.util.concurrent.Executor;
import java.util.concurrent.RejectedExecutionException;
import java.util.concurrent.ThreadPoolExecutor;
import java.util.concurrent.TimeUnit;
/**
* An opt-in, bounded hand-off for blocking work, carrying the request context with it.
*
* <p>Bounded on both axes, because a pool is only half of it. A virtual-thread-per-task executor
* limits nothing: it accepts every task and the bound becomes whatever the downstream system will
* tolerate. A fixed pool bounds threads and then queues without limit, which converts an overload
* into unbounded memory and latency rather than into a refusal. Here both the pool and the queue
* are finite and a full bridge refuses immediately, which is the answer a caller can act on.
*
* <p>Opt-in on purpose. The platform schedules nothing by default: work runs on the thread Spring
* already gave it, so there is one scheduler and one queue rather than two of each with a wait
* between them. A bridge exists for the case an adopter genuinely has — blocking work that must
* leave an event loop — and then it is explicit, sized, and visible.
*
* <p>The context travels through {@link GraphQlContextPropagator}, so the thread local is bound on
* the bridge thread and unbound afterwards. Reading the thread local is what a blocking library
* does; the GraphQL context remains the source of truth, and this only mirrors it for the duration
* of the hand-off.
*/
public final class GraphQlBlockingBridge implements AutoCloseable {
private final ThreadPoolExecutor executor;
private GraphQlBlockingBridge(ThreadPoolExecutor executor) {
this.executor = executor;
}
/**
* Creates a bridge with a finite pool and a finite queue.
*
* @param threads how many blocking calls may run at once
* @param queueDepth how many may wait; beyond this the bridge refuses
*/
public static GraphQlBlockingBridge bounded(int threads, int queueDepth) {
if (threads < 1 || queueDepth < 1) {
throw new IllegalArgumentException("a blocking bridge needs a positive pool and queue");
}
ThreadPoolExecutor executor =
new ThreadPoolExecutor(
threads,
threads,
0,
TimeUnit.MILLISECONDS,
new ArrayBlockingQueue<>(queueDepth),
runnable -> Thread.ofVirtual().name("graphql-blocking-bridge-", 0).unstarted(runnable),
new ThreadPoolExecutor.AbortPolicy());
return new GraphQlBlockingBridge(executor);
}
/** How many blocking calls may run at once. */
public int threads() {
return executor.getMaximumPoolSize();
}
/** How many may wait before the bridge refuses. */
public int queueDepth() {
return executor.getQueue().remainingCapacity() + executor.getQueue().size();
}
/**
* The executor, with the context bound around every task.
*
* @param context the request context to bind on the bridge thread
*/
public Executor executorFor(GraphQlRequestContext context) {
Objects.requireNonNull(context, "request context is required");
return task -> {
try {
executor.execute(() -> GraphQlContextPropagator.wrap(context, task).run());
} catch (RejectedExecutionException full) {
// Refusing is the point. Growing the queue here would turn an overload into latency that
// the request deadline has to discover later, by which time the work is already queued.
throw new GraphQlBlockingBridgeFullException(threads(), queueDepth());
}
};
}
@Override
public void close() {
executor.shutdownNow();
}
}
@@ -0,0 +1,28 @@
package dev.caskeleton.adapter.inbound.graphql.runtime;
/**
* Raised when the blocking bridge has no capacity left.
*
* <p>A refusal rather than a wait. An unbounded queue turns an overload into latency that only the
* request deadline discovers, by which time the work is already queued behind everything else and
* the client has been waiting for all of it.
*
* <p>Carries the configured bounds, never a key, an actor or a tenant.
*/
public class GraphQlBlockingBridgeFullException extends RuntimeException {
private static final long serialVersionUID = 1L;
/** Stable error code clients and operators see. */
public static final String CODE = "GRAPHQL_BLOCKING_BRIDGE_FULL";
/**
* Creates the failure.
*
* @param threads configured concurrency
* @param queueDepth configured queue depth
*/
public GraphQlBlockingBridgeFullException(int threads, int queueDepth) {
super(CODE + ": threads=" + threads + ", queue=" + queueDepth);
}
}
@@ -0,0 +1,83 @@
package dev.caskeleton.adapter.inbound.graphql.runtime;
import dev.caskeleton.adapter.inbound.graphql.cost.GraphQlComplexityCalculator;
import dev.caskeleton.adapter.inbound.graphql.cost.GraphQlComplexityResult;
import dev.caskeleton.adapter.inbound.graphql.cost.GraphQlDocumentComplexityScorer;
import dev.caskeleton.adapter.inbound.graphql.cost.GraphQlDocumentShape;
import dev.caskeleton.adapter.inbound.graphql.cost.GraphQlDocumentShapeAnalyzer;
import dev.caskeleton.adapter.inbound.graphql.cost.GraphQlStructuralLimitPolicy;
import dev.caskeleton.adapter.inbound.graphql.execution.GraphQlExecutionStage;
import dev.caskeleton.adapter.inbound.graphql.execution.GraphQlRequestCancelledException;
import dev.caskeleton.adapter.inbound.graphql.policy.GraphQlClientPolicy;
import java.time.Clock;
import java.util.Objects;
/**
* Enforces the structural and cost budgets, before any resolver runs.
*
* <p>A budget checked after execution has already been spent, so this is the last stage before
* graphql-java starts fetching. It also re-checks the deadline: the request may have queued behind
* other work since the transport set it, and starting an operation whose budget is already gone
* spends downstream capacity on a response nobody will read.
*/
public final class GraphQlCostBudgetHandler implements GraphQlExecutionHandler {
private final GraphQlDocumentShapeAnalyzer analyzer;
private final GraphQlStructuralLimitPolicy structuralLimits;
private final GraphQlComplexityCalculator calculator;
private final GraphQlDocumentComplexityScorer scorer;
private final GraphQlClientPolicy policy;
private final Clock clock;
/**
* Creates the handler.
*
* @param analyzer document structure measurement
* @param structuralLimits per-profile structural ceilings
* @param calculator per-field pricing
* @param policy the client policy carrying the complexity budget
* @param clock clock used for the deadline re-check
*/
public GraphQlCostBudgetHandler(
GraphQlDocumentShapeAnalyzer analyzer,
GraphQlStructuralLimitPolicy structuralLimits,
GraphQlComplexityCalculator calculator,
GraphQlClientPolicy policy,
Clock clock) {
this.analyzer = Objects.requireNonNull(analyzer, "document analyzer is required");
this.structuralLimits = Objects.requireNonNull(structuralLimits, "structural limits required");
this.calculator = Objects.requireNonNull(calculator, "complexity calculator is required");
this.scorer = new GraphQlDocumentComplexityScorer(this.calculator);
this.policy = Objects.requireNonNull(policy, "client policy is required");
this.clock = Objects.requireNonNull(clock, "clock is required");
}
@Override
public GraphQlExecutionStage stage() {
return GraphQlExecutionStage.COST;
}
@Override
public GraphQlExecutionContext handle(GraphQlExecutionContext context) {
if (context.requestContext().deadline().expired(clock)) {
throw new GraphQlRequestCancelledException();
}
// Scoped to the operation that will actually run. Measuring every operation in the document
// charges a client for selections this request never executes, which turns a document holding
// three cheap queries into one expensive one.
GraphQlDocumentShape shape =
analyzer.analyze(context.requireDocument(), context.requireOperation());
structuralLimits.verify(shape);
GraphQlComplexityResult complexity =
scorer.score(
context.schema(),
context.requireDocument(),
context.requireOperation(),
context.request().variables());
calculator.verify(complexity, policy.maxComplexity());
return context.withCost(shape, complexity);
}
}
@@ -0,0 +1,70 @@
package dev.caskeleton.adapter.inbound.graphql.runtime;
import dev.caskeleton.adapter.inbound.graphql.error.GraphQlErrorContext;
import dev.caskeleton.adapter.inbound.graphql.error.GraphQlWireError;
import graphql.GraphQLError;
import graphql.schema.DataFetchingEnvironment;
import java.util.List;
import java.util.Objects;
import org.springframework.graphql.execution.DataFetcherExceptionResolverAdapter;
/**
* Adapts the canonical wire-error mapper onto Spring's data-fetcher exception contract.
*
* <p>An adapter and nothing more. What a client is told — code, category, retryability, masking —
* is decided by {@link GraphQlWireErrorMapper}, so a failure raised inside a resolver and the same
* failure raised before execution produce the same answer. The previous arrangement had that
* decision in two classes whose names differed by one letter's case, only one of which Spring
* actually called.
*/
public final class GraphQlDataFetcherExceptionResolver extends DataFetcherExceptionResolverAdapter {
private final GraphQlWireErrorMapper mapper;
/**
* Creates the resolver.
*
* @param mapper the canonical mapper
*/
public GraphQlDataFetcherExceptionResolver(GraphQlWireErrorMapper mapper) {
this.mapper = Objects.requireNonNull(mapper, "wire error mapper is required");
// Spring resolves a failing field, not the whole request, so the response keeps sibling data.
setThreadLocalContextAware(false);
}
@Override
protected GraphQLError resolveToSingleError(Throwable failure, DataFetchingEnvironment env) {
GraphQlWireError wireError = mapper.mapFieldFailure(failure, errorContext(env));
// A carrier still knows its operational category, which is finer than the six wire categories.
// Classifying from it keeps `NOT_FOUND` distinguishable from any other business outcome.
if (failure instanceof dev.caskeleton.shared.error.ApiErrorCarrier carrier) {
return GraphQlWireErrors.toGraphQlError(
wireError, GraphQlWireErrors.classificationOf(carrier.errorCode().category()));
}
return GraphQlWireErrors.toGraphQlError(wireError);
}
/**
* Correlation context for a failing field.
*
* <p>A real execution always supplies the environment; a unit test may not, and a null-hostile
* mapper would then be untestable in isolation.
*/
private static GraphQlErrorContext errorContext(DataFetchingEnvironment env) {
if (env == null) {
return GraphQlErrorContext.of("unknown-execution");
}
String executionId = String.valueOf(env.getExecutionId());
List<Object> path =
env.getExecutionStepInfo() == null
? List.of()
: List.copyOf(env.getExecutionStepInfo().getPath().toList());
String coordinate =
env.getExecutionStepInfo() == null
? null
: env.getExecutionStepInfo().getObjectType().getName()
+ "."
+ env.getExecutionStepInfo().getFieldDefinition().getName();
return GraphQlErrorContext.field(executionId, path, coordinate);
}
}
@@ -0,0 +1,139 @@
package dev.caskeleton.adapter.inbound.graphql.runtime;
import dev.caskeleton.adapter.inbound.graphql.api.GraphQlSchemaCoordinate;
import dev.caskeleton.adapter.inbound.graphql.cost.GraphQlDocumentShapeAnalyzer;
import dev.caskeleton.adapter.inbound.graphql.execution.GraphQlExecutionStage;
import dev.caskeleton.adapter.inbound.graphql.policy.GraphQlClientPolicy;
import dev.caskeleton.adapter.inbound.graphql.security.GraphQlAuthorizationInterceptor;
import graphql.language.Field;
import graphql.language.FragmentDefinition;
import graphql.language.FragmentSpread;
import graphql.language.InlineFragment;
import graphql.language.OperationDefinition;
import graphql.language.Selection;
import graphql.language.SelectionSet;
import java.util.ArrayDeque;
import java.util.Deque;
import java.util.LinkedHashMap;
import java.util.LinkedHashSet;
import java.util.Map;
import java.util.Objects;
import java.util.Set;
/**
* Authorizes the operation and its root coordinates, before any resolver runs.
*
* <p>Running before execution is the whole point: a mutation denied halfway through has already
* performed the side effect it was denied for. So the decision is made on the document, and a
* denial means zero data fetchers were invoked.
*
* <p>The introspection gate lives here rather than in the cost stage because it is an authorization
* question — whether this client profile may see the schema — and not a budget one.
*/
public final class GraphQlDocumentAuthorizationHandler implements GraphQlExecutionHandler {
private final GraphQlAuthorizationInterceptor authorization;
private final GraphQlDocumentShapeAnalyzer analyzer;
private final GraphQlClientPolicy policy;
/**
* Creates the handler.
*
* @param authorization coordinate authorization
* @param analyzer document analyzer used for the introspection gate
* @param policy the client policy that decides whether introspection is permitted
*/
public GraphQlDocumentAuthorizationHandler(
GraphQlAuthorizationInterceptor authorization,
GraphQlDocumentShapeAnalyzer analyzer,
GraphQlClientPolicy policy) {
this.authorization = Objects.requireNonNull(authorization, "authorization is required");
this.analyzer = Objects.requireNonNull(analyzer, "document analyzer is required");
this.policy = Objects.requireNonNull(policy, "client policy is required");
}
@Override
public GraphQlExecutionStage stage() {
return GraphQlExecutionStage.AUTHORIZATION;
}
@Override
public GraphQlExecutionContext handle(GraphQlExecutionContext context) {
OperationDefinition operation = context.requireOperation();
analyzer.verifyIntrospection(
context.requireDocument(), operation, policy.introspectionAllowed());
String rootTypeName = rootTypeName(operation);
for (String fieldName : rootFieldNames(context, operation)) {
authorization.authorize(
context.requestContext(), new GraphQlSchemaCoordinate(rootTypeName, fieldName));
}
return context;
}
private static String rootTypeName(OperationDefinition operation) {
OperationDefinition.Operation kind =
operation.getOperation() == null
? OperationDefinition.Operation.QUERY
: operation.getOperation();
return switch (kind) {
case QUERY -> "Query";
case MUTATION -> "Mutation";
case SUBSCRIPTION -> "Subscription";
};
}
/**
* Root field names the operation actually reaches, fragments included.
*
* <p>Fragments are expanded rather than skipped: a root field reached only through a named
* fragment is executed exactly like one written inline, so authorizing only the inline ones would
* make {@code query { ...Hidden }} a bypass.
*/
private static Set<String> rootFieldNames(
GraphQlExecutionContext context, OperationDefinition operation) {
Map<String, FragmentDefinition> fragments = new LinkedHashMap<>();
context
.requireDocument()
.getDefinitions()
.forEach(
definition -> {
if (definition instanceof FragmentDefinition fragment) {
fragments.put(fragment.getName(), fragment);
}
});
Set<String> names = new LinkedHashSet<>();
collect(operation.getSelectionSet(), fragments, names, new ArrayDeque<>());
return names;
}
private static void collect(
SelectionSet selectionSet,
Map<String, FragmentDefinition> fragments,
Set<String> names,
Deque<String> expansionPath) {
if (selectionSet == null) {
return;
}
for (Selection<?> selection : selectionSet.getSelections()) {
if (selection instanceof Field field) {
if (!field.getName().startsWith(GraphQlDocumentShapeAnalyzer.INTROSPECTION_FIELD_PREFIX)) {
names.add(field.getName());
}
} else if (selection instanceof InlineFragment inlineFragment) {
collect(inlineFragment.getSelectionSet(), fragments, names, expansionPath);
} else if (selection instanceof FragmentSpread spread) {
FragmentDefinition fragment = fragments.get(spread.getName());
if (fragment == null || expansionPath.contains(spread.getName())) {
continue;
}
expansionPath.push(spread.getName());
collect(fragment.getSelectionSet(), fragments, names, expansionPath);
expansionPath.pop();
}
}
}
}
@@ -0,0 +1,82 @@
package dev.caskeleton.adapter.inbound.graphql.runtime;
import dev.caskeleton.adapter.inbound.graphql.execution.GraphQlExecutionPipeline;
import dev.caskeleton.adapter.inbound.graphql.execution.GraphQlExecutionPipelineValidator;
import dev.caskeleton.adapter.inbound.graphql.execution.GraphQlExecutionStage;
import java.util.ArrayList;
import java.util.List;
/**
* The handlers the platform actually runs, in the order it runs them.
*
* <p>This is the executable pipeline; {@link GraphQlExecutionPipeline} is a view derived from it.
* The direction matters: a stage catalogue written by hand can drift from the code without anything
* failing, whereas a catalogue derived from the registered handlers cannot describe a stage that is
* not there.
*
* <p>Two stages are owned by the framework seams rather than by handlers, and the derived pipeline
* says so explicitly. {@code CONTEXT} happens in the transport interceptor, which is the only layer
* that can see the authenticated principal, and {@code EXECUTE} is graphql-java running the
* operation. Including them in the derived view is what lets the ordering validator judge the whole
* request path instead of the middle of it.
*/
public final class GraphQlExecutionChain {
private final List<GraphQlExecutionHandler> handlers;
/**
* Creates the chain and validates the order it produces.
*
* @param handlers handlers in execution order
* @throws dev.caskeleton.adapter.inbound.graphql.execution.GraphQlExecutionPipelineException when
* the resulting pipeline drops or reorders a required stage
*/
public GraphQlExecutionChain(List<GraphQlExecutionHandler> handlers) {
if (handlers == null || handlers.isEmpty()) {
throw new IllegalArgumentException("an execution chain needs at least one handler");
}
this.handlers = List.copyOf(handlers);
GraphQlExecutionPipelineValidator.validate(pipeline());
}
/** The Stable chain: select the operation, authorize it, then judge its cost. */
public static GraphQlExecutionChain stable(
GraphQlOperationSelectionHandler selection,
GraphQlDocumentAuthorizationHandler authorization,
GraphQlCostBudgetHandler cost) {
return new GraphQlExecutionChain(List.of(selection, authorization, cost));
}
/** The pipeline this chain realises, including the two framework-owned stages. */
public GraphQlExecutionPipeline pipeline() {
List<GraphQlExecutionStage> stages = new ArrayList<>();
stages.add(GraphQlExecutionStage.CONTEXT);
handlers.forEach(handler -> stages.add(handler.stage()));
stages.add(GraphQlExecutionStage.EXECUTE);
return new GraphQlExecutionPipeline(stages);
}
/** The registered handlers, in execution order. */
public List<GraphQlExecutionHandler> handlers() {
return handlers;
}
/**
* Runs every handler in order.
*
* @param start state produced by the transport
* @return the state after the last handler
* @throws RuntimeException the first rejection, unwrapped, for the caller to map
*/
public GraphQlExecutionContext run(GraphQlExecutionContext start) {
GraphQlExecutionContext current = start;
for (GraphQlExecutionHandler handler : handlers) {
current = handler.handle(current);
if (current == null) {
throw new IllegalStateException(
"handler for stage " + handler.stage() + " returned no state");
}
}
return current;
}
}
@@ -0,0 +1,94 @@
package dev.caskeleton.adapter.inbound.graphql.runtime;
import dev.caskeleton.adapter.inbound.graphql.context.GraphQlRequestContext;
import dev.caskeleton.adapter.inbound.graphql.cost.GraphQlComplexityResult;
import dev.caskeleton.adapter.inbound.graphql.cost.GraphQlDocumentShape;
import dev.caskeleton.adapter.inbound.graphql.execution.GraphQlOperationSelection;
import graphql.language.Document;
import graphql.language.OperationDefinition;
import graphql.schema.GraphQLSchema;
import java.util.Objects;
/**
* The state threaded through the executable pipeline.
*
* <p>Each stage adds a field and never removes one, so "has this stage run?" is answerable by
* looking at the value rather than by trusting the order someone registered handlers in. A stage
* that needs an earlier result asks for it and fails loudly when it is absent — the alternative is
* a chain that silently authorizes {@code null}.
*
* @param request the request as received
* @param requestContext identity, tenant, client policy and deadline, produced by the transport
* @param schema the schema the document was validated against
* @param document the parsed document
* @param operation the selected operation, produced by the parse/select stage
* @param selection what was selected and out of how many
* @param shape measured document structure, produced by the cost stage
* @param complexity the scored complexity, produced by the cost stage
*/
public record GraphQlExecutionContext(
GraphQlExecutionRequest request,
GraphQlRequestContext requestContext,
GraphQLSchema schema,
Document document,
OperationDefinition operation,
GraphQlOperationSelection selection,
GraphQlDocumentShape shape,
GraphQlComplexityResult complexity) {
public GraphQlExecutionContext {
Objects.requireNonNull(request, "request is required");
Objects.requireNonNull(requestContext, "request context is required");
}
/** The state a chain starts from, once the transport has established identity and parsed. */
public static GraphQlExecutionContext starting(
GraphQlExecutionRequest request,
GraphQlRequestContext requestContext,
GraphQLSchema schema,
Document document) {
return new GraphQlExecutionContext(
request, requestContext, schema, document, null, null, null, null);
}
/** Returns a copy carrying the selected operation. */
public GraphQlExecutionContext withSelection(
OperationDefinition selected,
GraphQlOperationSelection operationSelection,
GraphQlRequestContext refinedContext) {
return new GraphQlExecutionContext(
request, refinedContext, schema, document, selected, operationSelection, shape, complexity);
}
/** Returns a copy carrying the measured shape and score. */
public GraphQlExecutionContext withCost(
GraphQlDocumentShape measuredShape, GraphQlComplexityResult scored) {
return new GraphQlExecutionContext(
request, requestContext, schema, document, operation, selection, measuredShape, scored);
}
/**
* The selected operation.
*
* @throws IllegalStateException when the parse/select stage has not run
*/
public OperationDefinition requireOperation() {
if (operation == null) {
throw new IllegalStateException(
"no operation has been selected; PARSE_VALIDATE must run before this stage");
}
return operation;
}
/**
* The parsed document.
*
* @throws IllegalStateException when the document is absent
*/
public Document requireDocument() {
if (document == null) {
throw new IllegalStateException("no parsed document is available for this stage");
}
return document;
}
}
@@ -0,0 +1,27 @@
package dev.caskeleton.adapter.inbound.graphql.runtime;
import dev.caskeleton.adapter.inbound.graphql.execution.GraphQlExecutionStage;
/**
* One executable stage of the platform pipeline.
*
* <p>A handler takes the state produced so far and returns the state it produced, so a stage that
* needs a previous stage's output cannot run without it — the missing value is a missing field, not
* a convention. This is what separates the pipeline from a list of stage names: the names could be
* in any order and nothing would notice, while a chain that authorizes before selecting an
* operation has nothing to authorize.
*/
public interface GraphQlExecutionHandler {
/** The stage this handler implements, used to derive and validate the pipeline order. */
GraphQlExecutionStage stage();
/**
* Runs the stage.
*
* @param context state produced by the preceding stages
* @return state including whatever this stage produced
* @throws RuntimeException when the request is rejected; the caller maps it onto the wire
*/
GraphQlExecutionContext handle(GraphQlExecutionContext context);
}
@@ -0,0 +1,41 @@
package dev.caskeleton.adapter.inbound.graphql.runtime;
import dev.caskeleton.adapter.inbound.graphql.http.GraphQlJsonValues;
import java.util.Map;
/**
* The request as it entered the platform, before anything was decided about it.
*
* <p>Sizes are carried rather than recomputed. The transport is the only layer that sees the raw
* bytes, and a limit re-derived downstream from a re-serialised map measures the platform's own
* encoder rather than what the client actually sent.
*
* @param document the GraphQL document text
* @param operationName the requested operation name, or {@code null}
* @param variables the request variables, never {@code null}
* @param documentBytes size of the document as received
* @param variablesBytes size of the serialised variables as received
*/
public record GraphQlExecutionRequest(
String document,
String operationName,
Map<String, Object> variables,
long documentBytes,
long variablesBytes) {
public GraphQlExecutionRequest {
if (document == null) {
throw new IllegalArgumentException("document is required");
}
// Null-valued variables are legal GraphQL input, so this cannot be Map.copyOf.
variables = GraphQlJsonValues.immutableObject(variables);
if (documentBytes < 0 || variablesBytes < 0) {
throw new IllegalArgumentException("request sizes cannot be negative");
}
}
/** Whether the request named the operation to run. */
public boolean named() {
return operationName != null && !operationName.isBlank();
}
}

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