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:
co-authored by
Claude Opus 5
parent
3b5aee50e3
commit
b074c1494e
@@ -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 설계다.
|
||||
|
||||
@@ -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 밖에서 생성해야 한다 — 다만 그 **부재가
|
||||
릴리스를 막도록** 게이트가 이미 서 있다.
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
+50
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
+17
@@ -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");
|
||||
}
|
||||
}
|
||||
+132
@@ -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));
|
||||
}
|
||||
}
|
||||
+43
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
+31
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
+45
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
+21
@@ -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);
|
||||
}
|
||||
}
|
||||
+29
@@ -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);
|
||||
}
|
||||
}
|
||||
+62
@@ -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('_', '-');
|
||||
}
|
||||
}
|
||||
+21
@@ -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);
|
||||
}
|
||||
}
|
||||
+31
@@ -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;
|
||||
}
|
||||
}
|
||||
+50
@@ -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());
|
||||
}
|
||||
}
|
||||
+40
@@ -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);
|
||||
}
|
||||
}
|
||||
+63
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
+25
@@ -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);
|
||||
}
|
||||
}
|
||||
+72
@@ -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;
|
||||
}
|
||||
}
|
||||
+52
@@ -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;
|
||||
}
|
||||
}
|
||||
+51
@@ -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);
|
||||
}
|
||||
}
|
||||
+23
@@ -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);
|
||||
}
|
||||
}
|
||||
+44
@@ -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);
|
||||
}
|
||||
}
|
||||
+51
@@ -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();
|
||||
}
|
||||
}
|
||||
+20
@@ -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");
|
||||
}
|
||||
}
|
||||
+34
@@ -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);
|
||||
}
|
||||
}
|
||||
+38
@@ -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();
|
||||
}
|
||||
}
|
||||
+45
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
+27
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
+54
@@ -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);
|
||||
}
|
||||
}
|
||||
+39
@@ -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;
|
||||
}
|
||||
}
|
||||
+40
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
+19
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
+21
@@ -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);
|
||||
}
|
||||
}
|
||||
+58
@@ -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");
|
||||
}
|
||||
}
|
||||
+35
@@ -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;
|
||||
}
|
||||
}
|
||||
+39
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
+44
@@ -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);
|
||||
}
|
||||
}
|
||||
+38
@@ -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);
|
||||
}
|
||||
}
|
||||
+42
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
+40
@@ -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;
|
||||
}
|
||||
}
|
||||
+25
@@ -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;
|
||||
}
|
||||
}
|
||||
+21
@@ -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);
|
||||
}
|
||||
}
|
||||
+29
@@ -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();
|
||||
}
|
||||
}
|
||||
+41
@@ -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);
|
||||
}
|
||||
}
|
||||
+47
@@ -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);
|
||||
}
|
||||
}
|
||||
+45
@@ -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");
|
||||
}
|
||||
}
|
||||
+31
@@ -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);
|
||||
}
|
||||
}
|
||||
+49
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+34
@@ -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());
|
||||
}
|
||||
}
|
||||
+21
@@ -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);
|
||||
}
|
||||
}
|
||||
+54
@@ -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;
|
||||
}
|
||||
}
|
||||
+45
@@ -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);
|
||||
}
|
||||
}
|
||||
+47
@@ -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;
|
||||
}
|
||||
}
|
||||
+42
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
+28
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
+41
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
+21
@@ -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);
|
||||
}
|
||||
}
|
||||
+53
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
+45
@@ -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();
|
||||
}
|
||||
}
|
||||
+35
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
+19
@@ -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;
|
||||
}
|
||||
}
|
||||
+32
@@ -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;
|
||||
}
|
||||
}
|
||||
+22
@@ -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);
|
||||
}
|
||||
}
|
||||
+32
@@ -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);
|
||||
}
|
||||
}
|
||||
+42
@@ -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();
|
||||
}
|
||||
}
|
||||
+100
@@ -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);
|
||||
}
|
||||
}
|
||||
+23
@@ -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");
|
||||
}
|
||||
}
|
||||
+23
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
+66
@@ -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());
|
||||
}
|
||||
}
|
||||
+37
@@ -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"));
|
||||
}
|
||||
}
|
||||
+90
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
+31
@@ -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);
|
||||
}
|
||||
+30
@@ -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;
|
||||
}
|
||||
}
|
||||
+25
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
+31
@@ -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;
|
||||
}
|
||||
}
|
||||
+44
@@ -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();
|
||||
}
|
||||
}
|
||||
+42
@@ -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();
|
||||
}
|
||||
}
|
||||
+45
@@ -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);
|
||||
}
|
||||
}
|
||||
+64
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
+31
@@ -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;
|
||||
}
|
||||
}
|
||||
+20
@@ -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);
|
||||
}
|
||||
}
|
||||
+50
@@ -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);
|
||||
}
|
||||
}
|
||||
+52
@@ -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);
|
||||
}
|
||||
}
|
||||
+51
@@ -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());
|
||||
}
|
||||
}
|
||||
+35
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
+17
@@ -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");
|
||||
}
|
||||
}
|
||||
+22
@@ -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);
|
||||
}
|
||||
}
|
||||
+25
@@ -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;
|
||||
}
|
||||
}
|
||||
+23
@@ -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);
|
||||
}
|
||||
}
|
||||
+41
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
+38
@@ -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();
|
||||
}
|
||||
}
|
||||
+58
@@ -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")));
|
||||
}
|
||||
}
|
||||
+49
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
+28
@@ -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;
|
||||
}
|
||||
}
|
||||
+36
@@ -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);
|
||||
}
|
||||
}
|
||||
+51
@@ -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);
|
||||
}
|
||||
}
|
||||
+39
@@ -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());
|
||||
}
|
||||
}
|
||||
+43
@@ -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
Reference in New Issue
Block a user