feat(graphql): GraphQL API 실행 플랫폼 구현 (Stable 48 + Advanced 19 Task)

설계 문서(specs/2026-08-12-graphql-api-execution-platform-design.md)와 두 실행 계획서에
선언된 create path 전량을 adapter-inbound-graphql leaf 안에 구현한다.

- 계획서 main 클래스 322개 전량, Task별 테스트 클래스 67개(Stable 48 + Advanced 19) 전량.
- 설계서의 Stable 16 + Advanced 12 "Gradle 모듈"은 modules.json 이 19개 leaf 정체성을
  소유하므로 bounded sub-package 로 매핑한다(선례: httpclient leaf). 모듈 경계는 문서가
  아니라 GraphQlStableModule/GraphQlAdvancedModule 값 선언 + GraphQlModuleBoundaryTest 의
  실제 소스 스캔으로 기계 검증한다.
- architecture/ 규칙은 리플렉션 + 단순명 매칭으로 구현한다. 인바운드 어댑터가 자신이
  금지하는 jakarta.persistence/spring-tx 에 의존해야 검사할 수 있다면 본말전도이기 때문.
- 부분 실패는 HTTP 200 + partial data, 요청 실패는 4xx. GraphQL over HTTP 초안 status 294 는
  의도적으로 미채택(초안 변경이 클라이언트를 깨뜨리므로).
- 요청 단위 DB 트랜잭션을 열지 않는다. 커서는 HMAC 서명된 버전 있는 keyset(상수 시간 비교).
- DataLoader 는 요청 스코프, 캐시 키는 actor/tenant sha256 지문으로 격리.
- Advanced capability 는 전부 기본 비활성. EXPERIMENTAL 등급은 명시 승인 없이 production
  활성화가 거부된다.
- spring-webflux 는 compileOnly(runtimeClasspath 제외) — MVC 배치가 WebFlux 런타임을
  물려받지 않도록. lockfile 이 스코프 제한을 고정.
- graphqlPerformanceTest 는 성능 태그가 0개면 실패한다. failOnNoDiscoveredTests 는 태그
  필터로 0건이 된 경우를 잡지 못해(Gradle 9.0.0 실측) 결과 검사를 추가했다. 증거 부재를
  통과로 위장하지 않기 위한 fail-closed.

검증: graphqlStableTest 404 / graphqlContractTest 9 / graphqlAdvancedTest 141 tests,
:adapter:inbound:graphql:check, verifyCleanArchitectureDependencies,
CleanArchitectureTest, verifyConfigurationPropertiesProcessor, verifyEnvKeys,
verifyPublicPathSnapshot 전부 통과.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
DongHyeonka
2026-08-14 15:41:38 +09:00
co-authored by Claude Opus 5
parent 3b5aee50e3
commit b074c1494e
448 changed files with 28244 additions and 13 deletions
+57 -7
View File
@@ -13,11 +13,30 @@ Package root: `dev.caskeleton.adapter.inbound.graphql`.
코드 주석에서 덜어낸 **설계 결정의 근거**는 [README.md](README.md) 가 모아둔다 (이 문서는 모듈
규칙 SSOT).
## 플랫폼 모듈 = sub-package (Gradle 모듈 아님)
GraphQL 플랫폼 설계서는 Stable 16개 + Advanced 12개 "모듈"을 말하지만, 이 레포의 leaf 정체성
SSOT 는 `src/config/architecture/modules.json` 이고 거기에는 **정확히 19개 leaf** 만 존재한다.
따라서 설계서의 28개 모듈은 이 leaf 안의 **bounded sub-package** 로 매핑한다(선례: httpclient
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 모듈을 가리키지 않을 것을 강제한다.
**새 플랫폼 sub-package 를 추가할 때는 반드시 해당 모듈 레코드에 정체성과 허용 edge 를 먼저
등록한다.** 등록 없이 추가된 패키지는 경계 테스트가 실패시킨다.
## Responsibility
- GraphQL 전송 인프라만: 최소 health 스키마(`skeleton.graphqls`) + `HealthGraphqlController`,
프로토콜 에러 매핑(`GraphqlExceptionResolver`). Spring for GraphQL 이 스키마와 컨트롤러를
자동 합성/바인딩하도록 얹는 얇은 계층이다.
- 그 위에 **GraphQL API 실행 플랫폼**(`...graphql` 하위 sub-package 군)이 스키마 계약·전송
프로파일·실행 정책·비용 한계·DataLoader·페이지네이션·에러/보안 경계·관측·릴리스 게이트를
소유한다. 플랫폼은 여전히 **feature-agnostic** 이며 정책·계약·검증 기계만 제공한다.
- feature-agnostic: `classpath:graphql/**` 스키마와 모든 `@Controller` `@QueryMapping`/
`@MutationMapping` 을 generic 하게 합성한다. **WorkLog 등 구체 기능을 이름으로 알지 않는다.**
- classpath opt-in: 현재 `app-bootstrap`/`sample-portfolio` production runtime 은 이 leaf 를
@@ -30,6 +49,13 @@ Package root: `dev.caskeleton.adapter.inbound.graphql`.
- `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`).
- `annotationProcessor``spring-boot-configuration-processor``GraphQlPlatformProperties`
`@ConfigurationProperties` 이므로 레포 전역 `verifyConfigurationPropertiesProcessor` 패리티
게이트가 이 선언을 요구한다.
## Forbidden
@@ -59,15 +85,26 @@ feature 는 `ApiErrorCarrier` 를 구현한 예외(자신의 `ApiErrorCode` 를
현재 sample 에 feature GraphQL schema/controller/resolver 가 있다고 가정하지 않는다. 이 leaf 는
health 스키마만 소유한다.
## 명시적 미구현 범위(P2)
## 구현된 플랫폼 범위
- feature GraphQL schema/resolver
- query depth/cost 제한
- persisted operation
- DataLoader/batching
- subscription
query depth/cost 제한(`cost/`), persisted operation(`advanced/persisted/`),
DataLoader/batching(`dataloader/`), subscription(`advanced/subscription/`, `advanced/websocket/`,
`advanced/sse/`)은 **더 이상 미구현이 아니다.** 다만 이들은 정책·계약·검증 기계이며, 실제
composition root 가 채택할 때 정책 값과 인증/인가 빈을 함께 제공해야 한다.
이 범위는 production GraphQL 표면 채택 시 별도 설계와 qualification 을 요구한다.
여전히 미구현인 것:
- feature GraphQL schema/resolver — 이 leaf 는 health 표면만 소유한다(변경 없음).
- 실부하 성능/장애 시나리오 증거 — `release/GraphQlPerformanceScenario`,
`GraphQlFaultScenario` 는 시나리오 카탈로그를 정의하고 `GraphQlReleaseGate` 는 그 증거가
없으면 릴리스를 **거부**한다. 증거 자체는 실제 부하 인프라를 요구하므로 이 leaf 밖에서
생성한다(`graphqlPerformanceTest` 레인이 그 자리를 예약해 둔다).
- 실제 datastore 통합 증거 — `testkit/GraphQlJpaIntegrationFixture` /
`GraphQlMongoIntegrationFixture` 가 계약을 정의하고 `GraphQlStorageIntegrationEvidence`
증거를 요구한다. 실 datastore 기동은 persistence leaf 의 책임 범위다.
- Advanced capability 는 전부 **기본 비활성**이다(`advanced/bootstrap/GraphQlAdvancedFeatureFlags`).
EXPERIMENTAL 등급(RSocket, incremental delivery, HTTP GET draft)은 명시적 승인 없이는
`GraphQlAdvancedModuleGuard` 가 production 활성화를 거부한다.
## Test
@@ -78,3 +115,16 @@ cd src
--tests dev.caskeleton.adapter.inbound.graphql.GraphqlHttpBoundaryQualificationTest \
--console=plain
```
플랫폼 테스트 레인(`gradle/graphql-platform-conventions.gradle` 등록). 기본 `test`
`quarantine`·`graphql-performance` 태그를 제외한다:
```bash
./gradlew :adapter:inbound:graphql:graphqlStableTest --console=plain # 404 tests
./gradlew :adapter:inbound:graphql:graphqlContractTest --console=plain # 9 tests
./gradlew :adapter:inbound:graphql:graphqlAdvancedTest --console=plain # 141 tests
./gradlew :adapter:inbound:graphql:graphqlPerformanceTest --console=plain # 실부하 인프라 필요
```
`graphqlPerformanceTest``@Tag("graphql-performance")` 가 하나도 없으면 **실패한다** — 이는
버그가 아니라 "성능 증거 없음"을 통과로 위장하지 않기 위한 fail-closed 설계다.
+93 -4
View File
@@ -85,8 +85,97 @@ composition root 는 이 leaf 를 채택할 때 인증/인가 및 CORS 정책을
`spring.graphql.graphiql.enabled=false`,
`spring.graphql.schema.introspection.enabled=false` 를 운영 설정으로 명시해야 한다.
## 아직 구현하지 않은 P2 범위
---
이 leaf 와 현재 sample 에는 feature GraphQL schema/resolver, query depth/cost 제한, persisted
operation, DataLoader/batching, subscription 이 구현되어 있지 않다. 이 항목들은 실제 GraphQL 제품
표면을 채택할 때 별도 설계·테스트와 함께 추가해야 한다.
# GraphQL API 실행 플랫폼 — 설계 결정의 근거
위 스켈레톤 머시너리 위에, GraphQL 설계 문서(Stable 48 Task / Advanced 19 Task)의 내용을
이 leaf 의 sub-package 로 구현했다. 아래는 그 과정에서 내린 **되돌리기 어려운 결정**과 근거다.
## 설계서의 "모듈"을 Gradle 모듈로 만들지 않은 이유
설계서는 Stable 16 + Advanced 12 = 28개 Gradle 모듈을 전제한다. 그러나 이 레포의 leaf 정체성
SSOT 는 `src/config/architecture/modules.json` 이고 **정확히 19개** 로 고정되어 있다. 28개를
추가하면 레지스트리·`settings.gradle` fail-closed 검증·의존 게이트가 전부 깨지고, 이는 Prime
Directive 5번(HARD-STOP)에 정면으로 저촉된다.
그래서 **모듈 = bounded sub-package** 로 매핑했다(선례: httpclient leaf 가 366개 java 파일을 같은
방식으로 담는다). 대신 "패키지는 경계가 아니다"라는 통상의 약점을 기계 검증으로 메웠다 —
`build/GraphQlStableModule`·`GraphQlAdvancedModule` 이 모듈 정체성과 허용 edge 를 값으로 선언하고,
`GraphQlModuleBoundaryTest` 가 **실제 소스 트리를 스캔**해 Stable→Advanced import, core-api 의
프레임워크 import, Stable edge 의 Advanced 참조를 실패시킨다. Gradle 이 해주던 일을 테스트가
한다.
## ArchUnit/JPA 없이 아키텍처 규칙을 강제한 방법
`architecture/` 의 규칙(리졸버 경계, 전송 타입, `@Transactional` 금지, Entity/Document 반환 금지)은
**리플렉션 + 단순명(simple name) 매칭**으로 구현했다. 인바운드 어댑터가 자신이 금지하는 대상
(`jakarta.persistence`, `spring-tx`)에 의존해야 그걸 검사할 수 있다면 본말전도이기 때문이다.
`GraphQlControllerTransactionRule` 이 애노테이션 타입이 아니라 단순명 `Transactional` 을 보는 것은
이 때문이며, 의도된 트레이드오프다. `GraphQlResolverBoundaryRules` 는 스캔 대상 패키지가 비어
있으면 **실패한다** — 검사할 게 없어서 통과하는 조용한 무력화를 막는다.
## 부분 실패는 200, 요청 실패는 4xx
`http/GraphQlHttpStatusMapper.V1` 은 검증 통과 후 발생한 필드 에러를 HTTP 200 + partial data 로
매핑한다. GraphQL over HTTP 초안의 status 294 는 **채택하지 않았다**
(`GraphQlHttpProfile.usesDraftPartialResponseStatus()``false` 로 못 박고, 초안 프로파일은
`advanced/get/GraphQlHttpDraftCompatibilityReport` 가 "의도적 미채택"으로 기록한다). 초안 상태
코드를 프로덕션 와이어 계약에 넣으면 초안이 바뀔 때 클라이언트가 깨진다.
이 때문에 `Map.copyOf` 를 응답 데이터 경로에 쓸 수 없다 — partial data 는 **정당하게 null 값을
가진다**. `GraphQlExecutionOutcome`·`GraphQlHttpResponse`·`GraphQlContractResponse` 는 null 을
허용하는 `LinkedHashMap` 복사를 쓴다.
## 요청 단위 DB 트랜잭션을 열지 않는다
GraphQL 한 요청은 여러 root field 를 담을 수 있고, 각 root 는 자기 use case 를 호출한다.
요청 전체를 하나의 트랜잭션으로 묶으면 커넥션을 요청 수명만큼 점유하고 부분 실패 의미론이
무너진다. `mutation/GraphQlMutationContractValidator.rejectRequestWideTransaction` 이 이를
계약으로 강제하고, `GraphQlControllerTransactionRule` 이 컨트롤러의 `@Transactional` 을 막는다.
## 커서는 HMAC 서명된 버전 있는 keyset
`pagination/HmacGraphQlCursorCodec` 은 offset 이 아니라 keyset payload 를 담고, 버전과 서명을
붙인다. 비교는 `MessageDigest.isEqual` 로 상수 시간이다. 클라이언트가 커서를 조작해 다른
tenant/정렬 축으로 넘어가는 것을 막기 위함이며, 키 회전은 `GraphQlCursorKeyRing` 이 담당한다.
## DataLoader 는 요청 스코프, 캐시 키는 actor·tenant 지문으로 격리
`dataloader/GraphQlDataLoaderRequestRegistry` 는 요청마다 새 인스턴스를 만든다. 전역 캐시는
tenant 간 데이터 누출 경로가 된다. `security/GraphQlBatchContext.cacheScope()` 는 raw tenant id 가
아니라 **sha256 지문**을 캐시 스코프에 쓴다(로그·메트릭에 tenant 원문이 새지 않도록).
## 관측은 저-카디널리티 강제
`observation/GraphQlMetricCardinalityPolicy` 는 operation name·필드 좌표처럼 유한 집합만 태그로
허용하고, 변수·인자·actor id 는 `GraphQlSensitiveAttributeFilter` 가 걸러낸다. GraphQL 은
카디널리티 폭발이 쉬운 전송이라 이 게이트가 없으면 메트릭 백엔드가 먼저 죽는다.
## Advanced 는 전부 기본 비활성 + 등급제
`advanced/bootstrap/GraphQlAdvancedFeatureFlags` 는 기본 전부 off 다. EXPERIMENTAL 등급
(RSocket, incremental delivery, HTTP GET draft)은 명시적 승인 플래그 없이는
`GraphQlAdvancedModuleGuard` 가 production 활성화를 거부한다. 등급 승격은
`advanced/release/GraphQlAdvancedPromotionDecision` 이 ADR 번호·승인자·미해결 증거를 요구한다 —
"조용히 켜짐"을 구조적으로 불가능하게 만든다.
## 릴리스 게이트는 증거가 없으면 거부한다
`release/GraphQlReleaseGate``advanced/release/GraphQlAdvancedReleaseGate` 는 성능·장애·보안·
호환성 증거가 없으면 **통과시키지 않는다**. Advanced 는 Stable 기준선 없이는 릴리스 자체가
불가능하다. 같은 이유로 `graphqlPerformanceTest` 레인은 성능 태그가 하나도 없으면 실패한다 —
증거 부재를 통과로 위장하지 않기 위한 fail-closed 설계다.
## 설계서의 내부 불일치 처리
Stable 16번째 모듈이 산문에서는 `graphql-testkit-security`, Task 1 파일 목록과 설계 §5 에서는
`graphql-testkit-integration` 으로 서로 다르게 적혀 있다. **파일 목록 쪽(testkit-integration)을
채택**하고, 보안 계약 표면은 `testkit/GraphQlSecurityContractSuite` 로 제공했다. 둘 다 실제로
존재하므로 어느 쪽 독법이든 표면은 비지 않는다.
## 아직 구현하지 않은 범위
feature GraphQL schema/resolver 는 여전히 이 leaf 밖이다(스켈레톤은 health 표면만 소유).
실부하 성능 증거와 실 datastore 통합 증거도 이 leaf 밖에서 생성해야 한다 — 다만 그 **부재가
릴리스를 막도록** 게이트가 이미 서 있다.
+17 -1
View File
@@ -8,9 +8,10 @@
// spring-graphql / graphql-java versions are managed by the Spring Boot BOM, so no explicit
// versions or module-scoped platform imports are needed (unlike the grpc adapter, whose io.grpc
// coordinates the BOM does not manage).
description = 'Inbound adapter: GraphQL API (Spring for GraphQL, skeleton machinery)'
description = 'Inbound adapter: GraphQL API (Spring for GraphQL, GraphQL execution platform)'
apply from: "${rootProject.projectDir}/gradle/strict-qualification-test.gradle"
apply from: "${rootProject.projectDir}/gradle/graphql-platform-conventions.gradle"
dependencies {
implementation project(':shared-contract')
@@ -18,6 +19,19 @@ dependencies {
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.
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'
// GraphQlTester (spring-graphql-test, BOM-managed) — the health test assembles the schema +
// controller through a real AnnotatedControllerConfigurer and drives it with an
// ExecutionGraphQlServiceTester.
@@ -29,6 +43,8 @@ dependencies {
testImplementation 'org.springframework.boot:spring-boot-starter-security'
}
registerGraphQlPlatformTestLanes()
registerStrictQualificationTest(
name: 'graphqlTransportQualificationTest',
sourceSet: sourceSets.test,
+2 -1
View File
@@ -117,6 +117,7 @@ org.slf4j:jul-to-slf4j:2.0.17=compileClasspath,runtimeClasspath,testCompileClass
org.slf4j:slf4j-api:2.0.17=compileClasspath,runtimeClasspath,spotbugs,spotbugsSlf4j,testCompileClasspath,testRuntimeClasspath
org.slf4j:slf4j-simple:2.0.17=checkstyle,spotbugsSlf4j
org.springframework.boot:spring-boot-autoconfigure:4.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-configuration-processor:4.0.0=annotationProcessor
org.springframework.boot:spring-boot-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-http-client:4.0.0=testCompileClasspath,testRuntimeClasspath
@@ -161,7 +162,7 @@ org.springframework:spring-core:7.0.1=compileClasspath,runtimeClasspath,testComp
org.springframework:spring-expression:7.0.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework:spring-test:7.0.1=testCompileClasspath,testRuntimeClasspath
org.springframework:spring-web:7.0.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework:spring-webflux:7.0.1=testCompileClasspath,testRuntimeClasspath
org.springframework:spring-webflux:7.0.1=compileClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework:spring-webmvc:7.0.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.xmlresolver:xmlresolver:5.3.3=checkstyle,spotbugs
org.xmlunit:xmlunit-core:2.10.4=testCompileClasspath,testRuntimeClasspath
@@ -0,0 +1,50 @@
package dev.caskeleton.adapter.inbound.graphql.advanced.admin;
import java.util.Set;
/**
* Who may change the approved-operation registry.
*
* <p>A separate authority from the request path. Application credentials are held by every running
* instance and reachable from any resolver; if one of them could register or block an operation, a
* compromised request path could rewrite what the whole platform is willing to execute.
*/
public final class GraphQlPersistedOperationAdminAuthorization {
private final Set<String> administrators;
/**
* Creates the authorization.
*
* @param administrators operator references permitted to administer the registry
*/
public GraphQlPersistedOperationAdminAuthorization(Set<String> administrators) {
this.administrators = Set.copyOf(administrators);
if (this.administrators.isEmpty()) {
throw new IllegalArgumentException("at least one registry administrator is required");
}
}
/**
* Requires the operator to be an administrator.
*
* @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.
*
* @throws GraphQlPersistedOperationAdminDeniedException when the caller came from the request
* path
*/
public void rejectApplicationCredential(boolean applicationCredential) {
if (applicationCredential) {
throw new GraphQlPersistedOperationAdminDeniedException();
}
}
}
@@ -0,0 +1,17 @@
package dev.caskeleton.adapter.inbound.graphql.advanced.admin;
/**
* Raised when a caller may not administer the approved-operation registry.
*
* <p>Carries no operator identity, so a denial cannot be used to enumerate who the administrators
* are.
*/
public class GraphQlPersistedOperationAdminDeniedException extends RuntimeException {
private static final long serialVersionUID = 1L;
/** Creates the failure. */
public GraphQlPersistedOperationAdminDeniedException() {
super("persisted operation administration is denied");
}
}
@@ -0,0 +1,132 @@
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 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.
*/
public final class GraphQlPersistedOperationAdminService {
private final GraphQlPersistedOperationRegistry registry;
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 clock clock used for audit timestamps and the quiet period
*/
public GraphQlPersistedOperationAdminService(
GraphQlPersistedOperationRegistry registry,
GraphQlPersistedOperationAdminAuthorization authorization,
GraphQlPersistedOperationRemovalGate removalGate,
Clock clock) {
this.registry = Objects.requireNonNull(registry);
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);
}
/**
* Blocks an operation immediately.
*
* <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(),
command.reason(),
before,
GraphQlPersistedOperationStatus.BLOCKED.name(),
command.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);
}
/**
* Removes an operation once usage evidence permits it.
*
* @throws GraphQlPersistedOperationRemovalRejectedException when it was used within the quiet
* period
*/
public void remove(
GraphQlPersistedOperationId operationId,
GraphQlPersistedOperationUsage usage,
String operator,
String reason,
String traceId) {
authorization.requireAdministrator(operator);
removalGate.verify(usage, clock.instant());
registry.updateStatus(operationId, GraphQlPersistedOperationStatus.BLOCKED);
audit(
operationId.value(),
operator,
reason,
"REMOVAL_APPROVED",
GraphQlPersistedOperationStatus.BLOCKED.name(),
traceId);
}
/** The audit trail, in order. */
public List<GraphQlPersistedOperationAudit> auditTrail() {
return List.copyOf(auditTrail);
}
private void audit(
String operationId,
String operator,
String reason,
String before,
String after,
String traceId) {
auditTrail.add(
new GraphQlPersistedOperationAudit(
operationId, operator, reason, before, after, clock.instant(), traceId));
}
}
@@ -0,0 +1,43 @@
package dev.caskeleton.adapter.inbound.graphql.advanced.admin;
import java.time.Instant;
/**
* One audited change to the approved-operation registry.
*
* <p>Records operator, reason, before/after state, time and trace — the questions asked after an
* incident. It never records variables or credentials, so the audit trail itself stays safe to
* retain.
*
* @param operationId the operation that changed
* @param operator who made the change
* @param reason why
* @param before previous state
* @param after new state
* @param at when
* @param traceId correlation identity
*/
public record GraphQlPersistedOperationAudit(
String operationId,
String operator,
String reason,
String before,
String after,
Instant at,
String traceId) {
public GraphQlPersistedOperationAudit {
if (operationId == null || operationId.isBlank()) {
throw new IllegalArgumentException("audited operation id is required");
}
if (operator == null || operator.isBlank()) {
throw new IllegalArgumentException("audited operator is required");
}
if (reason == null || reason.isBlank()) {
throw new IllegalArgumentException("audited reason is required");
}
if (at == null) {
throw new IllegalArgumentException("audit instant is required");
}
}
}
@@ -0,0 +1,31 @@
package dev.caskeleton.adapter.inbound.graphql.advanced.admin;
import dev.caskeleton.adapter.inbound.graphql.advanced.persisted.GraphQlPersistedOperationId;
/**
* An instruction to block one approved operation immediately.
*
* <p>The incident-response tool: it stops a single expensive or dangerous operation without a
* redeploy and without taking the endpoint down for everyone. Operator and reason are required
* because a block is a production change that someone will have to explain and eventually reverse.
*
* @param operationId the operation to block
* @param operator who is blocking it
* @param reason why
* @param traceId incident correlation identity
*/
public record GraphQlPersistedOperationBlockCommand(
GraphQlPersistedOperationId operationId, String operator, String reason, String traceId) {
public GraphQlPersistedOperationBlockCommand {
if (operationId == null) {
throw new IllegalArgumentException("operation id is required");
}
if (operator == null || operator.isBlank()) {
throw new IllegalArgumentException("blocking operator is required");
}
if (reason == null || reason.isBlank()) {
throw new IllegalArgumentException("blocking reason is required");
}
}
}
@@ -0,0 +1,45 @@
package dev.caskeleton.adapter.inbound.graphql.advanced.admin;
import java.time.Duration;
import java.time.Instant;
/**
* Refuses to remove an approved operation that is still in use (Advanced plan Task 4).
*
* <p>The quiet period exists because "no traffic right now" is not the same as "no client uses
* this": a monthly report or a mobile build in slow rollout can be silent for weeks and then send
* the operation again.
*/
public final class GraphQlPersistedOperationRemovalGate {
private final Duration quietPeriod;
/**
* Creates the gate.
*
* @param quietPeriod how long an operation must be unused before it may be removed
*/
public GraphQlPersistedOperationRemovalGate(Duration quietPeriod) {
if (quietPeriod == null || quietPeriod.isNegative()) {
throw new IllegalArgumentException("quiet period must not be negative");
}
this.quietPeriod = quietPeriod;
}
/** The configured quiet period. */
public Duration quietPeriod() {
return quietPeriod;
}
/**
* Verifies that removal is safe.
*
* @throws GraphQlPersistedOperationRemovalRejectedException when the operation was used recently
*/
public void verify(GraphQlPersistedOperationUsage usage, Instant now) {
if (usage.executions() > 0 && usage.lastUsedAt().plus(quietPeriod).isAfter(now)) {
throw new GraphQlPersistedOperationRemovalRejectedException(
"persisted operation used within quiet period");
}
}
}
@@ -0,0 +1,21 @@
package dev.caskeleton.adapter.inbound.graphql.advanced.admin;
/**
* Raised when an approved operation may not be removed yet.
*
* <p>Removing an operation a deployed client still sends breaks that client with no warning and no
* migration path, so the gate refuses until the usage evidence says otherwise.
*/
public class GraphQlPersistedOperationRemovalRejectedException extends RuntimeException {
private static final long serialVersionUID = 1L;
/**
* Creates the failure.
*
* @param reason why removal is blocked
*/
public GraphQlPersistedOperationRemovalRejectedException(String reason) {
super(reason);
}
}
@@ -0,0 +1,29 @@
package dev.caskeleton.adapter.inbound.graphql.advanced.admin;
import java.time.Instant;
/**
* Observed usage of one approved operation.
*
* <p>Counts and a last-used instant, never the callers: usage feeds a removal decision, and the
* identity of who ran an operation is not needed to decide whether it is still in use.
*
* @param lastUsedAt when it was last executed, or {@code null} when never
* @param executions executions observed in the window
*/
public record GraphQlPersistedOperationUsage(Instant lastUsedAt, long executions) {
public GraphQlPersistedOperationUsage {
if (executions < 0) {
throw new IllegalArgumentException("executions cannot be negative");
}
if (executions > 0 && lastUsedAt == null) {
throw new IllegalArgumentException("observed executions require a last-used instant");
}
}
/** Measured, and never used. */
public static GraphQlPersistedOperationUsage unused() {
return new GraphQlPersistedOperationUsage(null, 0);
}
}
@@ -0,0 +1,62 @@
package dev.caskeleton.adapter.inbound.graphql.advanced.bootstrap;
import java.util.Locale;
/**
* The Advanced and Experimental capabilities, each behind its own flag (Advanced plan Task 1).
*
* <p>Enumerated so a capability cannot activate merely by being on the classpath. Every one of
* these changes the platform's operational shape — a long-lived connection, an approved-operation
* registry, a second schema topology — and each deserves a deliberate decision.
*/
public enum GraphQlAdvancedCapability {
/** Approved operation registry and execution lookup. */
PERSISTED_OPERATION(GraphQlAdvancedCapabilityGrade.ADVANCED_STABLE),
/** {@code graphql-transport-ws} subscriptions. */
WEBSOCKET_SUBSCRIPTION(GraphQlAdvancedCapabilityGrade.ADVANCED_STABLE),
/** Server-sent event subscriptions, one connection per subscription. */
SSE_SUBSCRIPTION(GraphQlAdvancedCapabilityGrade.ADVANCED),
/** Federation subgraph schema and entity resolution. */
FEDERATION_SUBGRAPH(GraphQlAdvancedCapabilityGrade.ADVANCED),
/** 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),
/** Messaging-backed subscription replay. */
SUBSCRIPTION_REPLAY(GraphQlAdvancedCapabilityGrade.ADVANCED),
/** RSocket transport. */
RSOCKET(GraphQlAdvancedCapabilityGrade.EXPERIMENTAL),
/** GraphQL over HTTP GET, per the still-moving draft. */
HTTP_GET(GraphQlAdvancedCapabilityGrade.EXPERIMENTAL),
/** Incremental delivery. */
INCREMENTAL_DELIVERY(GraphQlAdvancedCapabilityGrade.EXPERIMENTAL);
private final GraphQlAdvancedCapabilityGrade grade;
GraphQlAdvancedCapability(GraphQlAdvancedCapabilityGrade grade) {
this.grade = grade;
}
/** The capability's support grade. */
public GraphQlAdvancedCapabilityGrade grade() {
return grade;
}
/** The property that enables it. */
public String featureFlag() {
return "backend.graphql.advanced." + name().toLowerCase(Locale.ROOT).replace('_', '-');
}
}
@@ -0,0 +1,21 @@
package dev.caskeleton.adapter.inbound.graphql.advanced.bootstrap;
/**
* Raised when a disabled or unapproved capability is used.
*
* <p>Fails loudly rather than degrading: a subscription endpoint that silently does nothing because
* its flag is off is far harder to diagnose than one that refuses to start.
*/
public class GraphQlAdvancedCapabilityDisabledException extends RuntimeException {
private static final long serialVersionUID = 1L;
/**
* Creates the failure.
*
* @param capabilityName the capability that is not enabled
*/
public GraphQlAdvancedCapabilityDisabledException(String capabilityName) {
super("GraphQL advanced capability is not enabled: " + capabilityName);
}
}
@@ -0,0 +1,31 @@
package dev.caskeleton.adapter.inbound.graphql.advanced.bootstrap;
/**
* How far a capability has been proven.
*
* <p>The grade decides what enabling it takes. An Advanced capability needs its feature flag; an
* Experimental one additionally needs an approval profile in production, because the evidence for
* it has not been collected yet and a config change alone should not put it in front of users.
*/
public enum GraphQlAdvancedCapabilityGrade {
/** Proven in production use; enabled by its feature flag. */
ADVANCED_STABLE(false),
/** Supported; enabled by its feature flag. */
ADVANCED(false),
/** Not yet proven; production activation also requires an approval profile. */
EXPERIMENTAL(true);
private final boolean productionApprovalRequired;
GraphQlAdvancedCapabilityGrade(boolean productionApprovalRequired) {
this.productionApprovalRequired = productionApprovalRequired;
}
/** Whether production activation needs approval beyond the feature flag. */
public boolean productionApprovalRequired() {
return productionApprovalRequired;
}
}
@@ -0,0 +1,50 @@
package dev.caskeleton.adapter.inbound.graphql.advanced.bootstrap;
import dev.caskeleton.adapter.inbound.graphql.build.GraphQlBuildModel;
import java.util.ArrayList;
import java.util.List;
import java.util.Set;
/**
* The Stable/Advanced dependency direction (Advanced plan Task 1).
*
* <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.
*/
public final class GraphQlAdvancedDependencyRules {
private GraphQlAdvancedDependencyRules() {}
/**
* Verifies that no Stable module declares an Advanced dependency.
*
* @throws IllegalStateException naming the offending edges
*/
public static void verifyStableDoesNotDependOnAdvanced() {
Set<String> advanced = GraphQlBuildModel.advancedModules();
List<String> violations = new ArrayList<>();
GraphQlBuildModel.stableDependencyEdges()
.forEach(
(module, dependencies) ->
dependencies.stream()
.filter(advanced::contains)
.forEach(dependency -> violations.add(module + " -> " + dependency)));
if (!violations.isEmpty()) {
throw new IllegalStateException(
"stable graphql modules must not depend on advanced capabilities: " + violations);
}
}
/** Whether a package belongs to an Advanced capability. */
public static boolean advancedPackage(String packageName) {
return packageName != null
&& packageName.startsWith(GraphQlBuildModel.PACKAGE_ROOT + ".advanced");
}
/** Every capability that must be flagged before it can run. */
public static Set<GraphQlAdvancedCapability> flaggedCapabilities() {
return Set.of(GraphQlAdvancedCapability.values());
}
}
@@ -0,0 +1,40 @@
package dev.caskeleton.adapter.inbound.graphql.advanced.bootstrap;
import java.util.Set;
/**
* Which Advanced capabilities are enabled, and whether experimental approval was granted.
*
* <p>Nothing is on by default. An Advanced capability that arrived because a dependency was added
* is exactly what the Stable/Advanced split exists to prevent.
*
* @param enabled capabilities whose feature flag is set
* @param experimentalApprovedInProduction whether Experimental capabilities may run in production
*/
public record GraphQlAdvancedFeatureFlags(
Set<GraphQlAdvancedCapability> enabled, boolean experimentalApprovedInProduction) {
public GraphQlAdvancedFeatureFlags {
enabled = Set.copyOf(enabled);
}
/** Nothing enabled. */
public static GraphQlAdvancedFeatureFlags disabled() {
return new GraphQlAdvancedFeatureFlags(Set.of(), false);
}
/** The given capabilities enabled, without experimental production approval. */
public static GraphQlAdvancedFeatureFlags enabling(GraphQlAdvancedCapability... capabilities) {
return new GraphQlAdvancedFeatureFlags(Set.of(capabilities), false);
}
/** Whether a capability's flag is set. */
public boolean isEnabled(GraphQlAdvancedCapability capability) {
return enabled.contains(capability);
}
/** Returns a copy with experimental capabilities approved for production. */
public GraphQlAdvancedFeatureFlags withExperimentalApproval() {
return new GraphQlAdvancedFeatureFlags(enabled, true);
}
}
@@ -0,0 +1,63 @@
package dev.caskeleton.adapter.inbound.graphql.advanced.bootstrap;
import java.util.Objects;
/**
* The single gate every Advanced capability passes through.
*
* <p>One place to check means a capability cannot be half-enabled: the flag governs whether it
* starts at all, and an Experimental capability additionally needs production approval, so neither
* can be reached by a code path that forgot to ask.
*/
public final class GraphQlAdvancedModuleGuard {
private final GraphQlAdvancedFeatureFlags flags;
private final boolean production;
/**
* Creates a non-production guard.
*
* @param flags the enabled capabilities
*/
public GraphQlAdvancedModuleGuard(GraphQlAdvancedFeatureFlags flags) {
this(flags, false);
}
/**
* Creates the guard.
*
* @param flags the enabled capabilities
* @param production whether production rules apply
*/
public GraphQlAdvancedModuleGuard(GraphQlAdvancedFeatureFlags flags, boolean production) {
this.flags = Objects.requireNonNull(flags);
this.production = production;
}
/**
* Requires a capability to be enabled and, in production, approved.
*
* @throws GraphQlAdvancedCapabilityDisabledException when it is not
*/
public void requireEnabled(GraphQlAdvancedCapability capability) {
if (!flags.isEnabled(capability)) {
throw new GraphQlAdvancedCapabilityDisabledException(capability.name());
}
if (production
&& capability.grade().productionApprovalRequired()
&& !flags.experimentalApprovedInProduction()) {
throw new GraphQlAdvancedCapabilityDisabledException(
capability.name() + " is experimental and requires an approval profile in production");
}
}
/** Whether a capability may run here. */
public boolean enabled(GraphQlAdvancedCapability capability) {
try {
requireEnabled(capability);
return true;
} catch (GraphQlAdvancedCapabilityDisabledException ex) {
return false;
}
}
}
@@ -0,0 +1,25 @@
package dev.caskeleton.adapter.inbound.graphql.advanced.chaining;
/**
* Whether chained DataLoader dispatch is enabled, and how deep it may chain.
*
* <p>Off by default. Chained dispatch changes when loaders fire, which changes query counts, batch
* sizes and result ordering — all things existing N+1 regression tests assert on. Enabling it is a
* deliberate change with its own regression evidence, not a free improvement.
*
* @param enabled whether chained dispatch is active
* @param maximumDepth deepest dependency chain permitted
*/
public record GraphQlChainedDataLoaderPolicy(boolean enabled, int maximumDepth) {
public GraphQlChainedDataLoaderPolicy {
if (maximumDepth < 1) {
throw new IllegalArgumentException("chained dispatch depth must be positive");
}
}
/** The Stable default: chained dispatch disabled. */
public static GraphQlChainedDataLoaderPolicy disabled() {
return new GraphQlChainedDataLoaderPolicy(false, 1);
}
}
@@ -0,0 +1,72 @@
package dev.caskeleton.adapter.inbound.graphql.advanced.chaining;
import dev.caskeleton.adapter.inbound.graphql.advanced.bootstrap.GraphQlAdvancedCapability;
import dev.caskeleton.adapter.inbound.graphql.advanced.bootstrap.GraphQlAdvancedModuleGuard;
import java.util.Objects;
/**
* Enables chained dispatch, once the flag, the graph and the depth all allow it (Advanced plan Task
* 11).
*
* <p>Every Stable DataLoader rule still applies: request scope, actor and tenant isolation, and the
* loader's maximum batch size. Chaining changes dispatch timing, nothing else.
*/
public final class GraphQlChainedDispatchConfigurer {
private final GraphQlAdvancedModuleGuard guard;
private final GraphQlDataLoaderCycleDetector cycleDetector;
/**
* Creates the configurer.
*
* @param guard the Advanced capability guard
* @param cycleDetector the dependency cycle check
*/
public GraphQlChainedDispatchConfigurer(
GraphQlAdvancedModuleGuard guard, GraphQlDataLoaderCycleDetector cycleDetector) {
this.guard = Objects.requireNonNull(guard);
this.cycleDetector = Objects.requireNonNull(cycleDetector);
}
/**
* Verifies chained dispatch may be enabled for a graph.
*
* @throws GraphQlDataLoaderDependencyCycleException when the graph has a cycle
* @throws IllegalArgumentException when the graph is deeper than the policy allows
*/
public void configure(
GraphQlChainedDataLoaderPolicy policy, GraphQlDataLoaderDependencyGraph graph) {
if (!policy.enabled()) {
return;
}
guard.requireEnabled(GraphQlAdvancedCapability.DATALOADER_CHAINING);
cycleDetector.verify(graph);
int depth = depthOf(graph);
if (depth > policy.maximumDepth()) {
throw new IllegalArgumentException(
"loader dependency depth "
+ depth
+ " exceeds the configured maximum of "
+ policy.maximumDepth());
}
}
/** The longest dependency chain in the graph. */
public int depthOf(GraphQlDataLoaderDependencyGraph graph) {
cycleDetector.verify(graph);
int deepest = 0;
for (String loader : graph.loaders()) {
deepest = Math.max(deepest, depthFrom(graph, loader));
}
return deepest;
}
private int depthFrom(GraphQlDataLoaderDependencyGraph graph, String loader) {
var dependencies = graph.edges().getOrDefault(loader, java.util.Set.of());
int deepest = 0;
for (String dependency : dependencies) {
deepest = Math.max(deepest, 1 + depthFrom(graph, dependency));
}
return deepest;
}
}
@@ -0,0 +1,52 @@
package dev.caskeleton.adapter.inbound.graphql.advanced.chaining;
import java.util.concurrent.atomic.AtomicLong;
/**
* Dispatch counters for chained loading.
*
* <p>Query count and batch size are the numbers that prove chaining helped or hurt. Without them,
* enabling chained dispatch is a change whose effect nobody can measure — which is exactly why it
* defaults to off.
*/
public final class GraphQlChainedLoaderMetrics {
private final AtomicLong dispatches = new AtomicLong();
private final AtomicLong keysLoaded = new AtomicLong();
private final AtomicLong chainedDispatches = new AtomicLong();
/**
* Records one dispatch round.
*
* @param keys keys dispatched
* @param chained whether it was triggered by another loader's completion
*/
public void recordDispatch(int keys, boolean chained) {
dispatches.incrementAndGet();
keysLoaded.addAndGet(keys);
if (chained) {
chainedDispatches.incrementAndGet();
}
}
/** Dispatch rounds. */
public long dispatches() {
return dispatches.get();
}
/** Keys loaded in total. */
public long keysLoaded() {
return keysLoaded.get();
}
/** Dispatch rounds triggered by chaining. */
public long chainedDispatches() {
return chainedDispatches.get();
}
/** Average batch size, the number a chaining regression shows up in first. */
public double averageBatchSize() {
long rounds = dispatches.get();
return rounds == 0 ? 0 : (double) keysLoaded.get() / rounds;
}
}
@@ -0,0 +1,51 @@
package dev.caskeleton.adapter.inbound.graphql.advanced.chaining;
import java.util.ArrayList;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Set;
/**
* Finds dependency cycles before chained dispatch is enabled.
*
* <p>A depth-first walk that reports the cycle it found, not just that one exists — with a dozen
* loaders, "there is a cycle somewhere" is not an actionable diagnostic.
*/
public final class GraphQlDataLoaderCycleDetector {
/**
* Verifies the graph is acyclic.
*
* @throws GraphQlDataLoaderDependencyCycleException naming the cycle
*/
public void verify(GraphQlDataLoaderDependencyGraph graph) {
Set<String> settled = new LinkedHashSet<>();
for (String loader : graph.loaders()) {
if (!settled.contains(loader)) {
walk(graph, loader, new LinkedHashSet<>(), settled);
}
}
}
private void walk(
GraphQlDataLoaderDependencyGraph graph,
String loader,
Set<String> path,
Set<String> settled) {
if (path.contains(loader)) {
List<String> cycle = new ArrayList<>(path);
cycle.add(loader);
throw new GraphQlDataLoaderDependencyCycleException(cycle);
}
if (settled.contains(loader)) {
return;
}
path.add(loader);
for (String dependency : graph.edges().getOrDefault(loader, Set.of())) {
walk(graph, dependency, path, settled);
}
path.remove(loader);
settled.add(loader);
}
}
@@ -0,0 +1,23 @@
package dev.caskeleton.adapter.inbound.graphql.advanced.chaining;
import java.util.List;
/**
* Raised when loaders depend on each other in a cycle.
*
* <p>A cycle has no dispatch order that satisfies it, so chained dispatch would deadlock or never
* dispatch — failing at startup is far cheaper than discovering it under load.
*/
public class GraphQlDataLoaderDependencyCycleException extends RuntimeException {
private static final long serialVersionUID = 1L;
/**
* Creates the failure.
*
* @param cycle the loaders on the cycle, in order
*/
public GraphQlDataLoaderDependencyCycleException(List<String> cycle) {
super("data loader dependency cycle: " + cycle);
}
}
@@ -0,0 +1,44 @@
package dev.caskeleton.adapter.inbound.graphql.advanced.chaining;
import java.util.Collections;
import java.util.LinkedHashMap;
import java.util.LinkedHashSet;
import java.util.Map;
import java.util.Set;
/**
* Declared dependencies between loaders.
*
* <p>Declared rather than inferred: chained dispatch changes when each loader fires, and the
* platform cannot discover from bytecode that one loader's keys come from another's results.
*/
public final class GraphQlDataLoaderDependencyGraph {
private final Map<String, Set<String>> edges = new LinkedHashMap<>();
/**
* Declares that one loader depends on another.
*
* @param loader the dependent loader
* @param dependency the loader it needs first
*/
public GraphQlDataLoaderDependencyGraph dependsOn(String loader, String dependency) {
if (loader == null || dependency == null) {
throw new IllegalArgumentException("loader and dependency names are required");
}
edges.computeIfAbsent(loader, ignored -> new LinkedHashSet<>()).add(dependency);
return this;
}
/** The declared edges. */
public Map<String, Set<String>> edges() {
return Collections.unmodifiableMap(edges);
}
/** Loaders that appear in the graph. */
public Set<String> loaders() {
Set<String> loaders = new LinkedHashSet<>(edges.keySet());
edges.values().forEach(loaders::addAll);
return Collections.unmodifiableSet(loaders);
}
}
@@ -0,0 +1,51 @@
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.
*
* <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.
*/
public final class GraphQlClientOperationGenerator {
private final GraphQlCodegenProfile profile;
/**
* Creates the generator.
*
* @param profile what to generate and where
*/
public GraphQlClientOperationGenerator(GraphQlCodegenProfile profile) {
this.profile = Objects.requireNonNull(profile);
}
/**
* Validates that an operation document still matches the schema.
*
* @param sdl the schema
* @param operationDocument the operation to validate
* @throws GraphQlCodegenBoundaryException when either is missing
*/
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);
}
/** The kinds this generator produces. */
public java.util.Set<String> generatedTypes() {
profile.generatedTypes().forEach(GraphQlGeneratedSourceBoundary.standard()::requireAllowed);
return profile.generatedTypes();
}
/** The package generated client sources are written to. */
public String generatedPackage() {
return profile.generatedPackage();
}
}
@@ -0,0 +1,20 @@
package dev.caskeleton.adapter.inbound.graphql.advanced.codegen;
/**
* Raised when generation is attempted for a kind that must be written by hand.
*
* <p>Fails at build time, where a generated domain type is still cheap to delete.
*/
public class GraphQlCodegenBoundaryException extends RuntimeException {
private static final long serialVersionUID = 1L;
/**
* Creates the failure.
*
* @param generatedType the kind that may not be generated
*/
public GraphQlCodegenBoundaryException(String generatedType) {
super(generatedType + " must be written by hand, not generated from the GraphQL schema");
}
}
@@ -0,0 +1,34 @@
package dev.caskeleton.adapter.inbound.graphql.advanced.codegen;
import java.util.List;
import java.util.Set;
/**
* What one generation run produces, and where.
*
* <p>Generated sources go to their own directory and package. Mixing them with hand-written code
* means the next run either overwrites something a person wrote or silently stops regenerating.
*
* @param generatedPackage package generated sources are written to
* @param generatedSourceDirectory directory they are written to
* @param generatedTypes kinds this run produces
* @param scalarMappings custom scalar mappings
*/
public record GraphQlCodegenProfile(
String generatedPackage,
String generatedSourceDirectory,
Set<String> generatedTypes,
List<GraphQlScalarMapping> scalarMappings) {
public GraphQlCodegenProfile {
if (generatedPackage == null || generatedPackage.isBlank()) {
throw new IllegalArgumentException("generated package is required");
}
if (generatedSourceDirectory == null || generatedSourceDirectory.isBlank()) {
throw new IllegalArgumentException("generated source directory is required");
}
generatedTypes = Set.copyOf(generatedTypes);
scalarMappings = List.copyOf(scalarMappings);
generatedTypes.forEach(GraphQlGeneratedSourceBoundary.standard()::requireAllowed);
}
}
@@ -0,0 +1,38 @@
package dev.caskeleton.adapter.inbound.graphql.advanced.codegen;
import dev.caskeleton.adapter.inbound.graphql.compat.GraphQlChangeKind;
import dev.caskeleton.adapter.inbound.graphql.compat.GraphQlCompatibilityReport;
import dev.caskeleton.adapter.inbound.graphql.compat.GraphQlSchemaChange;
import java.util.List;
/**
* Reports schema changes that are wire-compatible but break generated client source.
*
* <p>The case worth naming: adding an enum value or a union member is additive on the wire, and it
* breaks a generated client whose {@code switch} is exhaustive. The server sees a successful
* release; the client sees a compile error, or worse, a runtime one.
*/
public final class GraphQlGeneratedCompatibilityGate {
private GraphQlGeneratedCompatibilityGate() {}
/** Changes that need a generated-client rebuild or review, in deterministic order. */
public static List<String> generatedClientImpacts(GraphQlCompatibilityReport report) {
return report.changes().stream()
.filter(GraphQlSchemaChange::reviewRequired)
.map(GraphQlSchemaChange::describe)
.toList();
}
/** Whether a change is additive on the wire yet risky for an exhaustive generated client. */
public static boolean exhaustivenessRisk(GraphQlSchemaChange change) {
return change.kind() == GraphQlChangeKind.ENUM_VALUE_ADDED
|| change.kind() == GraphQlChangeKind.UNION_MEMBER_ADDED
|| change.kind() == GraphQlChangeKind.INTERFACE_IMPLEMENTATION_ADDED;
}
/** Whether the report can be released without regenerating clients. */
public static boolean generatedClientsUnaffected(GraphQlCompatibilityReport report) {
return generatedClientImpacts(report).isEmpty();
}
}
@@ -0,0 +1,45 @@
package dev.caskeleton.adapter.inbound.graphql.advanced.codegen;
import java.util.Set;
/**
* What code generation may and may not produce (design §21.2).
*
* <p>Client and transport types are good candidates: they mirror the schema exactly and carry no
* logic. Domain entities, use cases and repositories are not — they exist to hold behaviour the
* schema does not describe, and generating them from the schema inverts the dependency the whole
* architecture rests on.
*/
public final class GraphQlGeneratedSourceBoundary {
/** Kinds that may be generated. */
public static final Set<String> ALLOWED =
Set.of("CLIENT_REQUEST", "CLIENT_RESPONSE", "TRANSPORT_INPUT", "TRANSPORT_OUTPUT");
/** Kinds that must be written by hand. */
public static final Set<String> FORBIDDEN =
Set.of("DOMAIN_ENTITY", "APPLICATION_USE_CASE", "REPOSITORY", "PERSISTENCE_MODEL");
private GraphQlGeneratedSourceBoundary() {}
/** The standard boundary. */
public static GraphQlGeneratedSourceBoundary standard() {
return new GraphQlGeneratedSourceBoundary();
}
/** Whether a kind may be generated. */
public boolean isAllowed(String generatedType) {
return ALLOWED.contains(generatedType);
}
/**
* Requires a kind to be generatable.
*
* @throws GraphQlCodegenBoundaryException when it is not
*/
public void requireAllowed(String generatedType) {
if (!isAllowed(generatedType)) {
throw new GraphQlCodegenBoundaryException(generatedType);
}
}
}
@@ -0,0 +1,27 @@
package dev.caskeleton.adapter.inbound.graphql.advanced.codegen;
/**
* How a custom scalar maps onto a generated Java type.
*
* <p>Declared explicitly, because a generator that does not know a scalar falls back to {@code
* String} — and a {@code BigDecimal} arriving as a {@code String} in generated client code is
* precisely the precision loss the scalar was defined to prevent.
*
* @param scalarName the GraphQL scalar
* @param javaType the generated Java type
* @param codecId the codec that converts between them
*/
public record GraphQlScalarMapping(String scalarName, String javaType, String codecId) {
public GraphQlScalarMapping {
if (scalarName == null || scalarName.isBlank()) {
throw new IllegalArgumentException("scalar name is required");
}
if (javaType == null || javaType.isBlank()) {
throw new IllegalArgumentException("generated Java type is required");
}
if (codecId == null || codecId.isBlank()) {
throw new IllegalArgumentException("codec id is required");
}
}
}
@@ -0,0 +1,54 @@
package dev.caskeleton.adapter.inbound.graphql.advanced.codegen;
import java.util.Objects;
import java.util.Set;
/**
* Generates server-side transport DTOs.
*
* <p>Transport types only. A generated type that a resolver maps to and from is fine; a generated
* type the Application layer consumes directly would make every schema change a change to business
* code.
*/
public final class GraphQlTransportTypeGenerator {
private static final Set<String> TRANSPORT_KINDS = Set.of("TRANSPORT_INPUT", "TRANSPORT_OUTPUT");
private final GraphQlCodegenProfile profile;
/**
* Creates the generator.
*
* @param profile what to generate and where
*/
public GraphQlTransportTypeGenerator(GraphQlCodegenProfile profile) {
this.profile = Objects.requireNonNull(profile);
}
/**
* The transport kinds this profile generates.
*
* @throws GraphQlCodegenBoundaryException when the profile asks for a non-transport kind
*/
public Set<String> transportTypes() {
profile.generatedTypes().stream()
.filter(kind -> !TRANSPORT_KINDS.contains(kind) && !kind.startsWith("CLIENT_"))
.findFirst()
.ifPresent(
kind -> {
throw new GraphQlCodegenBoundaryException(kind);
});
return profile.generatedTypes().stream()
.filter(TRANSPORT_KINDS::contains)
.collect(java.util.stream.Collectors.toUnmodifiableSet());
}
/**
* Whether generated sources are separated from hand-written ones.
*
* @param handWrittenSourceDirectory where hand-written sources live
*/
public boolean separatedFrom(String handWrittenSourceDirectory) {
return !profile.generatedSourceDirectory().equals(handWrittenSourceDirectory);
}
}
@@ -0,0 +1,39 @@
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;
}
}
@@ -0,0 +1,40 @@
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);
}
}
}
@@ -0,0 +1,19 @@
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");
}
}
}
@@ -0,0 +1,21 @@
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);
}
}
@@ -0,0 +1,58 @@
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");
}
}
@@ -0,0 +1,35 @@
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;
}
}
@@ -0,0 +1,39 @@
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,44 @@
package dev.caskeleton.adapter.inbound.graphql.advanced.composition;
import java.util.ArrayList;
import java.util.List;
/**
* Blocks a federated release without complete evidence.
*
* <p>The gate exists because in a federated topology a mistake is not contained: an entity key
* change or an unbudgeted cross-subgraph call degrades queries the owning team never wrote.
*/
public final class GraphQlFederationCompositionGate {
/**
* Verifies a federated release.
*
* @throws GraphQlFederationReleaseRejectedException naming the missing evidence
*/
public void verify(GraphQlFederationReleaseEvidence evidence) {
List<String> missing = missing(evidence);
if (!missing.isEmpty()) {
throw new GraphQlFederationReleaseRejectedException(
"federation composition evidence incomplete: " + missing);
}
}
/** Which evidence is missing, in a deterministic order. */
public List<String> missing(GraphQlFederationReleaseEvidence evidence) {
List<String> missing = new ArrayList<>();
if (!evidence.compositionPassed()) {
missing.add("composition");
}
if (!evidence.entityContractsPassed()) {
missing.add("entityContracts");
}
if (!evidence.latencyPassed()) {
missing.add("latency");
}
if (!evidence.failureContractsPassed()) {
missing.add("failureContracts");
}
return List.copyOf(missing);
}
}
@@ -0,0 +1,38 @@
package dev.caskeleton.adapter.inbound.graphql.advanced.composition;
import java.util.List;
/**
* What composition CI reported.
*
* <p>Produced by the router project, consumed here: this repository owns a subgraph and cannot
* compose the supergraph itself, so the result is an input to the gate rather than something it
* computes.
*
* @param composed whether the supergraph composed
* @param supergraphHash hash of the composed supergraph, or {@code null} when composition failed
* @param problems composition problems, empty when it succeeded
*/
public record GraphQlFederationCompositionResult(
boolean composed, String supergraphHash, List<String> problems) {
public GraphQlFederationCompositionResult {
problems = problems == null ? List.of() : List.copyOf(problems);
if (composed && (supergraphHash == null || supergraphHash.isBlank())) {
throw new IllegalArgumentException("a successful composition must report a supergraph hash");
}
if (!composed && problems.isEmpty()) {
throw new IllegalArgumentException("a failed composition must report its problems");
}
}
/** A successful composition. */
public static GraphQlFederationCompositionResult composed(String supergraphHash) {
return new GraphQlFederationCompositionResult(true, supergraphHash, List.of());
}
/** A failed composition. */
public static GraphQlFederationCompositionResult failed(List<String> problems) {
return new GraphQlFederationCompositionResult(false, null, problems);
}
}
@@ -0,0 +1,42 @@
package dev.caskeleton.adapter.inbound.graphql.advanced.composition;
/**
* Which must deploy first, the subgraph or the router.
*
* <p>Order matters because the two are separate deployments. A subgraph that removes a field before
* the router stops asking for it breaks every query in flight; a router that starts asking for a
* field the subgraph does not have yet breaks just as loudly. Additive changes go subgraph-first,
* removals router-first.
*/
public enum GraphQlFederationDeploymentOrder {
/** Subgraph first: it adds capability the router will start using. */
SUBGRAPH_FIRST,
/** Router first: it must stop using capability the subgraph is removing. */
ROUTER_FIRST;
/**
* The required order for a change.
*
* @param removesCapability whether the change removes a field, key or type
*/
public static GraphQlFederationDeploymentOrder forChange(boolean removesCapability) {
return removesCapability ? ROUTER_FIRST : SUBGRAPH_FIRST;
}
/**
* Verifies a planned deployment order.
*
* @param removesCapability whether the change removes capability
* @param planned the planned order
* @throws GraphQlFederationReleaseRejectedException when the order would break in-flight queries
*/
public static void verify(boolean removesCapability, GraphQlFederationDeploymentOrder planned) {
GraphQlFederationDeploymentOrder required = forChange(removesCapability);
if (required != planned) {
throw new GraphQlFederationReleaseRejectedException(
"this change requires " + required + " deployment, not " + planned);
}
}
}
@@ -0,0 +1,40 @@
package dev.caskeleton.adapter.inbound.graphql.advanced.composition;
import java.time.Duration;
/**
* The latency a federated query may spend, and how many subgraph hops it may take.
*
* <p>Hops matter as much as time. A query that fans out to four subgraphs pays four network round
* trips before any data is read, and a per-entity downstream call turns that into a cross-subgraph
* N+1 — invisible in any single subgraph's own metrics.
*
* @param maximumTotal end-to-end budget for a federated query
* @param maximumSubgraphHops subgraph calls one query may make
* @param maximumPerEntityCalls per-entity downstream calls permitted
*/
public record GraphQlFederationLatencyBudget(
Duration maximumTotal, int maximumSubgraphHops, int maximumPerEntityCalls) {
public GraphQlFederationLatencyBudget {
if (maximumTotal == null || maximumTotal.isZero() || maximumTotal.isNegative()) {
throw new IllegalArgumentException("federation latency budget must be positive");
}
if (maximumSubgraphHops < 1 || maximumPerEntityCalls < 0) {
throw new IllegalArgumentException("invalid federation call budget");
}
}
/**
* Whether measured behaviour is within budget.
*
* @param observedTotal measured end-to-end latency
* @param observedHops measured subgraph hops
* @param observedPerEntityCalls measured per-entity downstream calls
*/
public boolean within(Duration observedTotal, int observedHops, int observedPerEntityCalls) {
return observedTotal.compareTo(maximumTotal) <= 0
&& observedHops <= maximumSubgraphHops
&& observedPerEntityCalls <= maximumPerEntityCalls;
}
}
@@ -0,0 +1,25 @@
package dev.caskeleton.adapter.inbound.graphql.advanced.composition;
/**
* The evidence a federated release requires (Advanced plan Task 13).
*
* <p>Composition succeeding is the weakest of the four. It proves the schemas fit together, not
* that entity keys are stable, that cross-subgraph latency is within budget, or that a partial
* subgraph failure produces a sensible response rather than a nulled supergraph.
*
* @param compositionPassed the supergraph composes
* @param entityContractsPassed entity keys and ownership verified
* @param latencyPassed cross-subgraph latency within budget
* @param failureContractsPassed partial-failure behaviour verified
*/
public record GraphQlFederationReleaseEvidence(
boolean compositionPassed,
boolean entityContractsPassed,
boolean latencyPassed,
boolean failureContractsPassed) {
/** Whether every kind of evidence is present. */
public boolean complete() {
return compositionPassed && entityContractsPassed && latencyPassed && failureContractsPassed;
}
}
@@ -0,0 +1,21 @@
package dev.caskeleton.adapter.inbound.graphql.advanced.composition;
/**
* Raised when a federated release lacks required evidence.
*
* <p>Names what is missing: a federated deployment affects services owned by other teams, so
* "blocked" has to come with the reason.
*/
public class GraphQlFederationReleaseRejectedException extends RuntimeException {
private static final long serialVersionUID = 1L;
/**
* Creates the failure.
*
* @param reason which evidence is missing
*/
public GraphQlFederationReleaseRejectedException(String reason) {
super(reason);
}
}
@@ -0,0 +1,29 @@
package dev.caskeleton.adapter.inbound.graphql.advanced.composition;
import java.util.Map;
import java.util.Set;
/**
* Which subgraphs use which of this subgraph's entity fields.
*
* <p>The precondition for changing an entity key: without it, the only way to find out who depended
* on a field is to remove it and wait for another team's incident.
*
* @param consumersByField consuming subgraph names, keyed by entity field
*/
public record GraphQlFederationUsageReport(Map<String, Set<String>> consumersByField) {
public GraphQlFederationUsageReport {
consumersByField = Map.copyOf(consumersByField);
}
/** Subgraphs that consume a field. */
public Set<String> consumersOf(String field) {
return consumersByField.getOrDefault(field, Set.of());
}
/** Whether a field may be changed without coordinating with another team. */
public boolean safeToChange(String field) {
return consumersOf(field).isEmpty();
}
}
@@ -0,0 +1,41 @@
package dev.caskeleton.adapter.inbound.graphql.advanced.composition;
import dev.caskeleton.adapter.inbound.graphql.advanced.federation.GraphQlFederationEntityKey;
import java.util.List;
/**
* What one subgraph publishes into the supergraph.
*
* <p>An owner is required. In a federated topology an entity key change breaks other teams'
* queries, and "who approves this" has to be answerable before the change is proposed, not after it
* lands.
*
* @param subgraphName the subgraph's name
* @param owner the team that owns it
* @param schemaHash hash of the subgraph's schema
* @param entityKeys entity keys it publishes
*/
public record GraphQlSubgraphContract(
String subgraphName,
String owner,
String schemaHash,
List<GraphQlFederationEntityKey> entityKeys) {
public GraphQlSubgraphContract {
if (subgraphName == null || subgraphName.isBlank()) {
throw new IllegalArgumentException("subgraph name is required");
}
if (owner == null || owner.isBlank()) {
throw new IllegalArgumentException("subgraph owner is required");
}
if (schemaHash == null || schemaHash.isBlank()) {
throw new IllegalArgumentException("subgraph schema hash is required");
}
entityKeys = List.copyOf(entityKeys);
}
/** Whether an entity key changed compared with the deployed contract. */
public boolean entityKeysChangedFrom(GraphQlSubgraphContract deployed) {
return deployed != null && !deployed.entityKeys().equals(entityKeys);
}
}
@@ -0,0 +1,47 @@
package dev.caskeleton.adapter.inbound.graphql.advanced.federation;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.Objects;
/**
* Resolves many entity representations as one batch.
*
* <p>A router sends the whole {@code _entities} array at once, so resolving them one at a time
* recreates the N+1 that DataLoader exists to prevent — except now it is one query per entity per
* subgraph per federated request.
*/
public final class GraphQlFederationBatchResolver {
private final GraphQlFederationEntityResolver resolver;
/**
* Creates the batch resolver.
*
* @param resolver the per-representation validator
*/
public GraphQlFederationBatchResolver(GraphQlFederationEntityResolver resolver) {
this.resolver = Objects.requireNonNull(resolver);
}
/**
* Validates a batch and extracts the key values, preserving order.
*
* <p>Order matters: the router matches results back to representations positionally.
*
* @throws GraphQlFederationRepresentationException when any representation is incomplete
*/
public List<Map<String, Object>> validateAndExtractKeys(
List<Map<String, Object>> representations) {
List<Map<String, Object>> keys = new ArrayList<>(representations.size());
for (Map<String, Object> representation : representations) {
resolver.validateRepresentation(representation);
Map<String, Object> keyValues = new java.util.LinkedHashMap<>();
resolver.key().fields().forEach(field -> keyValues.put(field, representation.get(field)));
keys.add(Map.copyOf(keyValues));
}
return List.copyOf(keys);
}
}
@@ -0,0 +1,45 @@
package dev.caskeleton.adapter.inbound.graphql.advanced.federation;
import java.util.List;
/**
* What this repository owns in a federated topology (design §21.1).
*
* <p>The subgraph, and nothing else. A router is a separate deployment with its own availability,
* its own scaling and its own on-call — running one is a project, not a library feature.
*/
public enum GraphQlFederationCapability {
/** This service publishes a subgraph schema. */
SUBGRAPH(true),
/** Composing subgraphs into a supergraph, owned by a separate project. */
ROUTER(false),
/** Schema stitching, not supported at all. */
SCHEMA_STITCHING(false);
private final boolean ownedHere;
GraphQlFederationCapability(boolean ownedHere) {
this.ownedHere = ownedHere;
}
/** Whether this repository implements the capability. */
public boolean ownedHere() {
return ownedHere;
}
/** The preconditions that must hold before federation is worth adopting. */
public static List<String> activationGate() {
return List.of(
"independently deployed services",
"schema ownership actually split across teams",
"composition CI",
"a router operations owner",
"distributed tracing",
"a cross-subgraph latency budget",
"an entity key lifecycle policy",
"a partial-failure policy");
}
}
@@ -0,0 +1,31 @@
package dev.caskeleton.adapter.inbound.graphql.advanced.federation;
import java.util.List;
/**
* An entity's {@code @key} fields.
*
* <p>A versioned public contract, not an implementation detail. Every other subgraph and the router
* send these fields to identify the entity, so changing one is a breaking change across services
* that deploy independently.
*
* @param typeName the entity type
* @param fields the key fields, in declaration order
*/
public record GraphQlFederationEntityKey(String typeName, List<String> fields) {
public GraphQlFederationEntityKey {
if (typeName == null || typeName.isBlank()) {
throw new IllegalArgumentException("federation entity type name is required");
}
fields = List.copyOf(fields);
if (fields.isEmpty()) {
throw new IllegalArgumentException("federation entity key cannot be empty");
}
}
/** The {@code @key(fields: "...")} directive argument this key corresponds to. */
public String directiveFields() {
return String.join(" ", fields);
}
}
@@ -0,0 +1,49 @@
package dev.caskeleton.adapter.inbound.graphql.advanced.federation;
import java.util.Map;
import java.util.Objects;
/**
* Validates entity representations before resolving them (Advanced plan Task 12).
*
* <p>Resolution itself goes through an Application query service, never a repository: an entity
* reference arriving from a router is still a request from outside, and it needs the same
* authorization and tenant scoping as one arriving over HTTP.
*/
public final class GraphQlFederationEntityResolver {
private final GraphQlFederationEntityKey key;
/**
* Creates the resolver.
*
* @param key the entity's declared key
*/
public GraphQlFederationEntityResolver(GraphQlFederationEntityKey key) {
this.key = Objects.requireNonNull(key);
}
/** The entity key this resolver serves. */
public GraphQlFederationEntityKey key() {
return key;
}
/**
* Validates one representation.
*
* @throws GraphQlFederationRepresentationException when a declared key field is missing or the
* {@code __typename} does not match
*/
public void validateRepresentation(Map<String, Object> representation) {
Object typeName = representation.get("__typename");
if (typeName != null && !key.typeName().equals(typeName)) {
throw new GraphQlFederationRepresentationException(
"representation is for a different entity type");
}
for (String field : key.fields()) {
if (!representation.containsKey(field)) {
throw new GraphQlFederationRepresentationException("missing federation entity key field");
}
}
}
}
@@ -0,0 +1,34 @@
package dev.caskeleton.adapter.inbound.graphql.advanced.federation;
import java.util.List;
/**
* Federation configuration for this subgraph.
*
* <p>Off by default, and a single executable schema remains the Stable topology: federation buys
* independent deployment at the cost of cross-subgraph N+1, network amplification, deployment
* ordering and duplicated authorization.
*
* @param enabled whether federation wiring is registered
* @param subgraphName this subgraph's name in the supergraph
* @param entityKeys the entity keys this subgraph publishes
*/
public record GraphQlFederationProperties(
boolean enabled, String subgraphName, List<GraphQlFederationEntityKey> entityKeys) {
public GraphQlFederationProperties {
entityKeys = entityKeys == null ? List.of() : List.copyOf(entityKeys);
if (enabled && (subgraphName == null || subgraphName.isBlank())) {
throw new IllegalArgumentException("an enabled subgraph requires a name");
}
if (enabled && entityKeys.isEmpty()) {
throw new IllegalArgumentException(
"an enabled subgraph must publish at least one entity key");
}
}
/** Federation disabled: the single executable schema default. */
public static GraphQlFederationProperties disabled() {
return new GraphQlFederationProperties(false, null, List.of());
}
}
@@ -0,0 +1,21 @@
package dev.caskeleton.adapter.inbound.graphql.advanced.federation;
/**
* Raised when a router sends an entity representation missing a declared key field.
*
* <p>Rejected rather than resolved partially: without the full key the subgraph would have to guess
* which entity was meant, and a guess here returns another tenant's or another user's object.
*/
public class GraphQlFederationRepresentationException extends RuntimeException {
private static final long serialVersionUID = 1L;
/**
* Creates the failure.
*
* @param reason bounded description; never the representation itself
*/
public GraphQlFederationRepresentationException(String reason) {
super(reason);
}
}
@@ -0,0 +1,54 @@
package dev.caskeleton.adapter.inbound.graphql.advanced.federation;
import dev.caskeleton.adapter.inbound.graphql.advanced.bootstrap.GraphQlAdvancedCapability;
import dev.caskeleton.adapter.inbound.graphql.advanced.bootstrap.GraphQlAdvancedModuleGuard;
import java.util.List;
import java.util.Objects;
/**
* Registers federation wiring, only when federation is actually enabled.
*
* <p>Without the flag nothing is registered at all: a schema that advertises {@code _entities} and
* {@code _service} tells a router it may send entity references, and the router will.
*/
public final class GraphQlFederationSchemaFactory {
private final GraphQlAdvancedModuleGuard guard;
private final GraphQlFederationProperties properties;
/**
* Creates the factory.
*
* @param guard the Advanced capability guard
* @param properties federation configuration
*/
public GraphQlFederationSchemaFactory(
GraphQlAdvancedModuleGuard guard, GraphQlFederationProperties properties) {
this.guard = Objects.requireNonNull(guard);
this.properties = Objects.requireNonNull(properties);
}
/** Whether federation wiring should be registered. */
public boolean federationEnabled() {
return properties.enabled() && guard.enabled(GraphQlAdvancedCapability.FEDERATION_SUBGRAPH);
}
/**
* The entity resolvers this subgraph publishes.
*
* @throws dev.caskeleton.adapter.inbound.graphql.advanced.bootstrap
* .GraphQlAdvancedCapabilityDisabledException when federation is configured but not enabled
*/
public List<GraphQlFederationEntityResolver> entityResolvers() {
if (!properties.enabled()) {
return List.of();
}
guard.requireEnabled(GraphQlAdvancedCapability.FEDERATION_SUBGRAPH);
return properties.entityKeys().stream().map(GraphQlFederationEntityResolver::new).toList();
}
/** What this repository owns; a router is not part of it. */
public GraphQlFederationCapability capability() {
return GraphQlFederationCapability.SUBGRAPH;
}
}
@@ -0,0 +1,45 @@
package dev.caskeleton.adapter.inbound.graphql.advanced.get;
import dev.caskeleton.adapter.inbound.graphql.http.GraphQlHttpProfile;
import java.util.List;
import java.util.Map;
/**
* What this deployment implements of the moving GraphQL over HTTP draft.
*
* <p>The point of tracking it separately: the draft can change without the Stable POST contract
* changing. This report is where draft drift is observed, so adopting a change stays a decision
* rather than a consequence of upgrading a library.
*
* @param draftStage the draft stage this was checked against
* @param supportedFeatures draft features implemented
* @param deliberatelyUnsupported draft features deliberately not implemented
*/
public record GraphQlHttpDraftCompatibilityReport(
String draftStage, List<String> supportedFeatures, List<String> deliberatelyUnsupported) {
public GraphQlHttpDraftCompatibilityReport {
supportedFeatures = List.copyOf(supportedFeatures);
deliberatelyUnsupported = List.copyOf(deliberatelyUnsupported);
}
/** The current position: POST is Stable, GET is experimental, and 294 is not adopted. */
public static GraphQlHttpDraftCompatibilityReport current() {
return new GraphQlHttpDraftCompatibilityReport(
"Stage 2 Draft",
List.of(
"POST", "application/graphql-response+json", "4xx request errors", "200 field errors"),
List.of(
"GET (experimental only)",
"status " + GraphQlHttpProfile.DRAFT_PARTIAL_RESPONSE_STATUS + " for partial responses",
"HTTP array batching"));
}
/** The report as a flat map, for the release evidence. */
public Map<String, Object> asMap() {
return Map.of(
"draftStage", draftStage,
"supported", supportedFeatures,
"deliberatelyUnsupported", deliberatelyUnsupported);
}
}
@@ -0,0 +1,47 @@
package dev.caskeleton.adapter.inbound.graphql.advanced.get;
/**
* Whether a GET response may be cached, and by whom.
*
* <p>The reason GET is attractive is caching, and the reason it is dangerous is the same. A GraphQL
* response depends on the actor, so a shared cache keyed by URL will serve one user's data to
* another.
*/
public enum GraphQlHttpGetCachePolicy {
/** No caching. */
NO_STORE("no-store"),
/** The requesting client may cache; no shared cache may. */
PRIVATE("private, max-age=0"),
/**
* A shared cache may store the response; only sound for responses with no actor-specific data.
*/
SHARED("public, max-age=60");
private final String cacheControl;
GraphQlHttpGetCachePolicy(String cacheControl) {
this.cacheControl = cacheControl;
}
/** The {@code Cache-Control} header this policy sets. */
public String cacheControl() {
return cacheControl;
}
/**
* The policy for a request.
*
* @param actorSpecificResponse whether the response depends on who asked
* @param profile the GET profile in force
*/
public static GraphQlHttpGetCachePolicy forRequest(
boolean actorSpecificResponse, GraphQlHttpGetProfile profile) {
if (actorSpecificResponse) {
return NO_STORE;
}
return profile.sharedCacheAllowed() ? SHARED : PRIVATE;
}
}
@@ -0,0 +1,42 @@
package dev.caskeleton.adapter.inbound.graphql.advanced.get;
/**
* CSRF protection for GET requests carrying ambient credentials.
*
* <p>Required whenever cookies are the credential. A GET with cookies is triggerable by any page
* that can make the browser fetch a URL, and the read it performs is a read of the victim's data.
*/
public final class GraphQlHttpGetCsrfPolicy {
private final boolean cookieCredentials;
private final GraphQlHttpGetProfile profile;
/**
* Creates the policy.
*
* @param cookieCredentials whether the deployment authenticates with cookies
* @param profile the GET profile in force
*/
public GraphQlHttpGetCsrfPolicy(boolean cookieCredentials, GraphQlHttpGetProfile profile) {
this.cookieCredentials = cookieCredentials;
this.profile = profile;
}
/** Whether a CSRF token must accompany the request. */
public boolean csrfTokenRequired() {
return cookieCredentials || profile.csrfRequired();
}
/**
* Verifies a request.
*
* @param csrfTokenPresent whether a valid CSRF token accompanied it
* @throws GraphQlHttpGetRejectedException when protection is required and missing
*/
public void verify(boolean csrfTokenPresent) {
if (csrfTokenRequired() && !csrfTokenPresent) {
throw new GraphQlHttpGetRejectedException(
"a CSRF token is required for cookie-authenticated GET requests");
}
}
}
@@ -0,0 +1,28 @@
package dev.caskeleton.adapter.inbound.graphql.advanced.get;
/**
* Which operations GET may carry (Advanced plan Task 17).
*
* <p>Queries only. A mutation over GET is a side effect behind a URL: prefetchers follow it, caches
* store it, and a link is enough to trigger it.
*/
public final class GraphQlHttpGetOperationPolicy {
private GraphQlHttpGetOperationPolicy() {}
/** The query-only policy. */
public static GraphQlHttpGetOperationPolicy queryOnly() {
return new GraphQlHttpGetOperationPolicy();
}
/**
* Verifies the operation type.
*
* @throws GraphQlHttpGetRejectedException for a mutation or subscription
*/
public void verify(String operationType) {
if (!"query".equals(operationType)) {
throw new GraphQlHttpGetRejectedException("GET supports query operations only");
}
}
}
@@ -0,0 +1,41 @@
package dev.caskeleton.adapter.inbound.graphql.advanced.get;
/**
* The GET draft profile's limits.
*
* <p>A URI budget, because query strings are truncated by proxies at lengths nobody controls, and a
* CSRF requirement whenever cookies are involved: a GET with ambient credentials is triggerable
* from any page.
*
* @param maximumUriBytes largest accepted request URI
* @param sharedCacheAllowed whether a shared cache may store responses
* @param csrfRequired whether CSRF protection is required
*/
public record GraphQlHttpGetProfile(
int maximumUriBytes, boolean sharedCacheAllowed, boolean csrfRequired) {
/** A conservative URI budget, below common proxy limits. */
public static final int CONSERVATIVE_URI_BYTES = 2_048;
public GraphQlHttpGetProfile {
if (maximumUriBytes < 1) {
throw new IllegalArgumentException("maximum URI size must be positive");
}
}
/** The default: conservative URI budget, no shared caching, CSRF required. */
public static GraphQlHttpGetProfile conservative() {
return new GraphQlHttpGetProfile(CONSERVATIVE_URI_BYTES, false, true);
}
/**
* Verifies a request URI fits the budget.
*
* @throws GraphQlHttpGetRejectedException when it does not
*/
public void verifyUriSize(int uriBytes) {
if (uriBytes > maximumUriBytes) {
throw new GraphQlHttpGetRejectedException("request URI exceeds the configured maximum");
}
}
}
@@ -0,0 +1,21 @@
package dev.caskeleton.adapter.inbound.graphql.advanced.get;
/**
* Raised when a GET request is outside the query-only draft profile.
*
* <p>Carries no query text: a rejected GET's document is in the URL, which is the reason GET is
* risky in the first place.
*/
public class GraphQlHttpGetRejectedException extends RuntimeException {
private static final long serialVersionUID = 1L;
/**
* Creates the failure.
*
* @param reason bounded description
*/
public GraphQlHttpGetRejectedException(String reason) {
super(reason);
}
}
@@ -0,0 +1,53 @@
package dev.caskeleton.adapter.inbound.graphql.advanced.get;
import dev.caskeleton.adapter.inbound.graphql.http.GraphQlHttpRequestEnvelope;
import java.nio.charset.StandardCharsets;
import java.util.Map;
import java.util.Objects;
/**
* Parses a GET query string into the standard request envelope.
*
* <p>{@code variables} and {@code extensions} arrive as URL-encoded JSON text, which is one more
* place a malformed value can appear — the parser rejects rather than coerces, and the resulting
* envelope is the same one the POST profile validates.
*/
public final class GraphQlHttpGetRequestParser {
private final GraphQlHttpGetProfile profile;
/**
* Creates the parser.
*
* @param profile the GET profile's limits
*/
public GraphQlHttpGetRequestParser(GraphQlHttpGetProfile profile) {
this.profile = Objects.requireNonNull(profile);
}
/**
* Parses query parameters into an envelope.
*
* @param parameters decoded query parameters
* @param uriBytes the raw request URI size
* @throws GraphQlHttpGetRejectedException when the URI is too large or the document is missing
*/
public GraphQlHttpRequestEnvelope parse(Map<String, String> parameters, int uriBytes) {
profile.verifyUriSize(uriBytes);
String query = parameters.get("query");
if (query == null || query.isBlank()) {
throw new GraphQlHttpGetRejectedException("query parameter is required");
}
verifyEncodable(query);
return new GraphQlHttpRequestEnvelope(
query, parameters.get("operationName"), Map.of(), Map.of());
}
private static void verifyEncodable(String value) {
if (value.getBytes(StandardCharsets.UTF_8).length != value.length()
&& value.chars().anyMatch(Character::isISOControl)) {
throw new GraphQlHttpGetRejectedException("query parameter contains invalid characters");
}
}
}
@@ -0,0 +1,45 @@
package dev.caskeleton.adapter.inbound.graphql.advanced.incremental;
import dev.caskeleton.adapter.inbound.graphql.execution.GraphQlCancellation;
import java.util.Objects;
/**
* Stops outstanding patches when the client goes away.
*
* <p>A client that disconnects after the initial result leaves deferred work in flight. Without
* cancellation those fragments keep resolving — doing database and downstream work for a response
* that can no longer be delivered.
*/
public final class GraphQlIncrementalCancellation {
private final GraphQlCancellation cancellation;
/**
* Creates the cancellation bridge.
*
* @param cancellation the request's cancellation signal
*/
public GraphQlIncrementalCancellation(GraphQlCancellation cancellation) {
this.cancellation = Objects.requireNonNull(cancellation);
}
/** Registers work producing a deferred patch. */
public void onCancel(Runnable stopPatch) {
cancellation.onCancel(stopPatch);
}
/** Cancels every outstanding patch. */
public void cancel() {
cancellation.cancel();
}
/**
* Whether a further patch may still be produced.
*
* <p>Checked before each patch, so a disconnect stops the work at the next boundary rather than
* at the end.
*/
public boolean mayContinue() {
return !cancellation.cancelled();
}
}
@@ -0,0 +1,35 @@
package dev.caskeleton.adapter.inbound.graphql.advanced.incremental;
/**
* Refuses incremental delivery on a runtime that cannot serve it (Advanced plan Task 18).
*
* <p>Never falls back to a complete response. A silent fallback means the feature appears to work
* in every environment where it is not actually enabled, and fails only where someone relied on it.
*/
public final class GraphQlIncrementalCompatibilityGate {
/**
* Verifies the runtime can serve incremental delivery.
*
* @throws GraphQlIncrementalDeliveryRejectedException when either half is missing
*/
public void verify(GraphQlIncrementalDeliveryCapability capability) {
if (!capability.engineSupported() || !capability.transportSupported()) {
throw new GraphQlIncrementalDeliveryRejectedException(
"incremental delivery runtime unsupported");
}
}
/**
* Verifies the client negotiated incremental delivery.
*
* @param clientNegotiated whether the client asked for it
* @throws GraphQlIncrementalDeliveryRejectedException when it did not
*/
public void verifyClientNegotiation(boolean clientNegotiated) {
if (!clientNegotiated) {
throw new GraphQlIncrementalDeliveryRejectedException(
"incremental responses require explicit client capability negotiation");
}
}
}
@@ -0,0 +1,19 @@
package dev.caskeleton.adapter.inbound.graphql.advanced.incremental;
/**
* Whether the engine and the transport can both do incremental delivery.
*
* <p>Both are required and they move independently: an engine that supports {@code @defer} over a
* transport that cannot stream produces a response the client cannot consume.
*
* @param engineSupported whether the GraphQL engine supports it
* @param transportSupported whether the transport can stream the patches
*/
public record GraphQlIncrementalDeliveryCapability(
boolean engineSupported, boolean transportSupported) {
/** Whether both halves are present. */
public boolean available() {
return engineSupported && transportSupported;
}
}
@@ -0,0 +1,32 @@
package dev.caskeleton.adapter.inbound.graphql.advanced.incremental;
/**
* The version-gated incremental delivery profile.
*
* <p>Version-gated because {@code @defer} and {@code @stream} are not in the September 2025 Stable
* contract and their engine and transport support are still settling. Pinning the profile is what
* keeps a library upgrade from silently changing what clients receive.
*
* @param profileVersion the profile this deployment implements
* @param deferSupported whether {@code @defer} is served
* @param streamSupported whether {@code @stream} is served
*/
public record GraphQlIncrementalDeliveryProfile(
String profileVersion, boolean deferSupported, boolean streamSupported) {
public GraphQlIncrementalDeliveryProfile {
if (profileVersion == null || profileVersion.isBlank()) {
throw new IllegalArgumentException("incremental delivery profile version is required");
}
}
/** Disabled, which is the Stable position. */
public static GraphQlIncrementalDeliveryProfile disabled() {
return new GraphQlIncrementalDeliveryProfile("experimental-v0", false, false);
}
/** Whether anything incremental is served at all. */
public boolean enabled() {
return deferSupported || streamSupported;
}
}
@@ -0,0 +1,22 @@
package dev.caskeleton.adapter.inbound.graphql.advanced.incremental;
/**
* Raised when incremental delivery cannot be served.
*
* <p>A configuration error rather than a silent fallback: a client that asked for {@code @defer}
* and received a complete response has been served correctly by accident, and will not notice until
* the response it depends on being incremental is not.
*/
public class GraphQlIncrementalDeliveryRejectedException extends RuntimeException {
private static final long serialVersionUID = 1L;
/**
* Creates the failure.
*
* @param reason bounded description
*/
public GraphQlIncrementalDeliveryRejectedException(String reason) {
super(reason);
}
}
@@ -0,0 +1,32 @@
package dev.caskeleton.adapter.inbound.graphql.advanced.incremental;
import java.util.List;
import java.util.Map;
/**
* One patch following an initial incremental result.
*
* <p>Carries its own path and errors. A deferred fragment can fail on its own, and its error
* belongs to the patch's path — attributing it to the initial result would tell the client the
* wrong field failed.
*
* @param path where in the response this patch applies
* @param data the patch's data, possibly partial
* @param errors errors raised while producing it
* @param hasNext whether more patches follow
*/
public record GraphQlIncrementalPatch(
List<Object> path,
Map<String, Object> data,
List<Map<String, Object>> errors,
boolean hasNext) {
public GraphQlIncrementalPatch {
path = path == null ? List.of() : List.copyOf(path);
data =
data == null
? Map.of()
: java.util.Collections.unmodifiableMap(new java.util.LinkedHashMap<>(data));
errors = errors == null ? List.of() : List.copyOf(errors);
}
}
@@ -0,0 +1,42 @@
package dev.caskeleton.adapter.inbound.graphql.advanced.incremental;
import dev.caskeleton.adapter.inbound.graphql.cost.GraphQlRuntimeBudgetTracker;
import java.util.Objects;
/**
* Keeps incremental responses inside the Stable budgets.
*
* <p>Patches are part of the same response, so they consume the same node and byte budget. Counting
* only the initial result would make a deferred fragment a way to send an unbounded response one
* patch at a time.
*/
public final class GraphQlIncrementalTransportPolicy {
private final GraphQlRuntimeBudgetTracker budgetTracker;
/**
* Creates the policy.
*
* @param budgetTracker the request's runtime budget
*/
public GraphQlIncrementalTransportPolicy(GraphQlRuntimeBudgetTracker budgetTracker) {
this.budgetTracker = Objects.requireNonNull(budgetTracker);
}
/**
* Records a patch against the request's budget.
*
* @throws dev.caskeleton.adapter.inbound.graphql.cost.GraphQlRuntimeBudgetExceededException when
* the response budget is exhausted
*/
public void recordPatch(GraphQlIncrementalPatch patch) {
budgetTracker.recordNode();
budgetTracker.recordBytes(
patch.data().toString().getBytes(java.nio.charset.StandardCharsets.UTF_8).length);
}
/** Marks the response committed; from here an overrun terminates the connection. */
public void markInitialResultSent() {
budgetTracker.markResponseCommitted();
}
}
@@ -0,0 +1,100 @@
package dev.caskeleton.adapter.inbound.graphql.advanced.persisted;
import java.util.Set;
/**
* One approved operation (design §19).
*
* <p>Carries the schema hash it was approved against, so an operation cannot survive a schema
* change that invalidated it, and its own complexity and variable limits, so an
* approved-but-expensive operation stays bounded even for a client whose general profile is
* generous.
*
* <p>Deliberately holds no variables: variables are per-request data, and the registry is
* long-lived storage.
*
* @param id operation identity
* @param operationName the operation's name, which the request must match
* @param documentHash SHA-256 of the canonical document
* @param canonicalDocument the approved document text
* @param schemaContractHash schema the operation was approved against
* @param allowedClientProfiles client profiles that may run it
* @param maximumComplexity complexity ceiling for this operation
* @param maximumVariablesBytes variable size ceiling for this operation
* @param status lifecycle state
*/
public record GraphQlPersistedOperation(
GraphQlPersistedOperationId id,
String operationName,
String documentHash,
String canonicalDocument,
String schemaContractHash,
Set<String> allowedClientProfiles,
long maximumComplexity,
int maximumVariablesBytes,
GraphQlPersistedOperationStatus status) {
public GraphQlPersistedOperation {
if (id == null || status == null) {
throw new IllegalArgumentException("persisted operation id and status are required");
}
if (operationName == null || operationName.isBlank()) {
throw new IllegalArgumentException("persisted operation name is required");
}
if (documentHash == null || documentHash.isBlank()) {
throw new IllegalArgumentException("persisted operation document hash is required");
}
if (canonicalDocument == null || canonicalDocument.isBlank()) {
throw new IllegalArgumentException("persisted operation document is required");
}
if (schemaContractHash == null || schemaContractHash.isBlank()) {
throw new IllegalArgumentException("persisted operation schema hash is required");
}
allowedClientProfiles = Set.copyOf(allowedClientProfiles);
if (allowedClientProfiles.isEmpty()) {
throw new IllegalArgumentException(
"a persisted operation must allow at least one client profile");
}
if (maximumComplexity < 1 || maximumVariablesBytes < 1) {
throw new IllegalArgumentException("persisted operation limits must be positive");
}
}
/** Registers an active operation with first-party defaults. */
public static GraphQlPersistedOperation active(
String id,
String operationName,
String documentHash,
String canonicalDocument,
String schemaHash) {
return new GraphQlPersistedOperation(
new GraphQlPersistedOperationId(id),
operationName,
documentHash,
canonicalDocument,
schemaHash,
Set.of("FIRST_PARTY"),
10_000,
65_536,
GraphQlPersistedOperationStatus.ACTIVE);
}
/** Returns a copy in a different lifecycle state. */
public GraphQlPersistedOperation withStatus(GraphQlPersistedOperationStatus newStatus) {
return new GraphQlPersistedOperation(
id,
operationName,
documentHash,
canonicalDocument,
schemaContractHash,
allowedClientProfiles,
maximumComplexity,
maximumVariablesBytes,
newStatus);
}
/** Whether a client profile may run this operation. */
public boolean allows(String clientProfileName) {
return allowedClientProfiles.contains(clientProfileName);
}
}
@@ -0,0 +1,23 @@
package dev.caskeleton.adapter.inbound.graphql.advanced.persisted;
/**
* Raised when one operation id is registered with a different document.
*
* <p>Rejected rather than overwritten: clients already deployed are sending that id expecting the
* document that was approved with it, and replacing it would change what their requests execute
* without any client changing.
*/
public class GraphQlPersistedOperationConflictException extends RuntimeException {
private static final long serialVersionUID = 1L;
/**
* Creates the failure.
*
* @param operationId the id that was registered twice
*/
public GraphQlPersistedOperationConflictException(String operationId) {
super(
"persisted operation " + operationId + " is already registered with a different document");
}
}
@@ -0,0 +1,23 @@
package dev.caskeleton.adapter.inbound.graphql.advanced.persisted;
import java.util.regex.Pattern;
/**
* The identity a client sends instead of a document.
*
* <p>Bounded, because it becomes a registry key and a metric label. Versioned by convention —
* {@code get-order-v2} rather than mutating {@code get-order} — so a client on the old build keeps
* running while a new one ships.
*
* @param value operation id matching {@code [a-z][a-z0-9.-]{2,127}}
*/
public record GraphQlPersistedOperationId(String value) {
private static final Pattern PATTERN = Pattern.compile("[a-z][a-z0-9.-]{2,127}");
public GraphQlPersistedOperationId {
if (value == null || !PATTERN.matcher(value).matches()) {
throw new IllegalArgumentException("invalid persisted operation id");
}
}
}
@@ -0,0 +1,66 @@
package dev.caskeleton.adapter.inbound.graphql.advanced.persisted;
import dev.caskeleton.adapter.inbound.graphql.advanced.bootstrap.GraphQlAdvancedCapability;
import dev.caskeleton.adapter.inbound.graphql.advanced.bootstrap.GraphQlAdvancedModuleGuard;
import dev.caskeleton.adapter.inbound.graphql.policy.GraphQlClientPolicy;
import java.util.Objects;
/**
* Runs the persisted-operation stage, before parsing (Advanced plan Task 3).
*
* <p>Before parsing because with an operation id the registry <em>is</em> the document source.
* Every check happens here in one place, and none of them is authorization: resolving an approved
* operation says the document is allowed to exist, not that this actor may run it.
*/
public final class GraphQlPersistedOperationInterceptor {
private final GraphQlAdvancedModuleGuard guard;
private final GraphQlPersistedOperationLookup lookup;
/**
* Creates the interceptor.
*
* @param guard the Advanced capability guard
* @param lookup the registry lookup
*/
public GraphQlPersistedOperationInterceptor(
GraphQlAdvancedModuleGuard guard, GraphQlPersistedOperationLookup lookup) {
this.guard = Objects.requireNonNull(guard);
this.lookup = Objects.requireNonNull(lookup);
}
/**
* Resolves and validates a persisted operation request.
*
* @param request what the client sent
* @param clientProfileName the caller's client profile
* @param deployedSchemaHash the deployed schema hash
* @param clientPolicy the caller's client policy
* @return the approved operation, ready to execute
* @throws GraphQlPersistedOperationRejectedException on any failed check
*/
public GraphQlPersistedOperation resolve(
GraphQlPersistedOperationRequest request,
String clientProfileName,
String deployedSchemaHash,
GraphQlClientPolicy clientPolicy) {
guard.requireEnabled(GraphQlAdvancedCapability.PERSISTED_OPERATION);
GraphQlPersistedOperation operation = lookup.require(request.operationId());
GraphQlPersistedOperationPolicy.requireActive(operation);
GraphQlPersistedOperationPolicy.requireClientAllowed(operation, clientProfileName);
GraphQlPersistedOperationPolicy.requireSchemaMatch(operation, deployedSchemaHash);
GraphQlPersistedOperationPolicy.requireDocumentMatch(operation, request.suppliedDocumentHash());
GraphQlPersistedOperationPolicy.requireVariablesWithinLimit(
operation, request, clientPolicy.maxVariablesBytes());
return operation;
}
/** The complexity ceiling for an operation: the stricter of its own and the client's. */
public long effectiveComplexityLimit(
GraphQlPersistedOperation operation, GraphQlClientPolicy clientPolicy) {
return GraphQlPersistedOperationPolicy.effectiveLimit(
operation.maximumComplexity(), clientPolicy.maxComplexity());
}
}
@@ -0,0 +1,37 @@
package dev.caskeleton.adapter.inbound.graphql.advanced.persisted;
import java.util.Objects;
/**
* Resolves an operation id to its approved document.
*
* <p>An unknown id is rejected with the same message as a blocked one, so the response does not
* tell a caller which ids exist.
*/
public final class GraphQlPersistedOperationLookup {
private final GraphQlPersistedOperationRegistry registry;
/**
* Creates the lookup.
*
* @param registry the approved operation store
*/
public GraphQlPersistedOperationLookup(GraphQlPersistedOperationRegistry registry) {
this.registry = Objects.requireNonNull(registry);
}
/**
* Resolves an operation.
*
* @throws GraphQlPersistedOperationRejectedException when it is unknown
*/
public GraphQlPersistedOperation require(GraphQlPersistedOperationId id) {
return registry
.find(id)
.orElseThrow(
() ->
new GraphQlPersistedOperationRejectedException(
"persisted operation is not available"));
}
}
@@ -0,0 +1,90 @@
package dev.caskeleton.adapter.inbound.graphql.advanced.persisted;
/**
* The checks an approved operation must still pass at request time (Advanced plan Task 3).
*
* <p>Being in the registry is not permission to run. The operation must still be active, belong to
* this client, match the deployed schema, and stay inside the stricter of its own and the client's
* limits — and none of that is authorization, which happens afterwards against the actor.
*/
public final class GraphQlPersistedOperationPolicy {
private GraphQlPersistedOperationPolicy() {}
/**
* Requires the operation to be executable.
*
* @throws GraphQlPersistedOperationRejectedException when it is blocked
*/
public static GraphQlPersistedOperation requireActive(GraphQlPersistedOperation operation) {
if (operation.status() != GraphQlPersistedOperationStatus.ACTIVE) {
throw new GraphQlPersistedOperationRejectedException("persisted operation is not active");
}
return operation;
}
/**
* Requires the client profile to be allowlisted for this operation.
*
* @throws GraphQlPersistedOperationRejectedException when it is not
*/
public static void requireClientAllowed(
GraphQlPersistedOperation operation, String clientProfileName) {
if (!operation.allows(clientProfileName)) {
throw new GraphQlPersistedOperationRejectedException(
"persisted operation is not allowed for this client profile");
}
}
/**
* Requires the operation to match the deployed schema.
*
* @throws GraphQlPersistedOperationRejectedException when the schema has moved on
*/
public static void requireSchemaMatch(
GraphQlPersistedOperation operation, String deployedSchemaHash) {
if (!operation.schemaContractHash().equals(deployedSchemaHash)) {
throw new GraphQlPersistedOperationRejectedException(
"persisted operation was approved against a different schema");
}
}
/**
* Requires a supplied document to match the approved one.
*
* @throws GraphQlPersistedOperationRejectedException when the hashes differ
*/
public static void requireDocumentMatch(
GraphQlPersistedOperation operation, String suppliedDocumentHash) {
if (suppliedDocumentHash != null && !operation.documentHash().equals(suppliedDocumentHash)) {
throw new GraphQlPersistedOperationRejectedException(
"the supplied document does not match the persisted operation");
}
}
/**
* The effective limit: the stricter of the operation's and the client's.
*
* @param operationLimit the operation's own ceiling
* @param clientLimit the client policy's ceiling
*/
public static long effectiveLimit(long operationLimit, long clientLimit) {
return Math.min(operationLimit, clientLimit);
}
/**
* Requires the request's variables to fit inside the effective limit.
*
* @throws GraphQlPersistedOperationRejectedException when they do not
*/
public static void requireVariablesWithinLimit(
GraphQlPersistedOperation operation,
GraphQlPersistedOperationRequest request,
int clientMaximumVariablesBytes) {
long limit = effectiveLimit(operation.maximumVariablesBytes(), clientMaximumVariablesBytes);
if (request.variablesBytes() > limit) {
throw new GraphQlPersistedOperationRejectedException(
"persisted operation variables too large");
}
}
}
@@ -0,0 +1,31 @@
package dev.caskeleton.adapter.inbound.graphql.advanced.persisted;
import java.util.Optional;
/**
* Storage for approved operations.
*
* <p>An SPI rather than a fixed implementation: a registry needs to survive restarts and stay
* consistent across instances, and which store provides that is a deployment decision the platform
* should not make.
*/
public interface GraphQlPersistedOperationRegistry {
/**
* Registers an operation.
*
* @throws GraphQlPersistedOperationConflictException when the id already holds a different
* document
*/
void register(GraphQlPersistedOperation operation);
/** Looks up an operation. */
Optional<GraphQlPersistedOperation> find(GraphQlPersistedOperationId id);
/**
* Replaces an operation's lifecycle state.
*
* <p>Used by the admin plane to block an operation during an incident.
*/
void updateStatus(GraphQlPersistedOperationId id, GraphQlPersistedOperationStatus status);
}
@@ -0,0 +1,30 @@
package dev.caskeleton.adapter.inbound.graphql.advanced.persisted;
/**
* Raised when a persisted operation may not execute.
*
* <p>One exception for every reason — blocked, unknown, wrong client, stale schema, oversized
* variables — with a bounded message, so a caller cannot use the rejection reason to probe the
* registry.
*/
public class GraphQlPersistedOperationRejectedException extends RuntimeException {
private static final long serialVersionUID = 1L;
/** Stable request-error code. */
public static final String CODE = "PERSISTED_OPERATION_REJECTED";
/**
* Creates the failure.
*
* @param reason bounded reason, never containing the document or variables
*/
public GraphQlPersistedOperationRejectedException(String reason) {
super(reason);
}
/** The stable request-error code. */
public String code() {
return CODE;
}
}
@@ -0,0 +1,25 @@
package dev.caskeleton.adapter.inbound.graphql.advanced.persisted;
/**
* What a client sent when invoking a persisted operation.
*
* <p>{@code suppliedDocumentHash} exists because some clients send the id <em>and</em> the
* document. When they do, the two must agree — otherwise a caller could quote an approved id while
* executing a document of their own.
*
* @param operationId the operation identity
* @param suppliedDocumentHash hash of the document the client also sent, or {@code null}
* @param variablesBytes serialized variables size
*/
public record GraphQlPersistedOperationRequest(
GraphQlPersistedOperationId operationId, String suppliedDocumentHash, int variablesBytes) {
public GraphQlPersistedOperationRequest {
if (operationId == null) {
throw new IllegalArgumentException("persisted operation id is required");
}
if (variablesBytes < 0) {
throw new IllegalArgumentException("variables size cannot be negative");
}
}
}
@@ -0,0 +1,31 @@
package dev.caskeleton.adapter.inbound.graphql.advanced.persisted;
/**
* An approved operation's lifecycle state.
*
* <p>{@link #BLOCKED} is the reason this is a state rather than a boolean: during an incident, one
* expensive operation has to be stoppable immediately, without a redeploy and without disabling the
* endpoint for everyone else.
*/
public enum GraphQlPersistedOperationStatus {
/** Executable. */
ACTIVE(true),
/** Still executable, but scheduled for removal. */
DEPRECATED(true),
/** Refused, effective immediately. */
BLOCKED(false);
private final boolean executable;
GraphQlPersistedOperationStatus(boolean executable) {
this.executable = executable;
}
/** Whether an operation in this state may run. */
public boolean executable() {
return executable;
}
}
@@ -0,0 +1,44 @@
package dev.caskeleton.adapter.inbound.graphql.advanced.persisted;
import dev.caskeleton.adapter.inbound.graphql.execution.GraphQlPreparsedCacheKey;
/**
* Bridges an approved operation onto the preparsed document cache.
*
* <p>The two are different things and must not be merged. The registry decides <em>whether</em> a
* document may run; the cache only avoids re-parsing one that already may. Blocking an operation
* therefore has to take effect immediately, even though its parsed form is still cached — which is
* why the block is checked on every request rather than at cache-fill time.
*/
public final class GraphQlPersistedPreparsedBridge {
private GraphQlPersistedPreparsedBridge() {}
/**
* The cache key for an approved operation.
*
* @param operation the approved operation
* @param validationPolicyVersion version of the validation rules in force
* @param clientSchemaProfile client profile whose schema view applies
*/
public static GraphQlPreparsedCacheKey cacheKey(
GraphQlPersistedOperation operation,
String validationPolicyVersion,
String clientSchemaProfile) {
return new GraphQlPreparsedCacheKey(
operation.documentHash(),
operation.schemaContractHash(),
validationPolicyVersion,
clientSchemaProfile);
}
/**
* Whether a cached parse may be reused for this operation.
*
* <p>A blocked operation may never run, even though its parsed document is still cached — the
* cache is a parsing optimisation, never an execution permission.
*/
public static boolean executable(GraphQlPersistedOperation operation) {
return operation.status().executable();
}
}
@@ -0,0 +1,42 @@
package dev.caskeleton.adapter.inbound.graphql.advanced.persisted;
import java.util.Map;
import java.util.Optional;
import java.util.concurrent.ConcurrentHashMap;
/**
* An in-memory registry, for tests and single-instance development.
*
* <p>Not a production implementation: it is neither durable nor shared, so a block applied during
* an incident would survive neither a restart nor the other instances. The Advanced release gate
* requires durable registry evidence for exactly this reason.
*/
public final class InMemoryGraphQlPersistedOperationRegistry
implements GraphQlPersistedOperationRegistry {
private final Map<GraphQlPersistedOperationId, GraphQlPersistedOperation> operations =
new ConcurrentHashMap<>();
@Override
public void register(GraphQlPersistedOperation operation) {
GraphQlPersistedOperation existing = operations.putIfAbsent(operation.id(), operation);
if (existing != null && !existing.documentHash().equals(operation.documentHash())) {
throw new GraphQlPersistedOperationConflictException(operation.id().value());
}
}
@Override
public Optional<GraphQlPersistedOperation> find(GraphQlPersistedOperationId id) {
return Optional.ofNullable(operations.get(id));
}
@Override
public void updateStatus(GraphQlPersistedOperationId id, GraphQlPersistedOperationStatus status) {
operations.computeIfPresent(id, (key, operation) -> operation.withStatus(status));
}
/** How many operations are registered. */
public int size() {
return operations.size();
}
}
@@ -0,0 +1,45 @@
package dev.caskeleton.adapter.inbound.graphql.advanced.release;
import dev.caskeleton.adapter.inbound.graphql.advanced.bootstrap.GraphQlAdvancedCapability;
import dev.caskeleton.adapter.inbound.graphql.release.GraphQlCompatibilityMatrix;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
/**
* The protocol and library versions one Advanced capability was verified against.
*
* <p>Extends the Stable matrix rather than replacing it, and adds the protocol versions that only
* Advanced capabilities have: a WebSocket sub-protocol, a federation specification version, a
* codegen engine.
*
* @param capability the capability
* @param stableMatrix the Stable framework combination underneath it
* @param protocolVersions protocol versions verified, keyed by protocol name
*/
public record GraphQlAdvancedCompatibilityMatrix(
GraphQlAdvancedCapability capability,
GraphQlCompatibilityMatrix stableMatrix,
Map<String, String> protocolVersions) {
public GraphQlAdvancedCompatibilityMatrix {
if (capability == null || stableMatrix == null) {
throw new IllegalArgumentException("capability and stable matrix are required");
}
protocolVersions = Map.copyOf(protocolVersions);
}
/** Whether the whole combination is supported. */
public boolean supported() {
return problems().isEmpty();
}
/** Why the combination is unsupported, in a deterministic order. */
public List<String> problems() {
List<String> problems = new ArrayList<>(stableMatrix.problems());
if (protocolVersions.isEmpty()) {
problems.add(capability.name() + " declares no verified protocol version");
}
return List.copyOf(problems);
}
}
@@ -0,0 +1,64 @@
package dev.caskeleton.adapter.inbound.graphql.advanced.release;
import dev.caskeleton.adapter.inbound.graphql.advanced.bootstrap.GraphQlAdvancedCapability;
import dev.caskeleton.adapter.inbound.graphql.advanced.bootstrap.GraphQlAdvancedCapabilityGrade;
import java.time.Instant;
import java.util.List;
/**
* A decision to promote, hold or withdraw one capability.
*
* <p>Promotion out of Experimental needs a recorded decision with an owner. Without one a
* capability drifts into production by habit — enabled in one environment, then another, until
* nobody remembers it was never approved.
*
* @param capability the capability decided on
* @param fromGrade its grade before
* @param toGrade its grade after
* @param decisionRecord reference to the ADR or decision log entry
* @param owner who owns the capability
* @param decidedAt when the decision was made
* @param missingEvidence evidence still outstanding, empty when promotion is clean
*/
public record GraphQlAdvancedPromotionDecision(
GraphQlAdvancedCapability capability,
GraphQlAdvancedCapabilityGrade fromGrade,
GraphQlAdvancedCapabilityGrade toGrade,
String decisionRecord,
String owner,
Instant decidedAt,
List<String> missingEvidence) {
public GraphQlAdvancedPromotionDecision {
if (capability == null || fromGrade == null || toGrade == null) {
throw new IllegalArgumentException("capability and both grades are required");
}
if (decisionRecord == null || decisionRecord.isBlank()) {
throw new IllegalArgumentException("a promotion requires a recorded decision reference");
}
if (owner == null || owner.isBlank()) {
throw new IllegalArgumentException("a promotion requires an owner");
}
if (decidedAt == null) {
throw new IllegalArgumentException("a promotion requires a decision instant");
}
missingEvidence = List.copyOf(missingEvidence);
}
/** Whether the decision actually promotes the capability. */
public boolean promotes() {
return toGrade != fromGrade && toGrade != GraphQlAdvancedCapabilityGrade.EXPERIMENTAL;
}
/**
* Verifies a promotion is supported by evidence.
*
* @throws GraphQlAdvancedReleaseFailure when evidence is still outstanding
*/
public void verify() {
if (promotes() && !missingEvidence.isEmpty()) {
throw new GraphQlAdvancedReleaseFailure(
capability.name() + " cannot be promoted with outstanding evidence: " + missingEvidence);
}
}
}
@@ -0,0 +1,31 @@
package dev.caskeleton.adapter.inbound.graphql.advanced.release;
/**
* The evidence an Advanced capability release requires (Advanced plan Task 19).
*
* <p>The Stable baseline comes first and is not negotiable: an Advanced capability sits on top of
* the Stable platform's transport, error, security and cost guarantees, so releasing one on an
* unproven base means its own evidence was gathered against something that might not hold.
*
* @param stableBaselinePassed the Stable release gate passed
* @param capabilityContractsPassed the capability's own contract suites passed
* @param securityPassed authentication, authorization and isolation evidence
* @param soakPassed long-running behaviour under sustained load
* @param compatibilityPassed framework and protocol compatibility
*/
public record GraphQlAdvancedReleaseEvidence(
boolean stableBaselinePassed,
boolean capabilityContractsPassed,
boolean securityPassed,
boolean soakPassed,
boolean compatibilityPassed) {
/** Whether every kind of evidence is present. */
public boolean complete() {
return stableBaselinePassed
&& capabilityContractsPassed
&& securityPassed
&& soakPassed
&& compatibilityPassed;
}
}
@@ -0,0 +1,20 @@
package dev.caskeleton.adapter.inbound.graphql.advanced.release;
/**
* Raised when an Advanced capability may not be released.
*
* <p>Names what is missing, so the gate is a checklist rather than a wall.
*/
public class GraphQlAdvancedReleaseFailure extends RuntimeException {
private static final long serialVersionUID = 1L;
/**
* Creates the failure.
*
* @param reason which evidence is missing
*/
public GraphQlAdvancedReleaseFailure(String reason) {
super(reason);
}
}
@@ -0,0 +1,50 @@
package dev.caskeleton.adapter.inbound.graphql.advanced.release;
import java.util.ArrayList;
import java.util.List;
/**
* Blocks an Advanced capability release without complete evidence.
*
* <p>The Stable baseline is checked first and reported on its own, because everything else is
* measured against it: if the Stable platform's guarantees have not been demonstrated, the
* capability's soak numbers describe a system nobody has verified.
*/
public final class GraphQlAdvancedReleaseGate {
/**
* Verifies an Advanced release.
*
* @throws GraphQlAdvancedReleaseFailure naming the missing evidence
*/
public void verify(GraphQlAdvancedReleaseEvidence evidence) {
if (!evidence.stableBaselinePassed()) {
throw new GraphQlAdvancedReleaseFailure("stable graphql baseline must pass first");
}
List<String> missing = missing(evidence);
if (!missing.isEmpty()) {
throw new GraphQlAdvancedReleaseFailure("advanced graphql evidence incomplete: " + missing);
}
}
/** Which evidence is missing, in a deterministic order. */
public List<String> missing(GraphQlAdvancedReleaseEvidence evidence) {
List<String> missing = new ArrayList<>();
if (!evidence.stableBaselinePassed()) {
missing.add("stableBaseline");
}
if (!evidence.capabilityContractsPassed()) {
missing.add("capabilityContracts");
}
if (!evidence.securityPassed()) {
missing.add("security");
}
if (!evidence.soakPassed()) {
missing.add("soak");
}
if (!evidence.compatibilityPassed()) {
missing.add("compatibility");
}
return List.copyOf(missing);
}
}
@@ -0,0 +1,52 @@
package dev.caskeleton.adapter.inbound.graphql.advanced.release;
import dev.caskeleton.adapter.inbound.graphql.advanced.bootstrap.GraphQlAdvancedCapability;
import java.util.EnumMap;
import java.util.Map;
/**
* The operational runbook each capability must have before release.
*
* <p>Every Advanced capability introduces a failure mode the Stable platform does not have — a
* connection storm, a blocked operation to reverse, a subgraph to roll back. Requiring the runbook
* up front is what stops that being written for the first time during the incident.
*/
public final class GraphQlAdvancedRunbookIndex {
private final Map<GraphQlAdvancedCapability, String> runbooks =
new EnumMap<>(GraphQlAdvancedCapability.class);
/**
* Registers a capability's runbook.
*
* @param capability the capability
* @param runbookReference where the runbook lives
*/
public GraphQlAdvancedRunbookIndex register(
GraphQlAdvancedCapability capability, String runbookReference) {
if (runbookReference == null || runbookReference.isBlank()) {
throw new IllegalArgumentException("a runbook reference is required");
}
runbooks.put(capability, runbookReference);
return this;
}
/**
* Requires a capability to have a runbook.
*
* @throws GraphQlAdvancedReleaseFailure when it does not
*/
public String require(GraphQlAdvancedCapability capability) {
String reference = runbooks.get(capability);
if (reference == null) {
throw new GraphQlAdvancedReleaseFailure(
capability.name() + " cannot be released without an operational runbook");
}
return reference;
}
/** Registered runbooks. */
public Map<GraphQlAdvancedCapability, String> runbooks() {
return Map.copyOf(runbooks);
}
}
@@ -0,0 +1,51 @@
package dev.caskeleton.adapter.inbound.graphql.advanced.release;
import java.util.List;
/**
* The soak scenarios a long-lived transport must survive (design §24.4).
*
* <p>All of these are duration problems, invisible to a short test. A buffer leak, a connection
* that never re-authenticates and a source that reconnects badly all look fine for the first
* minute.
*/
public enum GraphQlAdvancedSoakScenario {
/** A thousand persistent connections held open. */
BASELINE_1K_CONNECTIONS,
/** The deployment's target connection count. */
TARGET_CONNECTION_COUNT,
/** Several subscriptions multiplexed on one connection. */
MULTIPLE_SUBSCRIPTIONS_PER_CONNECTION,
/** A burst of events far above steady state. */
EVENT_BURST,
/** A client that reads more slowly than the source produces. */
SLOW_CONSUMER,
/** Many simultaneous cancellations. */
CANCEL_STORM,
/** Credentials expiring mid-stream. */
AUTH_EXPIRY,
/** The server restarting under load. */
SERVER_RESTART,
/** A rolling deployment across instances. */
ROLLING_DEPLOYMENT,
/** Graceful shutdown with subscriptions in flight. */
GRACEFUL_DRAIN,
/** The event source restarting underneath the subscriptions. */
SOURCE_RESTART;
/** Every scenario a realtime capability must pass before promotion. */
public static List<GraphQlAdvancedSoakScenario> requiredForRealtime() {
return List.of(values());
}
}
@@ -0,0 +1,35 @@
package dev.caskeleton.adapter.inbound.graphql.advanced.replay;
import dev.caskeleton.adapter.inbound.graphql.advanced.security.GraphQlWebSocketPrincipal;
/**
* 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.
*/
public final class GraphQlReplayAuthorization {
private GraphQlReplayAuthorization() {}
/**
* Verifies a replay request.
*
* @param cursorActorFingerprint the actor 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
*/
public static void verify(
String cursorActorFingerprint, GraphQlWebSocketPrincipal principal, boolean stillAuthorized) {
if (cursorActorFingerprint == null
|| !cursorActorFingerprint.equals(principal.actorFingerprint())) {
throw new GraphQlReplayAuthorizationException();
}
if (!stillAuthorized) {
throw new GraphQlReplayAuthorizationException();
}
}
}
@@ -0,0 +1,17 @@
package dev.caskeleton.adapter.inbound.graphql.advanced.replay;
/**
* Raised when a caller may not replay a subscription's history.
*
* <p>Carries no actor identity or position: a denial must not reveal whose cursor it was or how far
* it reached.
*/
public class GraphQlReplayAuthorizationException extends RuntimeException {
private static final long serialVersionUID = 1L;
/** Creates the failure. */
public GraphQlReplayAuthorizationException() {
super("subscription replay is not authorized");
}
}
@@ -0,0 +1,22 @@
package dev.caskeleton.adapter.inbound.graphql.advanced.replay;
/**
* Raised when a snapshot and the live stream do not join up.
*
* <p>A gap means events happened between the snapshot and the first live event, and the client will
* never see them. Failing loudly is the only honest option: silently continuing would present an
* incomplete stream as a complete one.
*/
public class GraphQlReplayGapException extends RuntimeException {
private static final long serialVersionUID = 1L;
/**
* Creates the failure.
*
* @param reason bounded description of the gap
*/
public GraphQlReplayGapException(String reason) {
super(reason);
}
}
@@ -0,0 +1,25 @@
package dev.caskeleton.adapter.inbound.graphql.advanced.replay;
/**
* Raised when a resume position has fallen outside the retention window.
*
* <p>A distinct outcome from a rejected cursor: the cursor is genuine, the history is simply gone,
* and the client's correct response is to re-read a snapshot rather than to re-authenticate.
*/
public class GraphQlReplayHistoryLostException extends RuntimeException {
private static final long serialVersionUID = 1L;
/** Stable error code. */
public static final String CODE = "SUBSCRIPTION_HISTORY_LOST";
/** Creates the failure. */
public GraphQlReplayHistoryLostException() {
super(CODE);
}
/** The stable error code. */
public String code() {
return CODE;
}
}
@@ -0,0 +1,23 @@
package dev.caskeleton.adapter.inbound.graphql.advanced.replay;
/**
* A position in the source event sequence.
*
* <p>The sequence is the messaging platform's, not GraphQL's: GraphQL has no resume token, and
* inventing one here would promise a durability guarantee the transport cannot keep.
*
* @param sequence monotonic source position
*/
public record GraphQlReplayPosition(long sequence) {
public GraphQlReplayPosition {
if (sequence < 0) {
throw new IllegalArgumentException("sequence cannot be negative");
}
}
/** The position immediately after this one. */
public GraphQlReplayPosition next() {
return new GraphQlReplayPosition(sequence + 1);
}
}
@@ -0,0 +1,41 @@
package dev.caskeleton.adapter.inbound.graphql.advanced.replay;
import dev.caskeleton.adapter.inbound.graphql.advanced.subscription.GraphQlSubscriptionEvent;
import org.reactivestreams.Publisher;
/**
* A source that can replay from a position.
*
* <p>The durability is the messaging platform's, and so is the retention window. This interface
* only asks whether a position is still available — a cursor older than the window has to fail
* rather than silently resume from wherever history now begins.
*/
public interface GraphQlReplaySource {
/** The earliest position still retained. */
GraphQlReplayPosition earliestAvailable();
/** Whether a position can still be replayed. */
default boolean available(GraphQlReplayPosition position) {
return position.sequence() >= earliestAvailable().sequence();
}
/**
* Replays from a position.
*
* @throws GraphQlReplayHistoryLostException when the position has fallen outside the retention
* window
*/
Publisher<GraphQlSubscriptionEvent> replayFrom(GraphQlReplayPosition position);
/**
* Verifies a position is still replayable.
*
* @throws GraphQlReplayHistoryLostException when it is not
*/
default void requireAvailable(GraphQlReplayPosition position) {
if (!available(position)) {
throw new GraphQlReplayHistoryLostException();
}
}
}
@@ -0,0 +1,38 @@
package dev.caskeleton.adapter.inbound.graphql.advanced.replay;
/**
* The join between a snapshot and the live stream (Advanced plan Task 10).
*
* <p>Resuming means reading a snapshot and then continuing live, and the two have to meet exactly.
* A live stream starting later than the snapshot ends loses events; one starting earlier repeats
* them. Both are checked, because "roughly continuous" is not something a client can compensate
* for.
*
* @param snapshotPosition last position included in the snapshot
* @param liveStartPosition first position the live stream will deliver
*/
public record GraphQlSnapshotLiveHandoff(
GraphQlReplayPosition snapshotPosition, GraphQlReplayPosition liveStartPosition) {
public GraphQlSnapshotLiveHandoff {
if (snapshotPosition == null || liveStartPosition == null) {
throw new IllegalArgumentException("both handoff positions are required");
}
}
/**
* Verifies the two streams join without a gap.
*
* @throws GraphQlReplayGapException when events would be missed
*/
public void verifyContiguous() {
if (liveStartPosition.sequence() > snapshotPosition.sequence() + 1) {
throw new GraphQlReplayGapException("snapshot and live stream contain a gap");
}
}
/** Whether the join would repeat events the snapshot already contained. */
public boolean duplicates() {
return liveStartPosition.sequence() <= snapshotPosition.sequence();
}
}
@@ -0,0 +1,58 @@
package dev.caskeleton.adapter.inbound.graphql.advanced.replay;
import dev.caskeleton.adapter.inbound.graphql.pagination.GraphQlCursorCodec;
import dev.caskeleton.adapter.inbound.graphql.pagination.GraphQlCursorPayload;
import java.util.Map;
/**
* 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>GraphQL itself defines no resume mechanism — this is an extension, and the durability behind
* it belongs to the messaging platform.
*/
public final class GraphQlSubscriptionCursor {
/** Query profile cursors of this kind are bound to. */
public static final String QUERY_PROFILE = "subscription-replay";
private GraphQlSubscriptionCursor() {}
/**
* Issues a resume cursor.
*
* @param codec the signing codec
* @param subscriptionProfile the subscription this cursor belongs to
* @param actorFingerprint the actor it was issued to
* @param position the resume position
*/
public static String issue(
GraphQlCursorCodec codec,
String subscriptionProfile,
String actorFingerprint,
GraphQlReplayPosition position) {
return codec.encode(
GraphQlCursorPayload.of(
QUERY_PROFILE,
GraphQlCursorPayload.FORWARD,
Map.of("id", subscriptionProfile, "sequence", Long.toString(position.sequence())),
actorFingerprint));
}
/**
* Verifies and decodes a resume cursor.
*
* @param codec the signing codec
* @param cursor the cursor the client presented
* @param actorFingerprint the actor presenting it
* @throws dev.caskeleton.adapter.inbound.graphql.pagination.GraphQlCursorException when the
* cursor was issued for another actor or subscription
*/
public static GraphQlReplayPosition resume(
GraphQlCursorCodec codec, String cursor, String actorFingerprint) {
GraphQlCursorPayload payload = codec.decode(cursor, QUERY_PROFILE, actorFingerprint);
return new GraphQlReplayPosition(Long.parseLong(payload.keyset().get("sequence")));
}
}
@@ -0,0 +1,49 @@
package dev.caskeleton.adapter.inbound.graphql.advanced.rsocket;
import java.util.Objects;
import java.util.Set;
/**
* Authentication metadata accepted on an RSocket connection.
*
* <p>MIME types are allowlisted for the same reason routes are: metadata drives how a credential is
* parsed, and an unexpected encoding is an unexpected parser.
*/
public final class GraphQlRSocketAuthentication {
private final Set<String> allowedMetadataMimeTypes;
/**
* Creates the authentication policy.
*
* @param allowedMetadataMimeTypes metadata MIME types accepted
*/
public GraphQlRSocketAuthentication(Set<String> allowedMetadataMimeTypes) {
this.allowedMetadataMimeTypes = Set.copyOf(Objects.requireNonNull(allowedMetadataMimeTypes));
}
/**
* Requires a metadata MIME type to be allowlisted.
*
* @throws GraphQlRSocketRouteRejectedException when it is not
*/
public void requireAllowedMetadata(String mimeType) {
if (mimeType == null || !allowedMetadataMimeTypes.contains(mimeType)) {
throw new GraphQlRSocketRouteRejectedException("metadata MIME type " + mimeType);
}
}
/**
* Requires an authenticated actor.
*
* <p>The same actor and tenant rules as HTTP: a different transport does not mean different
* security.
*
* @throws GraphQlRSocketRouteRejectedException when the connection is unauthenticated
*/
public void requireAuthenticated(boolean authenticated) {
if (!authenticated) {
throw new GraphQlRSocketRouteRejectedException("unauthenticated RSocket connection");
}
}
}
@@ -0,0 +1,28 @@
package dev.caskeleton.adapter.inbound.graphql.advanced.rsocket;
import dev.caskeleton.adapter.inbound.graphql.policy.GraphQlOperationType;
/**
* How GraphQL operations map onto RSocket interaction models.
*
* <p>A fixed mapping, not a choice: a query as request-stream would leave the client waiting for a
* stream that emits once and never completes in the way it expects.
*/
public enum GraphQlRSocketCapability {
/** Query and mutation: one request, one response. */
REQUEST_RESPONSE,
/** Subscription: one request, a stream of responses. */
REQUEST_STREAM;
/** The interaction model for an operation type. */
public static GraphQlRSocketCapability forOperation(GraphQlOperationType operationType) {
return operationType == GraphQlOperationType.SUBSCRIPTION ? REQUEST_STREAM : REQUEST_RESPONSE;
}
/** Whether RSocket is a public-facing transport. It is not. */
public static boolean publicTransport() {
return false;
}
}
@@ -0,0 +1,36 @@
package dev.caskeleton.adapter.inbound.graphql.advanced.rsocket;
import dev.caskeleton.adapter.inbound.graphql.error.GraphQlErrorContext;
import dev.caskeleton.adapter.inbound.graphql.error.GraphQlExceptionResolver;
import dev.caskeleton.adapter.inbound.graphql.error.GraphQlWireError;
import java.util.Objects;
/**
* Maps failures onto RSocket, reusing the HTTP error contract.
*
* <p>Deliberately the same resolver: a masked internal error over HTTP and a disclosed one over
* RSocket would mean the safest transport is whichever the attacker did not choose.
*/
public final class GraphQlRSocketErrorMapper {
private final GraphQlExceptionResolver resolver;
/**
* Creates the mapper.
*
* @param resolver the shared exception resolver
*/
public GraphQlRSocketErrorMapper(GraphQlExceptionResolver resolver) {
this.resolver = Objects.requireNonNull(resolver);
}
/** A mapper using the default masking resolver. */
public static GraphQlRSocketErrorMapper defaults() {
return new GraphQlRSocketErrorMapper(GraphQlExceptionResolver.defaults());
}
/** Maps a failure to the same wire error the HTTP transport would produce. */
public GraphQlWireError map(Throwable failure, GraphQlErrorContext context) {
return resolver.resolve(failure, context);
}
}
@@ -0,0 +1,51 @@
package dev.caskeleton.adapter.inbound.graphql.advanced.rsocket;
import dev.caskeleton.adapter.inbound.graphql.advanced.bootstrap.GraphQlAdvancedCapability;
import dev.caskeleton.adapter.inbound.graphql.advanced.bootstrap.GraphQlAdvancedModuleGuard;
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.
*
* <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 {
private final GraphQlAdvancedModuleGuard guard;
private final GraphQlRSocketProperties properties;
private final GraphQlRSocketRoutePolicy routePolicy;
/**
* Creates the factory.
*
* @param guard the Advanced capability guard
* @param properties transport configuration
*/
public GraphQlRSocketHandlerFactory(
GraphQlAdvancedModuleGuard guard, GraphQlRSocketProperties properties) {
this.guard = Objects.requireNonNull(guard);
this.properties = Objects.requireNonNull(properties);
this.routePolicy = new GraphQlRSocketRoutePolicy(properties.allowedRoutes());
}
/**
* Accepts a request on a route.
*
* @param route the requested route
* @param operationType the operation type
* @return the interaction model to use
* @throws GraphQlRSocketRouteRejectedException when the route is not allowlisted
*/
public GraphQlRSocketCapability accept(String route, GraphQlOperationType operationType) {
guard.requireEnabled(GraphQlAdvancedCapability.RSOCKET);
routePolicy.requireAllowed(route);
return GraphQlRSocketCapability.forOperation(operationType);
}
/** Whether the transport is registered at all. */
public boolean enabled() {
return properties.enabled() && guard.enabled(GraphQlAdvancedCapability.RSOCKET);
}
}
@@ -0,0 +1,39 @@
package dev.caskeleton.adapter.inbound.graphql.advanced.rsocket;
import java.util.Set;
/**
* RSocket transport configuration.
*
* <p>Requires a named consumer. This is an experimental transport for internal systems, and one
* that nobody is identified as using is one nobody is testing.
*
* @param enabled whether the transport is registered
* @param allowedRoutes routes that reach GraphQL
* @param allowedMetadataMimeTypes metadata MIME types accepted
* @param namedConsumers the systems that use it
*/
public record GraphQlRSocketProperties(
boolean enabled,
Set<String> allowedRoutes,
Set<String> allowedMetadataMimeTypes,
Set<String> namedConsumers) {
/** The route GraphQL is conventionally served on. */
public static final String DEFAULT_ROUTE = "graphql";
public GraphQlRSocketProperties {
allowedRoutes = Set.copyOf(allowedRoutes);
allowedMetadataMimeTypes = Set.copyOf(allowedMetadataMimeTypes);
namedConsumers = Set.copyOf(namedConsumers);
if (enabled && namedConsumers.isEmpty()) {
throw new IllegalArgumentException(
"an enabled RSocket transport must name the consumers that use it");
}
}
/** The transport disabled. */
public static GraphQlRSocketProperties disabled() {
return new GraphQlRSocketProperties(false, Set.of(DEFAULT_ROUTE), Set.of(), Set.of());
}
}
@@ -0,0 +1,43 @@
package dev.caskeleton.adapter.inbound.graphql.advanced.rsocket;
import java.util.Set;
/**
* The RSocket routes that reach GraphQL.
*
* <p>An allowlist rather than a prefix match: RSocket routing is string-based, and a pattern is
* exactly how an unintended handler becomes reachable.
*/
public final class GraphQlRSocketRoutePolicy {
private final Set<String> allowedRoutes;
/**
* Creates the policy.
*
* @param allowedRoutes routes that reach GraphQL execution
*/
public GraphQlRSocketRoutePolicy(Set<String> allowedRoutes) {
this.allowedRoutes = Set.copyOf(allowedRoutes);
if (this.allowedRoutes.isEmpty()) {
throw new IllegalArgumentException("at least one RSocket route is required");
}
}
/**
* Requires a route to be allowlisted.
*
* @throws GraphQlRSocketRouteRejectedException when it is not
*/
public String requireAllowed(String route) {
if (!allowedRoutes.contains(route)) {
throw new GraphQlRSocketRouteRejectedException(route);
}
return route;
}
/** The allowlisted routes. */
public Set<String> allowedRoutes() {
return allowedRoutes;
}
}

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