feat: jpa, messaging, notification, mongo, graphql 어댑터터 구현체 추가
This commit is contained in:
@@ -33,11 +33,21 @@ leaf). 즉 이 레포에는 두 패턴이 공존한다:
|
||||
모듈 레코드가 그대로 leaf 명세로 승격될 수 있게 설계해 두었다. 그때까지 모듈 경계는 문서가
|
||||
아니라 기계가 지킨다:
|
||||
|
||||
- `build/GraphQlStableModule` · `build/GraphQlAdvancedModule` 이 모듈 정체성과 허용 의존 edge 를
|
||||
값으로 선언하고, `build/GraphQlBuildModel` 이 실제 소스 트리를 스캔한다.
|
||||
- `build/GraphQlModuleBoundaryTest` 가 (a) Stable 패키지의 `...graphql.advanced` import 금지,
|
||||
(b) `graphql-core-api` 계열의 Spring/GraphQL Java/Reactor/persistence import 금지,
|
||||
(c) Stable 의존 edge 가 Advanced 모듈을 가리키지 않을 것을 강제한다.
|
||||
- main 의 `moduleboundary/GraphQlStableModule` · `moduleboundary/GraphQlAdvancedModule` 이 모듈
|
||||
정체성·purity 등급·허용 의존 edge 를 값으로 선언하고, `moduleboundary/GraphQlModuleBoundary` 가
|
||||
"이 패키지의 주인은 누구인가 / 이 edge 는 선언됐는가"를 답한다.
|
||||
- test 의 `moduleboundary/GraphQlBuildModel` 이 실제 소스 트리를 스캔하고,
|
||||
`moduleboundary/GraphQlModuleBoundaryTest` 가 (a) Stable 패키지의 `...graphql.advanced` import
|
||||
금지, (b) `CORE` 등급 모듈의 Spring/GraphQL Java/Reactor/Micrometer/Jakarta import 금지,
|
||||
(c) 선언되지 않은 cross-module edge 금지, (d) 미등록 패키지 금지, (e) 선언만 있고 소스가 없는
|
||||
모듈 금지를 강제한다. 각 규칙은 **거부되는 합성 트리(negative fixture)** 를 함께 가진다.
|
||||
|
||||
**패키지 이름은 `build` 가 아니라 `moduleboundary` 다.** `src/.gitignore:2` 의 anchor 없는
|
||||
`build/` 규칙은 Gradle 산출물과 Java 패키지를 구분하지 못해서, 예전에 이 경계 모델 전체를
|
||||
커밋에서 삼켰다(프로덕션 코드는 계속 import 하고, 작성자 작업본만 컴파일되고, fresh checkout
|
||||
은 7개 오류로 깨졌다). 레포 전역 `verifyNoIgnoredSourcePackages` 가 이 부류를 막고,
|
||||
`graphqlStableTest` 의 required-class 검사가 "경계 테스트만 조용히 사라지고 레인은 green" 인
|
||||
나머지 절반을 막는다.
|
||||
|
||||
**새 플랫폼 sub-package 를 추가할 때는 반드시 해당 모듈 레코드에 정체성과 허용 edge 를 먼저
|
||||
등록한다.** 등록 없이 추가된 패키지는 경계 테스트가 실패시킨다.
|
||||
@@ -59,13 +69,25 @@ leaf). 즉 이 레포에는 두 패턴이 공존한다:
|
||||
## Allowed
|
||||
|
||||
- `:application-core`, `:domain-core`, `:shared-contract`.
|
||||
- `spring-boot-starter-graphql`, `spring-boot-starter-web`, `jackson-datatype-jsr310`
|
||||
(전부 Spring Boot BOM 관리 — 버전 명시 없음).
|
||||
- test scope 에 한해 실제 HTTP 인증/CORS qualification 용 `spring-boot-starter-security`.
|
||||
- `compileOnly` 로만 `spring-webflux` — REACTIVE_WEBFLUX 전송 프로파일(`http/webflux/`)을
|
||||
컴파일하기 위한 것이고, 의도적으로 `runtimeClasspath` 에서 제외한다. MVC 배치에 WebFlux 를
|
||||
끌어들이지 않기 위함이며 `gradle.lockfile` 이 이 스코프 제한을 고정한다
|
||||
(`spring-webflux:...=compileClasspath,testCompileClasspath,testRuntimeClasspath`).
|
||||
- `spring-boot-starter-graphql` (Spring Boot BOM 관리 — 버전 명시 없음).
|
||||
- test scope 에 한해 `spring-boot-starter-web`(random-port 전송 테스트용),
|
||||
`spring-boot-starter-security`(HTTP 인증/CORS qualification 용),
|
||||
`io.micrometer:micrometer-core`(실제 `MeterRegistry` 로 metric label cardinality 를 **측정**).
|
||||
- `java-test-fixtures` — 계약 스위트·통합 fixture·in-memory 스텁은 `src/testFixtures/java` 가
|
||||
소유하고 production jar 에 들어가지 않는다. 모듈 경계 스캐너
|
||||
(`moduleboundary/GraphQlBuildModel`)는 main 과 testFixtures 를 **함께** 스캔한다: 아티팩트가
|
||||
갈렸다고 패키지 경계 규칙까지 갈리면, 규칙이 조용히 절반만 남는다.
|
||||
|
||||
**서버는 이 leaf 가 고르지 않는다.** production 파일 중 `org.springframework.web`·
|
||||
`jakarta.servlet`·`org.springframework.http` 을 import 하는 것은 **하나도 없다**. 예전에는
|
||||
`spring-boot-starter-web` 을 production `implementation` 으로 두어 모든 adopter 의
|
||||
runtimeClasspath 에 Tomcat 을 올리면서, 동시에 같은 artifact 가 `REACTIVE_WEBFLUX` 실행
|
||||
프로파일을 표방했다 — leaf 와 함께 servlet 컨테이너가 따라오므로 결코 성립할 수 없는 조합이었다.
|
||||
|
||||
이제 서버 선택은 composition root 의 결정이고, `gradle.lockfile` 이 이를 고정한다
|
||||
(`spring-boot-starter-web`·`spring-webmvc`·`spring-webflux`·`tomcat-embed-*` 전부
|
||||
`testCompileClasspath,testRuntimeClasspath` 만). `GraphQlRuntimeTransport` 가 실제 실행 중인
|
||||
서버를 감지해 `backend.graphql.execution-profile` 과 어긋나면 **부팅을 거부**한다.
|
||||
- `annotationProcessor` 로 `spring-boot-configuration-processor` — `GraphQlPlatformProperties` 가
|
||||
`@ConfigurationProperties` 이므로 레포 전역 `verifyConfigurationPropertiesProcessor` 패리티
|
||||
게이트가 이 선언을 요구한다.
|
||||
@@ -98,12 +120,30 @@ feature 는 `ApiErrorCarrier` 를 구현한 예외(자신의 `ApiErrorCode` 를
|
||||
현재 sample 에 feature GraphQL schema/controller/resolver 가 있다고 가정하지 않는다. 이 leaf 는
|
||||
health 스키마만 소유한다.
|
||||
|
||||
## 구현된 플랫폼 범위
|
||||
## 구현된 플랫폼 범위 — 등급으로 말한다
|
||||
|
||||
query depth/cost 제한(`cost/`), persisted operation(`advanced/persisted/`),
|
||||
DataLoader/batching(`dataloader/`), subscription(`advanced/subscription/`, `advanced/websocket/`,
|
||||
`advanced/sse/`)은 **더 이상 미구현이 아니다.** 다만 이들은 정책·계약·검증 기계이며, 실제
|
||||
composition root 가 채택할 때 정책 값과 인증/인가 빈을 함께 제공해야 한다.
|
||||
"구현됐다" 는 네 가지 서로 다른 사실을 한 단어로 덮는다. 그래서 capability 마다 아래 등급을
|
||||
쓰고, **현재 등급보다 높게 표현하지 않는다.**
|
||||
|
||||
| 등급 | 의미 |
|
||||
| --- | --- |
|
||||
| `modelled` | 정책·계약 객체가 있고 단위 테스트가 있다. 요청 경로에는 없다. |
|
||||
| `wired` | Spring 실행 경로에 연결돼 있고, 실제 endpoint 테스트가 그 사실을 증명한다. |
|
||||
| `integration-verified` | 실제 외부 시스템(datastore/broker) 과의 통합 증거가 있다. |
|
||||
| `production-verified` | 실부하·장애 시나리오 증거가 있다. |
|
||||
|
||||
| Capability | 등급 | 증거 |
|
||||
| --- | --- | --- |
|
||||
| 실행 파이프라인 / 인가 / cost 예산 | `wired` | `runtime/GraphQlPlatformExecutionPathTest` (random-port, 거부 시 resolver 호출 0회) |
|
||||
| depth/complexity 제한 (`cost/`) | `wired` | 같은 테스트의 depth/alias/complexity 케이스 |
|
||||
| preparsed document cache (`execution/`) | `wired` | `GraphQlPreparsedDocumentAdapter` + 같은 테스트의 캐시 hit 케이스 |
|
||||
| 커스텀 scalar (`scalar/`) | `wired` | 같은 테스트의 scalar coercion 케이스 |
|
||||
| 요청 크기/Accept 협상 (`http/`) | `wired` | `GraphQlRequestBoundsTest`, `GraphQlAcceptNegotiationTest` |
|
||||
| DataLoader/batching (`dataloader/`) | `wired` | `runtime/GraphQlBatchLoaderRegistrar` + `dataloader/GraphQlBatchContractTest` |
|
||||
| persisted operation (`advanced/persisted/`) | `modelled` | 중립 `OperationalRecordStorePort` 기반 레지스트리 + 방향성 테스트. durable 구현체는 미제공 |
|
||||
| subscription / WebSocket / SSE / RSocket | `modelled` | 정책·상태기계 단위 테스트만. Spring transport handler 는 없다(그래서 타입 이름도 `*Admission` 이다) |
|
||||
| federation / incremental / codegen / compat | `modelled` | 단위 테스트만 |
|
||||
| 실부하·장애 | 미달성 | `graphqlPerformanceTest` 레인이 자리를 예약, 증거 없으면 릴리스 게이트가 거부 |
|
||||
|
||||
여전히 미구현인 것:
|
||||
|
||||
@@ -114,7 +154,13 @@ composition root 가 채택할 때 정책 값과 인증/인가 빈을 함께 제
|
||||
생성한다(`graphqlPerformanceTest` 레인이 그 자리를 예약해 둔다).
|
||||
- 실제 datastore 통합 증거 — `testkit/GraphQlJpaIntegrationFixture` /
|
||||
`GraphQlMongoIntegrationFixture` 가 계약을 정의하고 `GraphQlStorageIntegrationEvidence` 가
|
||||
증거를 요구한다. 실 datastore 기동은 persistence leaf 의 책임 범위다.
|
||||
증거를 요구한다. 실 datastore 기동은 persistence leaf 의 책임 범위다. 이 testkit 은
|
||||
**production jar 에 없다** — `src/testFixtures/java` 에 살고 `verifyGraphQlProductionJar` 가
|
||||
그 사실을 jar 내용으로 확인한다.
|
||||
- persisted operation 의 durable 저장 구현체 — 이 leaf 는 중립 계약
|
||||
`dev.caskeleton.shared.opstore.OperationalRecordStorePort` 에만 의존하고 key/value 매핑만
|
||||
소유한다. Postgres/Redis 구현체는 **그 중립 계약을** 구현하며, 이 leaf 의 타입을 구현하지
|
||||
않는다(그랬다면 인프라 → 인바운드 전송으로 의존이 뒤집힌다).
|
||||
- Advanced capability 는 전부 **기본 비활성**이다(`advanced/bootstrap/GraphQlAdvancedFeatureFlags`).
|
||||
EXPERIMENTAL 등급(RSocket, incremental delivery, HTTP GET draft)은 명시적 승인 없이는
|
||||
`GraphQlAdvancedModuleGuard` 가 production 활성화를 거부한다.
|
||||
@@ -133,11 +179,30 @@ cd src
|
||||
`quarantine`·`graphql-performance` 태그를 제외한다:
|
||||
|
||||
```bash
|
||||
./gradlew :adapter:inbound:graphql:graphqlStableTest --console=plain # 404 tests
|
||||
./gradlew :adapter:inbound:graphql:graphqlStableTest --console=plain # 551 tests
|
||||
./gradlew :adapter:inbound:graphql:graphqlContractTest --console=plain # 9 tests
|
||||
./gradlew :adapter:inbound:graphql:graphqlAdvancedTest --console=plain # 141 tests
|
||||
./gradlew :adapter:inbound:graphql:graphqlAdvancedTest --console=plain # 152 tests
|
||||
./gradlew :adapter:inbound:graphql:graphqlPerformanceTest --console=plain # 실부하 인프라 필요
|
||||
```
|
||||
|
||||
`graphqlPerformanceTest` 는 `@Tag("graphql-performance")` 가 하나도 없으면 **실패한다** — 이는
|
||||
버그가 아니라 "성능 증거 없음"을 통과로 위장하지 않기 위한 fail-closed 설계다.
|
||||
|
||||
위 숫자는 `build/test-results/<lane>/*.xml` 의 실제 실행 결과다(기본 `test` 703, transport
|
||||
qualification 8). 문서에 옮겨 적은 숫자는 반드시 마지막 green 실행에서 다시 읽어 갱신한다 —
|
||||
컴파일이 깨진 채로 남은 과거 숫자는 통과 증거가 아니라 통과했다는 인상일 뿐이다.
|
||||
|
||||
## 아티팩트 게이트
|
||||
|
||||
```bash
|
||||
./gradlew :adapter:inbound:graphql:verifyGraphQlProductionJar --console=plain
|
||||
./gradlew :adapter:inbound:graphql:verifyGraphQlApiSurface --console=plain
|
||||
```
|
||||
|
||||
- `verifyGraphQlProductionJar` — production jar 에 `testkit`/`InMemory`/`Fixture`/`TestContext`
|
||||
클래스가 하나라도 있으면 실패한다. 계약 스위트와 in-memory 스텁은 `src/testFixtures/java` 가
|
||||
소유한다.
|
||||
- `verifyGraphQlApiSurface` — `docs/architecture/graphql-api-surface.txt` 스냅샷과 실제 public
|
||||
타입 목록이 다르면 실패한다. 단일 jar 안에서 `public` 은 모든 adopter 에게 public 이므로,
|
||||
표면 증가는 리뷰 결정이지 빌드 부산물이 아니다. 승인 후:
|
||||
`./gradlew :adapter:inbound:graphql:updateGraphQlApiSurface -PapproveGraphQlApiSurfaceChange`.
|
||||
|
||||
@@ -72,11 +72,40 @@ gRPC 와 달리 spring-graphql / graphql-java 는 Spring Boot BOM 이 관리한
|
||||
버전 명시도, 모듈 스코프 platform import 도 필요 없다 — `build.gradle` 은 BOM-managed 좌표만
|
||||
선언하고, per-module `gradle.lockfile` 이 strict locking 으로 정확한 버전을 고정한다.
|
||||
|
||||
## 설정 — 프레임워크 `spring.graphql.*`
|
||||
## 설정 — 프레임워크 `spring.graphql.*` + 플랫폼 `backend.graphql.*`
|
||||
|
||||
이 모듈은 자체 `@ConfigurationProperties` 를 두지 않는다. path, graphiql, introspection, schema
|
||||
location 은 프레임워크 `spring.graphql.*` 로 composition-root `application.yml` 에서 설정한다
|
||||
(모듈별 `yml` 없음). 정말 필요한 knob 이 생기기 전까지 커스텀 설정 클래스는 두지 않는다.
|
||||
전송 계층 설정(path, graphiql, introspection, schema location)은 프레임워크 `spring.graphql.*`
|
||||
가 소유한다. composition-root `application.yml` 에서 설정하며 모듈별 `yml` 은 없다.
|
||||
|
||||
플랫폼 정책은 `spring.graphql.*` 로 표현할 수 없다 — 실행 프로파일, cost/page 한계, preparsed
|
||||
캐시 경계, cursor 키 링, 관측 label 로 허용할 operation 이름은 전부 이 leaf 의 결정이다. 그래서
|
||||
`GraphQlPlatformProperties` 가 **`backend.graphql`** prefix 로 `@ConfigurationProperties` 를
|
||||
바인딩한다(`spring.graphql.platform.*` 이 아니다 — 그 prefix 는 존재한 적이 없다).
|
||||
|
||||
```yaml
|
||||
backend:
|
||||
graphql:
|
||||
production: true
|
||||
environment: PRODUCTION_PUBLIC
|
||||
execution-profile: BLOCKING_MVC
|
||||
validation-policy-version: v1 # preparsed 캐시 키의 일부
|
||||
console:
|
||||
graphiql-enabled: false
|
||||
introspection-enabled: false
|
||||
limits:
|
||||
maximum-page-size: 100
|
||||
maximum-complexity: 10000
|
||||
preparsed-cache-entries: 1000
|
||||
preparsed-cache-weight: 10000000
|
||||
preparsed-cache-expire-after-access: 30m
|
||||
cursor:
|
||||
key-ids: [cursor-key-1] # 키 자체는 설정에 오지 않는다
|
||||
observed-operation-names: [] # 비우면 모든 operation 이름이 `other` 로 접힌다
|
||||
```
|
||||
|
||||
`observed-operation-names` 가 비어 있는 것이 기본값이자 안전한 값이다. operation 이름은 문법만
|
||||
검증될 뿐 개수가 제한되지 않으므로, 원본을 그대로 metric label 로 쓰면 정상 클라이언트 하나가
|
||||
metrics 백엔드를 무너뜨릴 수 있다(`observation/GraphQlOperationNameCardinality`).
|
||||
|
||||
`GraphqlHttpBoundaryQualificationTest` 는 실제 random-port MVC HTTP 서버 위에서 test-only
|
||||
SecurityFilterChain 과 CORS allowlist 를 조합해 인증, origin, GraphiQL 비활성화, introspection
|
||||
@@ -109,10 +138,16 @@ composition root 는 이 leaf 를 채택할 때 인증/인가 및 CORS 정책을
|
||||
분해 비용은 낮게 유지했다.
|
||||
|
||||
어느 패턴이든 "패키지는 경계가 아니다"라는 약점은 기계 검증으로 메웠다 —
|
||||
`build/GraphQlStableModule`·`GraphQlAdvancedModule` 이 모듈 정체성과 허용 edge 를 값으로 선언하고,
|
||||
`GraphQlModuleBoundaryTest` 가 **실제 소스 트리를 스캔**해 Stable→Advanced import, core-api 의
|
||||
프레임워크 import, Stable edge 의 Advanced 참조를 실패시킨다. Gradle 이 해주던 일을 테스트가
|
||||
한다.
|
||||
`moduleboundary/GraphQlStableModule`·`GraphQlAdvancedModule` 이 모듈 정체성과 허용 edge 를 값으로
|
||||
선언하고, `GraphQlModuleBoundaryTest` 가 **실제 소스 트리를 스캔**해 Stable→Advanced import,
|
||||
CORE 모듈의 프레임워크 import, 선언되지 않은 edge, 미등록 패키지를 실패시킨다. Gradle 이
|
||||
해주던 일을 테스트가 한다.
|
||||
|
||||
스캔은 컴파일된 클래스가 아니라 **소스 텍스트**를 읽는다. 경계가 금지하는 import 는 상수
|
||||
인라이닝이나 미보존 시그니처로 바이트코드에서 지워지는 경우가 많아서, 바이트코드 스캔은
|
||||
리뷰어가 읽는 소스가 여전히 경계를 넘는데도 clean 이라고 보고한다. 그리고 스캐너는
|
||||
**파일을 하나도 못 찾으면 통과가 아니라 실패한다** — 0개 스캔으로 green 이 되는 것이 이
|
||||
모델이 막으려는 실패 그 자체다.
|
||||
|
||||
## ArchUnit/JPA 없이 아키텍처 규칙을 강제한 방법
|
||||
|
||||
|
||||
@@ -10,27 +10,53 @@
|
||||
// coordinates the BOM does not manage).
|
||||
description = 'Inbound adapter: GraphQL API (Spring for GraphQL, GraphQL execution platform)'
|
||||
|
||||
// The contract suites, the integration fixtures and the in-memory registries are for the people
|
||||
// verifying an adoption, not for the adoption. Shipped in the production jar they were reachable
|
||||
// from any adopter's runtime code — an in-memory persisted-operation registry is a perfectly
|
||||
// working bean until the second instance starts, and a `testContext(String)` mints an authenticated
|
||||
// actor without a credential. A separate test-fixtures artifact keeps them consumable by the tests
|
||||
// that want them and out of the jar that runs in production; `verifyGraphQlProductionJar` checks
|
||||
// the second half rather than trusting it.
|
||||
apply plugin: 'java-test-fixtures'
|
||||
|
||||
apply from: "${rootProject.projectDir}/gradle/strict-qualification-test.gradle"
|
||||
apply from: "${rootProject.projectDir}/gradle/graphql-platform-conventions.gradle"
|
||||
|
||||
dependencies {
|
||||
implementation project(':shared-contract')
|
||||
|
||||
// The fixtures exercise the platform through the same contracts an adopter uses.
|
||||
testFixturesImplementation project(':shared-contract')
|
||||
testFixturesImplementation 'org.springframework.boot:spring-boot-starter-graphql'
|
||||
|
||||
// Transport-neutral on purpose. The platform binds to Spring for GraphQL's execution and
|
||||
// interceptor contracts, and to nothing that decides which server runs them: no production file
|
||||
// imports `org.springframework.web`, `jakarta.servlet` or `org.springframework.http`.
|
||||
//
|
||||
// Depending on `spring-boot-starter-web` here put an embedded Tomcat on every adopter's
|
||||
// runtimeClasspath while the same artifact advertised a REACTIVE_WEBFLUX execution profile —
|
||||
// a profile that could never have run, because the servlet container arrived with the leaf.
|
||||
// Choosing the server is the composition root's decision; this leaf states which profile it was
|
||||
// configured for and refuses to start when the running context disagrees.
|
||||
implementation 'org.springframework.boot:spring-boot-starter-graphql'
|
||||
implementation 'org.springframework.boot:spring-boot-starter-web'
|
||||
|
||||
// GraphQlPlatformProperties is a @ConfigurationProperties binding, so this leaf owes the
|
||||
// repository-wide processor parity gate (`verifyConfigurationPropertiesProcessor`) a metadata
|
||||
// declaration — an adopter configuring spring.graphql.platform.* gets IDE completion and
|
||||
// validation from the generated metadata rather than from prose.
|
||||
// declaration — an adopter configuring backend.graphql.* gets IDE completion and validation
|
||||
// from the generated metadata rather than from prose. (The prefix is `backend.graphql`; this
|
||||
// comment used to say `spring.graphql.platform.*`, which never existed.)
|
||||
annotationProcessor 'org.springframework.boot:spring-boot-configuration-processor'
|
||||
|
||||
// REACTIVE_WEBFLUX execution profile (design §10). WebFlux is compileOnly on purpose: the
|
||||
// reactive transport adapter and its event-loop guard compile against Spring's reactive
|
||||
// transport types, but an adopter that runs the BLOCKING_MVC profile must not inherit a WebFlux
|
||||
// runtime. Reactor Core itself arrives with spring-graphql, so the reactive contracts stay
|
||||
// usable in both profiles. spring-webflux is already on the test classpath.
|
||||
compileOnly 'org.springframework:spring-webflux'
|
||||
// A raw request body can only be capped before something decodes it, and on a servlet stack the
|
||||
// only place that exists is a filter. `compileOnly` is what keeps that from contradicting the
|
||||
// paragraph above: it is the servlet API, not a server, and it stays off runtimeClasspath
|
||||
// entirely — so the filter class simply never loads for an adopter who is not running servlets.
|
||||
compileOnly 'jakarta.servlet:jakarta.servlet-api'
|
||||
|
||||
// The random-port transport tests need a real servlet server; production does not. Keeping the
|
||||
// server on the test classpath is what lets the qualification prove the platform works over
|
||||
// HTTP without shipping that choice to adopters.
|
||||
testImplementation 'org.springframework.boot:spring-boot-starter-web'
|
||||
|
||||
// GraphQlTester (spring-graphql-test, BOM-managed) — the health test assembles the schema +
|
||||
// controller through a real AnnotatedControllerConfigurer and drives it with an
|
||||
@@ -41,6 +67,11 @@ dependencies {
|
||||
// a test-only authentication/CORS composition. Security remains a composition-root concern;
|
||||
// this dependency does not add production security policy to the opt-in GraphQL adapter.
|
||||
testImplementation 'org.springframework.boot:spring-boot-starter-security'
|
||||
|
||||
// A real MeterRegistry, so the cardinality claim is measured rather than asserted. Only
|
||||
// micrometer-observation is on the production classpath; a registry that actually stores series
|
||||
// is what turns "this tag is bounded" into a number a test can fail on.
|
||||
testImplementation 'io.micrometer:micrometer-core'
|
||||
}
|
||||
|
||||
registerGraphQlPlatformTestLanes()
|
||||
@@ -52,3 +83,159 @@ registerStrictQualificationTest(
|
||||
'dev.caskeleton.adapter.inbound.graphql.GraphqlHttpBoundaryQualificationTest'
|
||||
],
|
||||
description: 'Runs exact no-skip GraphQL conditional transport wire evidence.')
|
||||
|
||||
// verifyGraphQlProductionJar — the production artifact must carry nothing a test wrote.
|
||||
//
|
||||
// Moving the testkit into test fixtures is a source-tree decision, and source-tree decisions drift.
|
||||
// One `implementation` where a `testFixturesImplementation` belonged, one file created in the wrong
|
||||
// directory, and the contract suites are back inside the jar an adopter deploys — where an
|
||||
// in-memory persisted-operation registry looks like a working bean until a second instance starts,
|
||||
// and where `testContext(String)` hands out an authenticated actor to anyone who calls it.
|
||||
//
|
||||
// So the claim is checked against the jar rather than against the layout that is supposed to
|
||||
// produce it. Entry names and class names only: this reads the archive index, never the bytecode.
|
||||
tasks.register('verifyGraphQlProductionJar') {
|
||||
group = 'verification'
|
||||
description = 'Fails when the GraphQL production jar contains testkit, fixture or in-memory-only types.'
|
||||
|
||||
dependsOn tasks.named('jar')
|
||||
def jarFile = tasks.named('jar').flatMap { it.archiveFile }
|
||||
inputs.file(jarFile)
|
||||
outputs.upToDateWhen { true }
|
||||
|
||||
doLast {
|
||||
Map<String, String> forbidden = [
|
||||
'/testkit/' : 'contract suites and integration fixtures belong to test fixtures',
|
||||
'InMemory' : 'an in-memory implementation is a development stand-in, not a shipped default',
|
||||
'ForTests' : 'a for-tests factory in the production jar is reachable from production code',
|
||||
'TestContext' : 'a credential-free authenticated context must not ship',
|
||||
'Fixture' : 'fixtures belong to test fixtures',
|
||||
]
|
||||
List<String> violations = []
|
||||
new java.util.zip.ZipFile(jarFile.get().asFile).withCloseable { archive ->
|
||||
archive.entries().each { entry ->
|
||||
if (entry.directory || !entry.name.endsWith('.class')) {
|
||||
return
|
||||
}
|
||||
forbidden.each { marker, reason ->
|
||||
if (entry.name.contains(marker)) {
|
||||
violations << "${entry.name}: ${reason}"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!violations.isEmpty()) {
|
||||
throw new GradleException(
|
||||
"The GraphQL production jar contains non-production types:\n " +
|
||||
violations.sort().join('\n ') +
|
||||
"\nMove them to src/testFixtures/java, or declare them with " +
|
||||
"testFixturesImplementation."
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
tasks.named('check') {
|
||||
dependsOn tasks.named('verifyGraphQlProductionJar')
|
||||
}
|
||||
|
||||
// verifyGraphQlApiSurface — every public type this leaf exposes is a committed decision.
|
||||
//
|
||||
// One jar, 40-odd packages, and a public type in any of them is reachable from every adopter's
|
||||
// code. Package boundaries express the intended structure but enforce nothing across a single
|
||||
// artifact: `public` inside a jar means public to everybody who has the jar. The consequence is not
|
||||
// hypothetical — a package that went missing from a commit was still compiled against by seven
|
||||
// production files, and nothing in the build had an opinion about what the surface was supposed to
|
||||
// be.
|
||||
//
|
||||
// A snapshot does not shrink the surface. It makes each addition visible in review, which is the
|
||||
// prerequisite for shrinking it: the `api` and `spi` packages are the surface an adopter is meant
|
||||
// to use, and everything else in this file is a candidate for becoming internal when the leaf is
|
||||
// split into capability artifacts. Until then the number cannot grow by accident.
|
||||
def graphQlApiSurfaceFile = rootProject.file('../docs/architecture/graphql-api-surface.txt')
|
||||
|
||||
Closure<String> renderGraphQlApiSurface = {
|
||||
def sourceRoot = file('src/main/java')
|
||||
def typePattern = ~/(?m)^public\s+(?:final\s+|abstract\s+|sealed\s+|non-sealed\s+)*(class|interface|enum|record|@interface)\s+(\w+)/
|
||||
def packagePattern = ~/(?m)^package\s+([\w.]+)\s*;/
|
||||
List<String> types = []
|
||||
sourceRoot.eachFileRecurse { candidate ->
|
||||
if (!candidate.isFile() || !candidate.name.endsWith('.java')) {
|
||||
return
|
||||
}
|
||||
String text = candidate.getText('UTF-8')
|
||||
def packageMatcher = packagePattern.matcher(text)
|
||||
if (!packageMatcher.find()) {
|
||||
return
|
||||
}
|
||||
String packageName = packageMatcher.group(1)
|
||||
def typeMatcher = typePattern.matcher(text)
|
||||
while (typeMatcher.find()) {
|
||||
types << "${packageName}.${typeMatcher.group(2)}".toString()
|
||||
}
|
||||
}
|
||||
types = types.unique().toSorted()
|
||||
String header =
|
||||
"# GraphQL leaf public API surface — every public top-level type in src/main/java.\n" +
|
||||
"# A public type in a single-jar leaf is reachable from every adopter's code, so\n" +
|
||||
"# additions are reviewed rather than discovered. `api` and `spi` are the intended\n" +
|
||||
"# external surface; the rest are candidates to become internal when this leaf is\n" +
|
||||
"# split into capability artifacts.\n" +
|
||||
"# Update only after review with:\n" +
|
||||
"# ./gradlew :adapter:inbound:graphql:updateGraphQlApiSurface -PapproveGraphQlApiSurfaceChange\n" +
|
||||
"# types: ${types.size()}\n"
|
||||
header + (types.isEmpty() ? '' : types.join('\n') + '\n')
|
||||
}
|
||||
|
||||
tasks.register('verifyGraphQlApiSurface') {
|
||||
group = 'verification'
|
||||
description = 'Fails without mutation when the committed GraphQL public API surface drifts.'
|
||||
|
||||
doLast {
|
||||
if (project.hasProperty('approveGraphQlApiSurfaceChange')) {
|
||||
throw new GradleException(
|
||||
'verifyGraphQlApiSurface is read-only; use updateGraphQlApiSurface to record an ' +
|
||||
'approved change.')
|
||||
}
|
||||
String rendered = renderGraphQlApiSurface()
|
||||
if (!graphQlApiSurfaceFile.isFile()) {
|
||||
throw new GradleException(
|
||||
"verifyGraphQlApiSurface: missing committed baseline ${graphQlApiSurfaceFile}")
|
||||
}
|
||||
String committed = graphQlApiSurfaceFile.getText('UTF-8')
|
||||
if (committed != rendered) {
|
||||
List<String> committedTypes = committed.readLines().findAll { !it.startsWith('#') }
|
||||
List<String> renderedTypes = rendered.readLines().findAll { !it.startsWith('#') }
|
||||
List<String> added = (renderedTypes - committedTypes).toSorted()
|
||||
List<String> removed = (committedTypes - renderedTypes).toSorted()
|
||||
throw new GradleException(
|
||||
"verifyGraphQlApiSurface: the public API surface changed.\n" +
|
||||
(added.isEmpty() ? '' : " added:\n " + added.join('\n ') + '\n') +
|
||||
(removed.isEmpty() ? '' : " removed:\n " + removed.join('\n ') + '\n') +
|
||||
"Review the change, then record it with:\n" +
|
||||
" ./gradlew :adapter:inbound:graphql:updateGraphQlApiSurface " +
|
||||
"-PapproveGraphQlApiSurfaceChange")
|
||||
}
|
||||
logger.lifecycle('verifyGraphQlApiSurface: OK — the committed public API surface is unchanged.')
|
||||
}
|
||||
}
|
||||
|
||||
tasks.register('updateGraphQlApiSurface') {
|
||||
group = 'verification'
|
||||
description = 'Rewrites the committed GraphQL public API surface baseline after review.'
|
||||
|
||||
doLast {
|
||||
if (!project.hasProperty('approveGraphQlApiSurfaceChange')) {
|
||||
throw new GradleException(
|
||||
'updateGraphQlApiSurface requires -PapproveGraphQlApiSurfaceChange: growing the ' +
|
||||
'public surface is a review decision, not a build step.')
|
||||
}
|
||||
graphQlApiSurfaceFile.parentFile.mkdirs()
|
||||
graphQlApiSurfaceFile.setText(renderGraphQlApiSurface(), 'UTF-8')
|
||||
logger.lifecycle("updateGraphQlApiSurface: wrote ${graphQlApiSurfaceFile}")
|
||||
}
|
||||
}
|
||||
|
||||
tasks.named('check') {
|
||||
dependsOn tasks.named('verifyGraphQlApiSurface')
|
||||
}
|
||||
|
||||
@@ -1,37 +1,37 @@
|
||||
# This is a Gradle generated file for dependency locking.
|
||||
# Manual edits can break the build and are not advised.
|
||||
# This file is expected to be part of source control.
|
||||
biz.aQute.bnd:biz.aQute.bnd.annotation:7.1.0=compileClasspath,testCompileClasspath
|
||||
ch.qos.logback:logback-classic:1.5.21=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
ch.qos.logback:logback-core:1.5.21=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
com.fasterxml.jackson.core:jackson-annotations:2.20=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
com.github.ben-manes.caffeine:caffeine:3.2.3=annotationProcessor,testAnnotationProcessor
|
||||
com.github.kevinstern:software-and-algorithms:1.0=annotationProcessor,testAnnotationProcessor
|
||||
biz.aQute.bnd:biz.aQute.bnd.annotation:7.1.0=compileClasspath,testCompileClasspath,testFixturesCompileClasspath
|
||||
ch.qos.logback:logback-classic:1.5.21=compileClasspath,runtimeClasspath,testCompileClasspath,testFixturesCompileClasspath,testFixturesRuntimeClasspath,testRuntimeClasspath
|
||||
ch.qos.logback:logback-core:1.5.21=compileClasspath,runtimeClasspath,testCompileClasspath,testFixturesCompileClasspath,testFixturesRuntimeClasspath,testRuntimeClasspath
|
||||
com.fasterxml.jackson.core:jackson-annotations:2.20=compileClasspath,runtimeClasspath,testCompileClasspath,testFixturesCompileClasspath,testFixturesRuntimeClasspath,testRuntimeClasspath
|
||||
com.github.ben-manes.caffeine:caffeine:3.2.3=annotationProcessor,testAnnotationProcessor,testFixturesAnnotationProcessor
|
||||
com.github.kevinstern:software-and-algorithms:1.0=annotationProcessor,testAnnotationProcessor,testFixturesAnnotationProcessor
|
||||
com.github.spotbugs:spotbugs-annotations:4.10.2=spotbugs
|
||||
com.github.spotbugs:spotbugs-annotations:4.8.6=compileClasspath,testCompileClasspath
|
||||
com.github.spotbugs:spotbugs-annotations:4.8.6=compileClasspath,testCompileClasspath,testFixturesCompileClasspath
|
||||
com.github.spotbugs:spotbugs:4.10.2=spotbugs
|
||||
com.github.stephenc.jcip:jcip-annotations:1.0-1=spotbugs
|
||||
com.google.auto.service:auto-service-annotations:1.0.1=annotationProcessor,testAnnotationProcessor
|
||||
com.google.auto.value:auto-value-annotations:1.9=annotationProcessor,testAnnotationProcessor
|
||||
com.google.auto:auto-common:1.2.2=annotationProcessor,testAnnotationProcessor
|
||||
com.google.code.findbugs:jsr305:3.0.2=checkstyle,compileClasspath,spotbugs,testCompileClasspath
|
||||
com.google.auto.service:auto-service-annotations:1.0.1=annotationProcessor,testAnnotationProcessor,testFixturesAnnotationProcessor
|
||||
com.google.auto.value:auto-value-annotations:1.9=annotationProcessor,testAnnotationProcessor,testFixturesAnnotationProcessor
|
||||
com.google.auto:auto-common:1.2.2=annotationProcessor,testAnnotationProcessor,testFixturesAnnotationProcessor
|
||||
com.google.code.findbugs:jsr305:3.0.2=checkstyle,compileClasspath,spotbugs,testCompileClasspath,testFixturesCompileClasspath
|
||||
com.google.code.gson:gson:2.13.2=spotbugs
|
||||
com.google.errorprone:error_prone_annotation:2.49.0=annotationProcessor,testAnnotationProcessor
|
||||
com.google.errorprone:error_prone_annotations:2.38.0=compileClasspath,testCompileClasspath
|
||||
com.google.errorprone:error_prone_annotation:2.49.0=annotationProcessor,testAnnotationProcessor,testFixturesAnnotationProcessor
|
||||
com.google.errorprone:error_prone_annotations:2.38.0=compileClasspath,testCompileClasspath,testFixturesCompileClasspath
|
||||
com.google.errorprone:error_prone_annotations:2.41.0=spotbugs
|
||||
com.google.errorprone:error_prone_annotations:2.47.0=checkstyle
|
||||
com.google.errorprone:error_prone_annotations:2.49.0=annotationProcessor,testAnnotationProcessor
|
||||
com.google.errorprone:error_prone_check_api:2.49.0=annotationProcessor,testAnnotationProcessor
|
||||
com.google.errorprone:error_prone_core:2.49.0=annotationProcessor,testAnnotationProcessor
|
||||
com.google.googlejavaformat:google-java-format:1.35.0=annotationProcessor,testAnnotationProcessor
|
||||
com.google.guava:failureaccess:1.0.3=annotationProcessor,checkstyle,testAnnotationProcessor
|
||||
com.google.guava:guava:33.5.0-jre=annotationProcessor,testAnnotationProcessor
|
||||
com.google.errorprone:error_prone_annotations:2.49.0=annotationProcessor,testAnnotationProcessor,testFixturesAnnotationProcessor
|
||||
com.google.errorprone:error_prone_check_api:2.49.0=annotationProcessor,testAnnotationProcessor,testFixturesAnnotationProcessor
|
||||
com.google.errorprone:error_prone_core:2.49.0=annotationProcessor,testAnnotationProcessor,testFixturesAnnotationProcessor
|
||||
com.google.googlejavaformat:google-java-format:1.35.0=annotationProcessor,testAnnotationProcessor,testFixturesAnnotationProcessor
|
||||
com.google.guava:failureaccess:1.0.3=annotationProcessor,checkstyle,testAnnotationProcessor,testFixturesAnnotationProcessor
|
||||
com.google.guava:guava:33.5.0-jre=annotationProcessor,testAnnotationProcessor,testFixturesAnnotationProcessor
|
||||
com.google.guava:guava:33.6.0-jre=checkstyle
|
||||
com.google.guava:listenablefuture:9999.0-empty-to-avoid-conflict-with-guava=annotationProcessor,checkstyle,testAnnotationProcessor
|
||||
com.google.j2objc:j2objc-annotations:3.1=annotationProcessor,checkstyle,testAnnotationProcessor
|
||||
com.google.protobuf:protobuf-java:4.33.2=annotationProcessor,testAnnotationProcessor
|
||||
com.graphql-java:graphql-java:25.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
com.graphql-java:java-dataloader:6.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
com.google.guava:listenablefuture:9999.0-empty-to-avoid-conflict-with-guava=annotationProcessor,checkstyle,testAnnotationProcessor,testFixturesAnnotationProcessor
|
||||
com.google.j2objc:j2objc-annotations:3.1=annotationProcessor,checkstyle,testAnnotationProcessor,testFixturesAnnotationProcessor
|
||||
com.google.protobuf:protobuf-java:4.33.2=annotationProcessor,testAnnotationProcessor,testFixturesAnnotationProcessor
|
||||
com.graphql-java:graphql-java:25.0=compileClasspath,runtimeClasspath,testCompileClasspath,testFixturesCompileClasspath,testFixturesRuntimeClasspath,testRuntimeClasspath
|
||||
com.graphql-java:java-dataloader:6.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testFixturesCompileClasspath,testFixturesRuntimeClasspath,testRuntimeClasspath
|
||||
com.h3xstream.findsecbugs:findsecbugs-plugin:1.14.0=spotbugsPlugins
|
||||
com.jayway.jsonpath:json-path:2.9.0=testCompileClasspath,testRuntimeClasspath
|
||||
com.puppycrawl.tools:checkstyle:13.5.0=checkstyle
|
||||
@@ -39,18 +39,20 @@ com.vaadin.external.google:android-json:0.0.20131108.vaadin1=testCompileClasspat
|
||||
commons-beanutils:commons-beanutils:1.11.0=checkstyle
|
||||
commons-collections:commons-collections:3.2.2=checkstyle
|
||||
commons-io:commons-io:2.21.0=spotbugs
|
||||
commons-logging:commons-logging:1.3.5=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
commons-logging:commons-logging:1.3.5=compileClasspath,runtimeClasspath,testCompileClasspath,testFixturesCompileClasspath,testFixturesRuntimeClasspath,testRuntimeClasspath
|
||||
info.picocli:picocli:4.7.7=checkstyle
|
||||
io.github.eisop:dataflow-errorprone:3.41.0-eisop1=annotationProcessor,testAnnotationProcessor
|
||||
io.github.java-diff-utils:java-diff-utils:4.12=annotationProcessor,testAnnotationProcessor
|
||||
io.micrometer:context-propagation:1.2.0=runtimeClasspath,testRuntimeClasspath
|
||||
io.micrometer:micrometer-commons:1.16.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
io.micrometer:micrometer-observation:1.16.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
io.projectreactor:reactor-core:3.8.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
io.github.eisop:dataflow-errorprone:3.41.0-eisop1=annotationProcessor,testAnnotationProcessor,testFixturesAnnotationProcessor
|
||||
io.github.java-diff-utils:java-diff-utils:4.12=annotationProcessor,testAnnotationProcessor,testFixturesAnnotationProcessor
|
||||
io.micrometer:context-propagation:1.2.0=runtimeClasspath,testFixturesRuntimeClasspath,testRuntimeClasspath
|
||||
io.micrometer:micrometer-commons:1.16.0=compileClasspath,runtimeClasspath,testCompileClasspath,testFixturesCompileClasspath,testFixturesRuntimeClasspath,testRuntimeClasspath
|
||||
io.micrometer:micrometer-core:1.16.0=testCompileClasspath,testRuntimeClasspath
|
||||
io.micrometer:micrometer-observation:1.16.0=compileClasspath,runtimeClasspath,testCompileClasspath,testFixturesCompileClasspath,testFixturesRuntimeClasspath,testRuntimeClasspath
|
||||
io.projectreactor:reactor-core:3.8.0=compileClasspath,runtimeClasspath,testCompileClasspath,testFixturesCompileClasspath,testFixturesRuntimeClasspath,testRuntimeClasspath
|
||||
jakarta.activation:jakarta.activation-api:2.1.4=testCompileClasspath,testRuntimeClasspath
|
||||
jakarta.annotation:jakarta.annotation-api:3.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
jakarta.annotation:jakarta.annotation-api:3.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testFixturesCompileClasspath,testFixturesRuntimeClasspath,testRuntimeClasspath
|
||||
jakarta.servlet:jakarta.servlet-api:6.1.0=compileClasspath
|
||||
jakarta.xml.bind:jakarta.xml.bind-api:4.0.4=testCompileClasspath,testRuntimeClasspath
|
||||
javax.inject:javax.inject:1=annotationProcessor,testAnnotationProcessor
|
||||
javax.inject:javax.inject:1=annotationProcessor,testAnnotationProcessor,testFixturesAnnotationProcessor
|
||||
jaxen:jaxen:2.0.0=spotbugs
|
||||
net.bytebuddy:byte-buddy-agent:1.17.8=testCompileClasspath,testRuntimeClasspath
|
||||
net.bytebuddy:byte-buddy:1.17.8=testCompileClasspath,testRuntimeClasspath
|
||||
@@ -64,16 +66,16 @@ org.apache.commons:commons-text:1.15.0=spotbugs
|
||||
org.apache.commons:commons-text:1.3=checkstyle
|
||||
org.apache.httpcomponents:httpclient:4.5.13=checkstyle
|
||||
org.apache.httpcomponents:httpcore:4.4.16=checkstyle
|
||||
org.apache.logging.log4j:log4j-api:2.25.2=compileClasspath,runtimeClasspath,spotbugs,testCompileClasspath,testRuntimeClasspath
|
||||
org.apache.logging.log4j:log4j-api:2.25.2=compileClasspath,runtimeClasspath,spotbugs,testCompileClasspath,testFixturesCompileClasspath,testFixturesRuntimeClasspath,testRuntimeClasspath
|
||||
org.apache.logging.log4j:log4j-core:2.25.2=spotbugs
|
||||
org.apache.logging.log4j:log4j-to-slf4j:2.25.2=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.apache.logging.log4j:log4j-to-slf4j:2.25.2=compileClasspath,runtimeClasspath,testCompileClasspath,testFixturesCompileClasspath,testFixturesRuntimeClasspath,testRuntimeClasspath
|
||||
org.apache.maven.doxia:doxia-core:1.12.0=checkstyle
|
||||
org.apache.maven.doxia:doxia-logging-api:1.12.0=checkstyle
|
||||
org.apache.maven.doxia:doxia-module-xdoc:1.12.0=checkstyle
|
||||
org.apache.maven.doxia:doxia-sink-api:1.12.0=checkstyle
|
||||
org.apache.tomcat.embed:tomcat-embed-core:11.0.14=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.apache.tomcat.embed:tomcat-embed-el:11.0.14=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.apache.tomcat.embed:tomcat-embed-websocket:11.0.14=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.apache.tomcat.embed:tomcat-embed-core:11.0.14=testCompileClasspath,testRuntimeClasspath
|
||||
org.apache.tomcat.embed:tomcat-embed-el:11.0.14=testCompileClasspath,testRuntimeClasspath
|
||||
org.apache.tomcat.embed:tomcat-embed-websocket:11.0.14=testCompileClasspath,testRuntimeClasspath
|
||||
org.apache.xbean:xbean-reflect:3.7=checkstyle
|
||||
org.apiguardian:apiguardian-api:1.1.2=testCompileClasspath
|
||||
org.assertj:assertj-core:3.27.6=testCompileClasspath,testRuntimeClasspath
|
||||
@@ -84,8 +86,9 @@ org.codehaus.plexus:plexus-container-default:2.1.0=checkstyle
|
||||
org.codehaus.plexus:plexus-utils:3.3.0=checkstyle
|
||||
org.dom4j:dom4j:2.2.0=spotbugs
|
||||
org.hamcrest:hamcrest:3.0=testCompileClasspath,testRuntimeClasspath
|
||||
org.hdrhistogram:HdrHistogram:2.2.2=testRuntimeClasspath
|
||||
org.javassist:javassist:3.28.0-GA=checkstyle
|
||||
org.jspecify:jspecify:1.0.0=annotationProcessor,checkstyle,compileClasspath,runtimeClasspath,testAnnotationProcessor,testCompileClasspath,testRuntimeClasspath
|
||||
org.jspecify:jspecify:1.0.0=annotationProcessor,checkstyle,compileClasspath,runtimeClasspath,testAnnotationProcessor,testCompileClasspath,testFixturesAnnotationProcessor,testFixturesCompileClasspath,testFixturesRuntimeClasspath,testRuntimeClasspath
|
||||
org.junit.jupiter:junit-jupiter-api:6.0.1=testCompileClasspath,testRuntimeClasspath
|
||||
org.junit.jupiter:junit-jupiter-engine:6.0.1=testRuntimeClasspath
|
||||
org.junit.jupiter:junit-jupiter-params:6.0.1=testCompileClasspath,testRuntimeClasspath
|
||||
@@ -95,79 +98,80 @@ org.junit.platform:junit-platform-engine:6.0.1=testRuntimeClasspath
|
||||
org.junit.platform:junit-platform-launcher:6.0.1=testRuntimeClasspath
|
||||
org.junit:junit-bom:6.0.1=testCompileClasspath,testRuntimeClasspath
|
||||
org.junit:junit-bom:6.1.0=spotbugs
|
||||
org.latencyutils:LatencyUtils:2.0.3=testRuntimeClasspath
|
||||
org.mockito:mockito-core:5.20.0=mockitoAgent,testCompileClasspath,testRuntimeClasspath
|
||||
org.mockito:mockito-junit-jupiter:5.20.0=testCompileClasspath,testRuntimeClasspath
|
||||
org.objenesis:objenesis:3.3=testRuntimeClasspath
|
||||
org.opentest4j:opentest4j:1.3.0=testCompileClasspath,testRuntimeClasspath
|
||||
org.osgi:org.osgi.annotation.bundle:2.0.0=compileClasspath,testCompileClasspath
|
||||
org.osgi:org.osgi.annotation.versioning:1.1.2=compileClasspath,testCompileClasspath
|
||||
org.osgi:org.osgi.resource:1.0.0=compileClasspath,testCompileClasspath
|
||||
org.osgi:org.osgi.service.serviceloader:1.0.0=compileClasspath,testCompileClasspath
|
||||
org.osgi:org.osgi.annotation.bundle:2.0.0=compileClasspath,testCompileClasspath,testFixturesCompileClasspath
|
||||
org.osgi:org.osgi.annotation.versioning:1.1.2=compileClasspath,testCompileClasspath,testFixturesCompileClasspath
|
||||
org.osgi:org.osgi.resource:1.0.0=compileClasspath,testCompileClasspath,testFixturesCompileClasspath
|
||||
org.osgi:org.osgi.service.serviceloader:1.0.0=compileClasspath,testCompileClasspath,testFixturesCompileClasspath
|
||||
org.ow2.asm:asm-analysis:9.10.1=spotbugs
|
||||
org.ow2.asm:asm-commons:9.10.1=spotbugs
|
||||
org.ow2.asm:asm-tree:9.10.1=spotbugs
|
||||
org.ow2.asm:asm-util:9.10.1=spotbugs
|
||||
org.ow2.asm:asm:9.10.1=spotbugs
|
||||
org.ow2.asm:asm:9.7.1=testCompileClasspath,testRuntimeClasspath
|
||||
org.pcollections:pcollections:4.0.1=annotationProcessor,testAnnotationProcessor
|
||||
org.reactivestreams:reactive-streams:1.0.4=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.pcollections:pcollections:4.0.1=annotationProcessor,testAnnotationProcessor,testFixturesAnnotationProcessor
|
||||
org.reactivestreams:reactive-streams:1.0.4=compileClasspath,runtimeClasspath,testCompileClasspath,testFixturesCompileClasspath,testFixturesRuntimeClasspath,testRuntimeClasspath
|
||||
org.reflections:reflections:0.10.2=checkstyle
|
||||
org.skyscreamer:jsonassert:1.5.3=testCompileClasspath,testRuntimeClasspath
|
||||
org.slf4j:jul-to-slf4j:2.0.17=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.slf4j:slf4j-api:2.0.17=compileClasspath,runtimeClasspath,spotbugs,spotbugsSlf4j,testCompileClasspath,testRuntimeClasspath
|
||||
org.slf4j:jul-to-slf4j:2.0.17=compileClasspath,runtimeClasspath,testCompileClasspath,testFixturesCompileClasspath,testFixturesRuntimeClasspath,testRuntimeClasspath
|
||||
org.slf4j:slf4j-api:2.0.17=compileClasspath,runtimeClasspath,spotbugs,spotbugsSlf4j,testCompileClasspath,testFixturesCompileClasspath,testFixturesRuntimeClasspath,testRuntimeClasspath
|
||||
org.slf4j:slf4j-simple:2.0.17=checkstyle,spotbugsSlf4j
|
||||
org.springframework.boot:spring-boot-autoconfigure:4.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-autoconfigure:4.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testFixturesCompileClasspath,testFixturesRuntimeClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-configuration-processor:4.0.0=annotationProcessor
|
||||
org.springframework.boot:spring-boot-graphql-test:4.0.0=testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-graphql:4.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-graphql:4.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testFixturesCompileClasspath,testFixturesRuntimeClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-http-client:4.0.0=testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-http-codec:4.0.0=testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-http-converter:4.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-jackson:4.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-http-converter:4.0.0=testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-jackson:4.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testFixturesCompileClasspath,testFixturesRuntimeClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-restclient:4.0.0=testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-resttestclient:4.0.0=testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-security:4.0.0=testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-servlet:4.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-servlet:4.0.0=testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-starter-graphql-test:4.0.0=testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-starter-graphql:4.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-starter-graphql:4.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testFixturesCompileClasspath,testFixturesRuntimeClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-starter-jackson-test:4.0.0=testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-starter-jackson:4.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-starter-logging:4.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-starter-jackson:4.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testFixturesCompileClasspath,testFixturesRuntimeClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-starter-logging:4.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testFixturesCompileClasspath,testFixturesRuntimeClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-starter-security:4.0.0=testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-starter-test:4.0.0=testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-starter-tomcat-runtime:4.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-starter-tomcat:4.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-starter-web:4.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-starter-tomcat-runtime:4.0.0=testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-starter-tomcat:4.0.0=testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-starter-web:4.0.0=testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-starter-webmvc-test:4.0.0=testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-starter-webmvc:4.0.0=testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-starter:4.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-starter:4.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testFixturesCompileClasspath,testFixturesRuntimeClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-test-autoconfigure:4.0.0=testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-test:4.0.0=testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-tomcat:4.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-web-server:4.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-tomcat:4.0.0=testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-web-server:4.0.0=testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-webmvc-test:4.0.0=testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-webmvc:4.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-webmvc:4.0.0=testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-webtestclient:4.0.0=testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot:4.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot:4.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testFixturesCompileClasspath,testFixturesRuntimeClasspath,testRuntimeClasspath
|
||||
org.springframework.graphql:spring-graphql-test:2.0.0=testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.graphql:spring-graphql:2.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.graphql:spring-graphql:2.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testFixturesCompileClasspath,testFixturesRuntimeClasspath,testRuntimeClasspath
|
||||
org.springframework.security:spring-security-config:7.0.0=testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.security:spring-security-core:7.0.0=testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.security:spring-security-crypto:7.0.0=testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.security:spring-security-web:7.0.0=testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework:spring-aop:7.0.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework:spring-beans:7.0.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework:spring-context:7.0.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework:spring-core:7.0.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework:spring-expression:7.0.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework:spring-aop:7.0.1=compileClasspath,runtimeClasspath,testCompileClasspath,testFixturesCompileClasspath,testFixturesRuntimeClasspath,testRuntimeClasspath
|
||||
org.springframework:spring-beans:7.0.1=compileClasspath,runtimeClasspath,testCompileClasspath,testFixturesCompileClasspath,testFixturesRuntimeClasspath,testRuntimeClasspath
|
||||
org.springframework:spring-context:7.0.1=compileClasspath,runtimeClasspath,testCompileClasspath,testFixturesCompileClasspath,testFixturesRuntimeClasspath,testRuntimeClasspath
|
||||
org.springframework:spring-core:7.0.1=compileClasspath,runtimeClasspath,testCompileClasspath,testFixturesCompileClasspath,testFixturesRuntimeClasspath,testRuntimeClasspath
|
||||
org.springframework:spring-expression:7.0.1=compileClasspath,runtimeClasspath,testCompileClasspath,testFixturesCompileClasspath,testFixturesRuntimeClasspath,testRuntimeClasspath
|
||||
org.springframework:spring-test:7.0.1=testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework:spring-web:7.0.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework:spring-webflux:7.0.1=compileClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework:spring-webmvc:7.0.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework:spring-web:7.0.1=testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework:spring-webflux:7.0.1=testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework:spring-webmvc:7.0.1=testCompileClasspath,testRuntimeClasspath
|
||||
org.xmlresolver:xmlresolver:5.3.3=checkstyle,spotbugs
|
||||
org.xmlunit:xmlunit-core:2.10.4=testCompileClasspath,testRuntimeClasspath
|
||||
org.yaml:snakeyaml:2.5=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
tools.jackson.core:jackson-core:3.0.2=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
tools.jackson.core:jackson-databind:3.0.2=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
tools.jackson:jackson-bom:3.0.2=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.yaml:snakeyaml:2.5=compileClasspath,runtimeClasspath,testCompileClasspath,testFixturesCompileClasspath,testFixturesRuntimeClasspath,testRuntimeClasspath
|
||||
tools.jackson.core:jackson-core:3.0.2=compileClasspath,runtimeClasspath,testCompileClasspath,testFixturesCompileClasspath,testFixturesRuntimeClasspath,testRuntimeClasspath
|
||||
tools.jackson.core:jackson-databind:3.0.2=compileClasspath,runtimeClasspath,testCompileClasspath,testFixturesCompileClasspath,testFixturesRuntimeClasspath,testRuntimeClasspath
|
||||
tools.jackson:jackson-bom:3.0.2=compileClasspath,runtimeClasspath,testCompileClasspath,testFixturesCompileClasspath,testFixturesRuntimeClasspath,testRuntimeClasspath
|
||||
empty=
|
||||
|
||||
-72
@@ -1,72 +0,0 @@
|
||||
package dev.caskeleton.adapter.inbound.graphql;
|
||||
|
||||
import dev.caskeleton.shared.error.ApiErrorCarrier;
|
||||
import dev.caskeleton.shared.error.ApiErrorCode;
|
||||
import dev.caskeleton.shared.error.Category;
|
||||
import graphql.GraphQLError;
|
||||
import graphql.GraphqlErrorBuilder;
|
||||
import graphql.schema.DataFetchingEnvironment;
|
||||
import java.util.Map;
|
||||
import org.springframework.graphql.execution.DataFetcherExceptionResolverAdapter;
|
||||
import org.springframework.graphql.execution.ErrorType;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
/**
|
||||
* Centralises the GraphQL error contract: a data fetcher just throws, and this resolver translates
|
||||
* any throwable carrying a stable {@link ApiErrorCode} (via the shared-contract {@link
|
||||
* ApiErrorCarrier} hook) into a {@link GraphQLError} with an {@link ErrorType} classification plus
|
||||
* machine-readable {@code code} / {@code category} extensions — the GraphQL sibling of the web
|
||||
* adapter's {@code GlobalExceptionHandler} and the gRPC adapter's {@code
|
||||
* GrpcExceptionHandlingInterceptor}.
|
||||
*
|
||||
* <p>The {@link ApiErrorCarrier} hook is implemented by the shared-contract {@code
|
||||
* PersistenceFailureException} / {@code DependencyFailureException} (an error surfacing from an
|
||||
* outbound adapter) and by feature throwables (which carry a mapped domain {@code ApiErrorCode}),
|
||||
* so a single {@code instanceof ApiErrorCarrier} branch covers them all. A non-carrier throwable
|
||||
* returns {@code null}: Spring for GraphQL then merges the other {@link
|
||||
* org.springframework.graphql.execution.DataFetcherExceptionResolver} beans (e.g. a feature's own
|
||||
* resolver mapping its domain exceptions) and finally its default handling. Only the stable {@link
|
||||
* ApiErrorCode#code()} reaches the client — never the raw exception message, which may carry a
|
||||
* SQLState or upstream detail.
|
||||
*/
|
||||
@Component
|
||||
public class GraphqlExceptionResolver extends DataFetcherExceptionResolverAdapter {
|
||||
|
||||
@Override
|
||||
protected GraphQLError resolveToSingleError(Throwable ex, DataFetchingEnvironment env) {
|
||||
if (!(ex instanceof ApiErrorCarrier carrier)) {
|
||||
return null; // fall through to other resolvers / Spring's default handling
|
||||
}
|
||||
ApiErrorCode code = carrier.errorCode();
|
||||
var builder =
|
||||
GraphqlErrorBuilder.newError()
|
||||
.errorType(classify(code.category()))
|
||||
.message(code.code())
|
||||
.extensions(Map.of("code", code.code(), "category", code.category().name()));
|
||||
// A real GraphQL execution always supplies the environment; a unit test may pass null. Only
|
||||
// attach the field path/location when they are present.
|
||||
if (env != null) {
|
||||
builder.path(env.getExecutionStepInfo().getPath());
|
||||
if (env.getField() != null) {
|
||||
builder.location(env.getField().getSourceLocation());
|
||||
}
|
||||
}
|
||||
return builder.build();
|
||||
}
|
||||
|
||||
/**
|
||||
* Maps the 10-value operational {@link Category} SSOT to a GraphQL {@link ErrorType} (design
|
||||
* Error-Mapping table). The switch is exhaustive, so a new {@link Category} fails to compile
|
||||
* until a mapping decision is made.
|
||||
*/
|
||||
private static ErrorType classify(Category category) {
|
||||
return switch (category) {
|
||||
case VALIDATION, CONFLICT, RATE_LIMIT -> ErrorType.BAD_REQUEST;
|
||||
case AUTH -> ErrorType.UNAUTHORIZED;
|
||||
case AUTHZ -> ErrorType.FORBIDDEN;
|
||||
case NOT_FOUND -> ErrorType.NOT_FOUND;
|
||||
case TRANSIENT_DEPENDENCY, PERMANENT_DEPENDENCY, DATA_INTEGRITY, INTERNAL ->
|
||||
ErrorType.INTERNAL_ERROR;
|
||||
};
|
||||
}
|
||||
}
|
||||
+37
@@ -0,0 +1,37 @@
|
||||
package dev.caskeleton.adapter.inbound.graphql.advanced.admin;
|
||||
|
||||
import java.util.Objects;
|
||||
|
||||
/**
|
||||
* A verified administrator, as the transport established them.
|
||||
*
|
||||
* <p>The admin service used to take the operator as a bare {@code String} and check it against an
|
||||
* allowlist. A string is not evidence: any caller that could reach the service could name any
|
||||
* operator on the list, so the allowlist described who <em>may</em> administer the registry while
|
||||
* proving nothing about who actually did. The audit trail then recorded that name as fact.
|
||||
*
|
||||
* <p>Only the transport can construct this, having verified the credential, and it records whether
|
||||
* the credential came from the request path. That check used to exist as a method nobody called.
|
||||
*
|
||||
* @param operator the verified operator reference
|
||||
* @param applicationCredential whether the credential is one the request path also holds
|
||||
*/
|
||||
public record GraphQlAdminPrincipal(String operator, boolean applicationCredential) {
|
||||
|
||||
public GraphQlAdminPrincipal {
|
||||
Objects.requireNonNull(operator, "operator is required");
|
||||
if (operator.isBlank()) {
|
||||
throw new IllegalArgumentException("operator is required");
|
||||
}
|
||||
}
|
||||
|
||||
/** A principal established from a dedicated operations credential. */
|
||||
public static GraphQlAdminPrincipal operations(String operator) {
|
||||
return new GraphQlAdminPrincipal(operator, false);
|
||||
}
|
||||
|
||||
/** A principal established from a credential the request path also holds. */
|
||||
public static GraphQlAdminPrincipal fromApplicationCredential(String operator) {
|
||||
return new GraphQlAdminPrincipal(operator, true);
|
||||
}
|
||||
}
|
||||
+11
-15
@@ -26,24 +26,20 @@ public final class GraphQlPersistedOperationAdminAuthorization {
|
||||
}
|
||||
|
||||
/**
|
||||
* Requires the operator to be an administrator.
|
||||
* Requires a verified administrator holding an operations credential.
|
||||
*
|
||||
* @throws GraphQlPersistedOperationAdminDeniedException when they are not
|
||||
*/
|
||||
public void requireAdministrator(String operator) {
|
||||
if (operator == null || !administrators.contains(operator)) {
|
||||
throw new GraphQlPersistedOperationAdminDeniedException();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Refuses an application credential outright.
|
||||
* <p>Both halves are checked here now. The credential-kind refusal used to be a separate public
|
||||
* method that no caller invoked, so an application credential naming an allowlisted operator
|
||||
* passed — which is the compromise this class exists to prevent, arriving through the door it
|
||||
* documented.
|
||||
*
|
||||
* @throws GraphQlPersistedOperationAdminDeniedException when the caller came from the request
|
||||
* path
|
||||
* @throws GraphQlPersistedOperationAdminDeniedException when the principal is absent, not an
|
||||
* administrator, or authenticated with a credential the request path also holds
|
||||
*/
|
||||
public void rejectApplicationCredential(boolean applicationCredential) {
|
||||
if (applicationCredential) {
|
||||
public void requireAdministrator(GraphQlAdminPrincipal principal) {
|
||||
if (principal == null
|
||||
|| principal.applicationCredential()
|
||||
|| !administrators.contains(principal.operator())) {
|
||||
throw new GraphQlPersistedOperationAdminDeniedException();
|
||||
}
|
||||
}
|
||||
|
||||
+47
@@ -0,0 +1,47 @@
|
||||
package dev.caskeleton.adapter.inbound.graphql.advanced.admin;
|
||||
|
||||
import dev.caskeleton.adapter.inbound.graphql.advanced.persisted.GraphQlPersistedOperation;
|
||||
import dev.caskeleton.adapter.inbound.graphql.advanced.persisted.GraphQlPersistedOperationId;
|
||||
import dev.caskeleton.adapter.inbound.graphql.advanced.persisted.GraphQlPersistedOperationTransition;
|
||||
|
||||
/**
|
||||
* Applies a registry change and records it, as one durable unit.
|
||||
*
|
||||
* <p>The two used to be separate steps: the registry was mutated, then an entry was appended to an
|
||||
* {@code ArrayList} field. A crash between them left a change nobody could account for, a failure
|
||||
* in the append left a change with no record, and the list itself was not thread-safe, so two
|
||||
* concurrent administrators could lose an entry outright. An audit trail with any of those
|
||||
* properties is worse than none, because it is trusted.
|
||||
*
|
||||
* <p>A port rather than an implementation: transactional guarantees over both the registry and the
|
||||
* trail need a store, and which store provides them is a deployment decision.
|
||||
*/
|
||||
public interface GraphQlPersistedOperationAdminPort {
|
||||
|
||||
/**
|
||||
* Registers an operation and records it atomically.
|
||||
*
|
||||
* @return the stored operation
|
||||
* @throws dev.caskeleton.adapter.inbound.graphql.advanced.persisted
|
||||
* .GraphQlPersistedOperationConflictException when the id already holds a different document
|
||||
*/
|
||||
GraphQlPersistedOperation register(
|
||||
GraphQlPersistedOperation operation, GraphQlPersistedOperationAudit audit);
|
||||
|
||||
/**
|
||||
* Applies a transition and records it atomically.
|
||||
*
|
||||
* @return the operation as it now stands
|
||||
* @throws dev.caskeleton.adapter.inbound.graphql.advanced.persisted
|
||||
* .GraphQlPersistedOperationNotFoundException when the operation does not exist
|
||||
* @throws dev.caskeleton.adapter.inbound.graphql.advanced.persisted
|
||||
* .GraphQlPersistedOperationConflictException when the transition is not permitted
|
||||
*/
|
||||
GraphQlPersistedOperation apply(
|
||||
GraphQlPersistedOperationId operationId,
|
||||
GraphQlPersistedOperationTransition transition,
|
||||
GraphQlPersistedOperationAudit audit);
|
||||
|
||||
/** The recorded trail, oldest first. */
|
||||
java.util.List<GraphQlPersistedOperationAudit> auditTrail();
|
||||
}
|
||||
+92
-63
@@ -2,53 +2,59 @@ package dev.caskeleton.adapter.inbound.graphql.advanced.admin;
|
||||
|
||||
import dev.caskeleton.adapter.inbound.graphql.advanced.persisted.GraphQlPersistedOperation;
|
||||
import dev.caskeleton.adapter.inbound.graphql.advanced.persisted.GraphQlPersistedOperationId;
|
||||
import dev.caskeleton.adapter.inbound.graphql.advanced.persisted.GraphQlPersistedOperationRegistry;
|
||||
import dev.caskeleton.adapter.inbound.graphql.advanced.persisted.GraphQlPersistedOperationStatus;
|
||||
import dev.caskeleton.adapter.inbound.graphql.advanced.persisted.GraphQlPersistedOperationTransition;
|
||||
import java.time.Clock;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
|
||||
/**
|
||||
* The G4 operations plane for approved operations (Advanced plan Task 4).
|
||||
*
|
||||
* <p>Every change is authorized against the administrator set and recorded in the audit trail,
|
||||
* because a registry change silently alters what the whole platform will execute. Blocking takes
|
||||
* effect immediately; removal has to pass the usage gate first.
|
||||
* <p>Every change is authorized against a verified principal and recorded in the same durable unit
|
||||
* as the change itself, because a registry change silently alters what the whole platform will
|
||||
* execute. Blocking takes effect immediately; retiring has to pass the usage gate first.
|
||||
*
|
||||
* <p>Every command returns the stored operation. A command that cannot be applied throws, so the
|
||||
* audit trail records changes that happened rather than changes that were attempted — the previous
|
||||
* service wrote {@code ABSENT -> BLOCKED} for operations that did not exist, which is precisely the
|
||||
* entry an operator would trust during an incident.
|
||||
*/
|
||||
public final class GraphQlPersistedOperationAdminService {
|
||||
|
||||
private final GraphQlPersistedOperationRegistry registry;
|
||||
private final GraphQlPersistedOperationAdminPort adminPort;
|
||||
private final GraphQlPersistedOperationAdminAuthorization authorization;
|
||||
private final GraphQlPersistedOperationRemovalGate removalGate;
|
||||
private final Clock clock;
|
||||
private final List<GraphQlPersistedOperationAudit> auditTrail = new ArrayList<>();
|
||||
|
||||
/**
|
||||
* Creates the service.
|
||||
*
|
||||
* @param registry the approved operation store
|
||||
* @param authorization who may administer it
|
||||
* @param removalGate the usage gate protecting removals
|
||||
* @param adminPort applies registry changes and their audit entries atomically
|
||||
* @param authorization who may administer the registry
|
||||
* @param removalGate the usage gate protecting retirement
|
||||
* @param clock clock used for audit timestamps and the quiet period
|
||||
*/
|
||||
public GraphQlPersistedOperationAdminService(
|
||||
GraphQlPersistedOperationRegistry registry,
|
||||
GraphQlPersistedOperationAdminPort adminPort,
|
||||
GraphQlPersistedOperationAdminAuthorization authorization,
|
||||
GraphQlPersistedOperationRemovalGate removalGate,
|
||||
Clock clock) {
|
||||
this.registry = Objects.requireNonNull(registry);
|
||||
this.adminPort = Objects.requireNonNull(adminPort);
|
||||
this.authorization = Objects.requireNonNull(authorization);
|
||||
this.removalGate = Objects.requireNonNull(removalGate);
|
||||
this.clock = Objects.requireNonNull(clock);
|
||||
}
|
||||
|
||||
/** Registers a new approved operation. */
|
||||
public void register(
|
||||
GraphQlPersistedOperation operation, String operator, String reason, String traceId) {
|
||||
authorization.requireAdministrator(operator);
|
||||
registry.register(operation);
|
||||
audit(operation.id().value(), operator, reason, "ABSENT", operation.status().name(), traceId);
|
||||
public GraphQlPersistedOperation register(
|
||||
GraphQlPersistedOperation operation,
|
||||
GraphQlAdminPrincipal principal,
|
||||
String reason,
|
||||
String traceId) {
|
||||
authorization.requireAdministrator(principal);
|
||||
return adminPort.register(
|
||||
operation,
|
||||
audit(operation.id(), principal, reason, "ABSENT", operation.status().name(), traceId));
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -56,77 +62,100 @@ public final class GraphQlPersistedOperationAdminService {
|
||||
*
|
||||
* <p>Takes effect on the next request; a cached parse does not keep it executable.
|
||||
*/
|
||||
public void block(GraphQlPersistedOperationBlockCommand command) {
|
||||
authorization.requireAdministrator(command.operator());
|
||||
String before =
|
||||
registry
|
||||
.find(command.operationId())
|
||||
.map(operation -> operation.status().name())
|
||||
.orElse("ABSENT");
|
||||
registry.updateStatus(command.operationId(), GraphQlPersistedOperationStatus.BLOCKED);
|
||||
audit(
|
||||
command.operationId().value(),
|
||||
command.operator(),
|
||||
public GraphQlPersistedOperation block(
|
||||
GraphQlPersistedOperationBlockCommand command, GraphQlAdminPrincipal principal) {
|
||||
return transition(
|
||||
command.operationId(),
|
||||
GraphQlPersistedOperationTransition.BLOCK,
|
||||
principal,
|
||||
command.reason(),
|
||||
before,
|
||||
GraphQlPersistedOperationStatus.BLOCKED.name(),
|
||||
command.traceId());
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverses a block.
|
||||
*
|
||||
* <p>Its own command, so leaving the emergency state is an explicit decision with its own audit
|
||||
* entry. It used to be reachable by marking a blocked operation deprecated, which reads like a
|
||||
* documentation change and made it executable again.
|
||||
*/
|
||||
public GraphQlPersistedOperation unblock(
|
||||
GraphQlPersistedOperationId operationId,
|
||||
GraphQlAdminPrincipal principal,
|
||||
String reason,
|
||||
String traceId) {
|
||||
return transition(
|
||||
operationId, GraphQlPersistedOperationTransition.UNBLOCK, principal, reason, traceId);
|
||||
}
|
||||
|
||||
/** Marks an operation deprecated, which keeps it executable while clients migrate. */
|
||||
public void deprecate(
|
||||
GraphQlPersistedOperationId operationId, String operator, String reason, String traceId) {
|
||||
authorization.requireAdministrator(operator);
|
||||
String before =
|
||||
registry.find(operationId).map(operation -> operation.status().name()).orElse("ABSENT");
|
||||
registry.updateStatus(operationId, GraphQlPersistedOperationStatus.DEPRECATED);
|
||||
audit(
|
||||
operationId.value(),
|
||||
operator,
|
||||
reason,
|
||||
before,
|
||||
GraphQlPersistedOperationStatus.DEPRECATED.name(),
|
||||
traceId);
|
||||
public GraphQlPersistedOperation deprecate(
|
||||
GraphQlPersistedOperationId operationId,
|
||||
GraphQlAdminPrincipal principal,
|
||||
String reason,
|
||||
String traceId) {
|
||||
return transition(
|
||||
operationId, GraphQlPersistedOperationTransition.DEPRECATE, principal, reason, traceId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes an operation once usage evidence permits it.
|
||||
* Retires an operation once usage evidence permits it.
|
||||
*
|
||||
* <p>Named for what it does. It was called {@code remove}, and it blocked rather than deleted —
|
||||
* an operator reading the method name would have believed the document was gone.
|
||||
*
|
||||
* @throws GraphQlPersistedOperationRemovalRejectedException when it was used within the quiet
|
||||
* period
|
||||
*/
|
||||
public void remove(
|
||||
public GraphQlPersistedOperation retireAndBlock(
|
||||
GraphQlPersistedOperationId operationId,
|
||||
GraphQlPersistedOperationUsage usage,
|
||||
String operator,
|
||||
GraphQlAdminPrincipal principal,
|
||||
String reason,
|
||||
String traceId) {
|
||||
authorization.requireAdministrator(operator);
|
||||
authorization.requireAdministrator(principal);
|
||||
removalGate.verify(usage, clock.instant());
|
||||
registry.updateStatus(operationId, GraphQlPersistedOperationStatus.BLOCKED);
|
||||
audit(
|
||||
operationId.value(),
|
||||
operator,
|
||||
reason,
|
||||
"REMOVAL_APPROVED",
|
||||
GraphQlPersistedOperationStatus.BLOCKED.name(),
|
||||
traceId);
|
||||
return transition(
|
||||
operationId, GraphQlPersistedOperationTransition.RETIRE, principal, reason, traceId);
|
||||
}
|
||||
|
||||
/** The audit trail, in order. */
|
||||
public List<GraphQlPersistedOperationAudit> auditTrail() {
|
||||
return List.copyOf(auditTrail);
|
||||
return adminPort.auditTrail();
|
||||
}
|
||||
|
||||
private void audit(
|
||||
String operationId,
|
||||
String operator,
|
||||
private GraphQlPersistedOperation transition(
|
||||
GraphQlPersistedOperationId operationId,
|
||||
GraphQlPersistedOperationTransition transition,
|
||||
GraphQlAdminPrincipal principal,
|
||||
String reason,
|
||||
String traceId) {
|
||||
|
||||
authorization.requireAdministrator(principal);
|
||||
// The audit entry names the state the operation is actually leaving. The port applies both
|
||||
// together, so a rejected transition leaves no entry at all.
|
||||
GraphQlPersistedOperation updated =
|
||||
adminPort.apply(
|
||||
operationId,
|
||||
transition,
|
||||
audit(
|
||||
operationId,
|
||||
principal,
|
||||
reason,
|
||||
transition.name(),
|
||||
transition.target().name(),
|
||||
traceId));
|
||||
return updated;
|
||||
}
|
||||
|
||||
private GraphQlPersistedOperationAudit audit(
|
||||
GraphQlPersistedOperationId operationId,
|
||||
GraphQlAdminPrincipal principal,
|
||||
String reason,
|
||||
String before,
|
||||
String after,
|
||||
String traceId) {
|
||||
auditTrail.add(
|
||||
new GraphQlPersistedOperationAudit(
|
||||
operationId, operator, reason, before, after, clock.instant(), traceId));
|
||||
return new GraphQlPersistedOperationAudit(
|
||||
operationId.value(), principal.operator(), reason, before, after, clock.instant(), traceId);
|
||||
}
|
||||
}
|
||||
|
||||
-3
@@ -26,9 +26,6 @@ public enum GraphQlAdvancedCapability {
|
||||
/** Client and transport DTO code generation. */
|
||||
CODE_GENERATION(GraphQlAdvancedCapabilityGrade.ADVANCED),
|
||||
|
||||
/** Allowlisted Spring Data repository exposure. */
|
||||
SPRING_DATA_COMPAT(GraphQlAdvancedCapabilityGrade.ADVANCED),
|
||||
|
||||
/** GraphQL Java 25 chained DataLoader dispatch. */
|
||||
DATALOADER_CHAINING(GraphQlAdvancedCapabilityGrade.ADVANCED),
|
||||
|
||||
|
||||
+6
-4
@@ -1,6 +1,8 @@
|
||||
package dev.caskeleton.adapter.inbound.graphql.advanced.bootstrap;
|
||||
|
||||
import dev.caskeleton.adapter.inbound.graphql.build.GraphQlBuildModel;
|
||||
import dev.caskeleton.adapter.inbound.graphql.moduleboundary.GraphQlAdvancedModule;
|
||||
import dev.caskeleton.adapter.inbound.graphql.moduleboundary.GraphQlModuleBoundary;
|
||||
import dev.caskeleton.adapter.inbound.graphql.moduleboundary.GraphQlStableModule;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
@@ -23,9 +25,9 @@ public final class GraphQlAdvancedDependencyRules {
|
||||
* @throws IllegalStateException naming the offending edges
|
||||
*/
|
||||
public static void verifyStableDoesNotDependOnAdvanced() {
|
||||
Set<String> advanced = GraphQlBuildModel.advancedModules();
|
||||
Set<String> advanced = GraphQlAdvancedModule.moduleIds();
|
||||
List<String> violations = new ArrayList<>();
|
||||
GraphQlBuildModel.stableDependencyEdges()
|
||||
GraphQlStableModule.dependencyEdges()
|
||||
.forEach(
|
||||
(module, dependencies) ->
|
||||
dependencies.stream()
|
||||
@@ -40,7 +42,7 @@ public final class GraphQlAdvancedDependencyRules {
|
||||
/** Whether a package belongs to an Advanced capability. */
|
||||
public static boolean advancedPackage(String packageName) {
|
||||
return packageName != null
|
||||
&& packageName.startsWith(GraphQlBuildModel.PACKAGE_ROOT + ".advanced");
|
||||
&& packageName.startsWith(GraphQlModuleBoundary.PACKAGE_ROOT + ".advanced");
|
||||
}
|
||||
|
||||
/** Every capability that must be flagged before it can run. */
|
||||
|
||||
+24
-12
@@ -1,13 +1,17 @@
|
||||
package dev.caskeleton.adapter.inbound.graphql.advanced.codegen;
|
||||
|
||||
import dev.caskeleton.adapter.inbound.graphql.compat.GraphQlSchemaComparator;
|
||||
import java.util.Objects;
|
||||
|
||||
/**
|
||||
* Generates client request and response types, validating operations against the schema first.
|
||||
* Plans client code generation and validates the operations it would generate from.
|
||||
*
|
||||
* <p>Compile-time validation is most of the value: an operation that no longer matches the schema
|
||||
* becomes a build failure in the client's repository instead of a runtime error in production.
|
||||
* <p>Named a plan because that is what it produces: which kinds may be generated and into which
|
||||
* package. No source writer and no Gradle task exist behind it, and calling it a generator invited
|
||||
* the reasonable assumption that running it emitted files.
|
||||
*
|
||||
* <p>Validation is the part that does real work, and it is most of the value: an operation that no
|
||||
* longer matches the schema becomes a build failure in the client's repository instead of a runtime
|
||||
* error in production.
|
||||
*/
|
||||
public final class GraphQlClientOperationGenerator {
|
||||
|
||||
@@ -27,18 +31,26 @@ public final class GraphQlClientOperationGenerator {
|
||||
*
|
||||
* @param sdl the schema
|
||||
* @param operationDocument the operation to validate
|
||||
* @throws GraphQlCodegenBoundaryException when either is missing
|
||||
* @throws GraphQlCodegenBoundaryException when the document does not parse or does not match
|
||||
*/
|
||||
public void validateOperation(String sdl, String operationDocument) {
|
||||
if (sdl == null || sdl.isBlank() || operationDocument == null || operationDocument.isBlank()) {
|
||||
throw new GraphQlCodegenBoundaryException("CLIENT_REQUEST");
|
||||
}
|
||||
// Parsing the schema is what makes generation fail on an invalid schema rather than emitting
|
||||
// sources against one.
|
||||
GraphQlSchemaComparator.compare(sdl, sdl);
|
||||
validateOperation(sdl, operationDocument, null);
|
||||
}
|
||||
|
||||
/** The kinds this generator produces. */
|
||||
/**
|
||||
* Validates one named operation from a document.
|
||||
*
|
||||
* @param sdl the schema
|
||||
* @param operationDocument the operation document
|
||||
* @param operationName which operation, when the document declares several
|
||||
* @throws GraphQlCodegenBoundaryException when the document does not parse, does not match the
|
||||
* schema, or names no such operation
|
||||
*/
|
||||
public void validateOperation(String sdl, String operationDocument, String operationName) {
|
||||
GraphQlOperationValidator.validate(sdl, operationDocument, operationName);
|
||||
}
|
||||
|
||||
/** The kinds this plan covers. */
|
||||
public java.util.Set<String> generatedTypes() {
|
||||
profile.generatedTypes().forEach(GraphQlGeneratedSourceBoundary.standard()::requireAllowed);
|
||||
return profile.generatedTypes();
|
||||
|
||||
+111
@@ -0,0 +1,111 @@
|
||||
package dev.caskeleton.adapter.inbound.graphql.advanced.codegen;
|
||||
|
||||
import graphql.language.Definition;
|
||||
import graphql.language.Document;
|
||||
import graphql.language.OperationDefinition;
|
||||
import graphql.parser.InvalidSyntaxException;
|
||||
import graphql.parser.Parser;
|
||||
import graphql.schema.GraphQLSchema;
|
||||
import graphql.schema.idl.RuntimeWiring;
|
||||
import graphql.schema.idl.SchemaGenerator;
|
||||
import graphql.schema.idl.SchemaParser;
|
||||
import graphql.schema.idl.TypeDefinitionRegistry;
|
||||
import graphql.schema.idl.errors.SchemaProblem;
|
||||
import graphql.validation.ValidationError;
|
||||
import graphql.validation.Validator;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
|
||||
/**
|
||||
* Validates a client operation against the schema it will be compiled for.
|
||||
*
|
||||
* <p>The previous check confirmed both strings were non-blank and then compared the schema with
|
||||
* itself, which is true of every schema. The operation document was never read, so a document with
|
||||
* invalid syntax, an unknown field, or an argument that does not exist passed validation and became
|
||||
* generated client code that fails at runtime — in the client's repository, against a schema that
|
||||
* had already changed.
|
||||
*
|
||||
* <p>The schema is compiled to an executable {@link GraphQLSchema} because that is what the
|
||||
* validator needs: field and argument existence, type compatibility and variable usage are
|
||||
* questions about types, and a parsed SDL registry alone cannot answer them.
|
||||
*/
|
||||
public final class GraphQlOperationValidator {
|
||||
|
||||
private GraphQlOperationValidator() {}
|
||||
|
||||
/**
|
||||
* Validates one operation document against a schema.
|
||||
*
|
||||
* @param sdl the schema, as SDL
|
||||
* @param operationDocument the client operation
|
||||
* @param operationName the operation to validate when the document declares several, or {@code
|
||||
* null} when it declares one
|
||||
* @throws GraphQlCodegenBoundaryException when either input is missing, the schema does not
|
||||
* compile, the document does not parse, the document does not match the schema, or the
|
||||
* operation to validate is ambiguous
|
||||
*/
|
||||
public static void validate(String sdl, String operationDocument, String operationName) {
|
||||
if (sdl == null || sdl.isBlank() || operationDocument == null || operationDocument.isBlank()) {
|
||||
throw new GraphQlCodegenBoundaryException("CLIENT_REQUEST");
|
||||
}
|
||||
|
||||
GraphQLSchema schema = compile(sdl);
|
||||
Document document = parse(operationDocument);
|
||||
requireUnambiguousOperation(document, operationName);
|
||||
|
||||
List<ValidationError> errors = new Validator().validateDocument(schema, document, Locale.ROOT);
|
||||
if (!errors.isEmpty()) {
|
||||
List<String> messages = new ArrayList<>();
|
||||
errors.forEach(error -> messages.add(error.getMessage()));
|
||||
throw new GraphQlCodegenBoundaryException("CLIENT_REQUEST: " + String.join("; ", messages));
|
||||
}
|
||||
}
|
||||
|
||||
private static GraphQLSchema compile(String sdl) {
|
||||
try {
|
||||
TypeDefinitionRegistry registry = new SchemaParser().parse(sdl);
|
||||
return new SchemaGenerator().makeExecutableSchema(registry, RuntimeWiring.MOCKED_WIRING);
|
||||
} catch (SchemaProblem | InvalidSyntaxException invalid) {
|
||||
throw new GraphQlCodegenBoundaryException("CLIENT_REQUEST: schema does not compile");
|
||||
}
|
||||
}
|
||||
|
||||
private static Document parse(String operationDocument) {
|
||||
try {
|
||||
return Parser.parse(operationDocument);
|
||||
} catch (InvalidSyntaxException invalid) {
|
||||
throw new GraphQlCodegenBoundaryException("CLIENT_REQUEST: operation does not parse");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Refuses a document whose operation cannot be identified.
|
||||
*
|
||||
* <p>Generating from a multi-operation document without being told which one means picking by
|
||||
* position, and the generated client then changes meaning when someone reorders the file.
|
||||
*/
|
||||
private static void requireUnambiguousOperation(Document document, String operationName) {
|
||||
List<OperationDefinition> operations = new ArrayList<>();
|
||||
for (Definition<?> definition : document.getDefinitions()) {
|
||||
if (definition instanceof OperationDefinition operation) {
|
||||
operations.add(operation);
|
||||
}
|
||||
}
|
||||
if (operations.isEmpty()) {
|
||||
throw new GraphQlCodegenBoundaryException("CLIENT_REQUEST: document declares no operation");
|
||||
}
|
||||
if (operationName == null || operationName.isBlank()) {
|
||||
if (operations.size() > 1) {
|
||||
throw new GraphQlCodegenBoundaryException(
|
||||
"CLIENT_REQUEST: document declares " + operations.size() + " operations");
|
||||
}
|
||||
return;
|
||||
}
|
||||
boolean found = operations.stream().anyMatch(op -> operationName.equals(op.getName()));
|
||||
if (!found) {
|
||||
throw new GraphQlCodegenBoundaryException(
|
||||
"CLIENT_REQUEST: no operation named " + operationName);
|
||||
}
|
||||
}
|
||||
}
|
||||
-39
@@ -1,39 +0,0 @@
|
||||
package dev.caskeleton.adapter.inbound.graphql.advanced.compat;
|
||||
|
||||
import java.util.Set;
|
||||
|
||||
/**
|
||||
* The repositories permitted to back GraphQL fields.
|
||||
*
|
||||
* <p>Empty by default. Spring Data's automatic exposure is convenient and turns filter, sort and
|
||||
* pagination semantics into public API the moment it is switched on — the allowlist is what makes
|
||||
* each of those a decision.
|
||||
*/
|
||||
public final class GraphQlRepositoryAllowlist {
|
||||
|
||||
private final Set<String> repositoryNames;
|
||||
|
||||
private GraphQlRepositoryAllowlist(Set<String> repositoryNames) {
|
||||
this.repositoryNames = Set.copyOf(repositoryNames);
|
||||
}
|
||||
|
||||
/** Nothing exposed. */
|
||||
public static GraphQlRepositoryAllowlist empty() {
|
||||
return new GraphQlRepositoryAllowlist(Set.of());
|
||||
}
|
||||
|
||||
/** The named repositories exposed. */
|
||||
public static GraphQlRepositoryAllowlist of(String... repositoryNames) {
|
||||
return new GraphQlRepositoryAllowlist(Set.of(repositoryNames));
|
||||
}
|
||||
|
||||
/** Whether a repository is allowlisted. */
|
||||
public boolean contains(String repositoryName) {
|
||||
return repositoryNames.contains(repositoryName);
|
||||
}
|
||||
|
||||
/** The allowlisted repositories. */
|
||||
public Set<String> repositoryNames() {
|
||||
return repositoryNames;
|
||||
}
|
||||
}
|
||||
-40
@@ -1,40 +0,0 @@
|
||||
package dev.caskeleton.adapter.inbound.graphql.advanced.compat;
|
||||
|
||||
import java.util.Set;
|
||||
import java.util.TreeSet;
|
||||
|
||||
/**
|
||||
* Which filter and sort arguments an exposed repository accepts.
|
||||
*
|
||||
* <p>Enumerated, because automatic exposure turns GraphQL arguments into Querydsl predicates: an
|
||||
* unlisted argument becomes a query nobody designed, against a column that may have no index and
|
||||
* may not be meant to be filterable at all.
|
||||
*
|
||||
* @param allowedFilterFields fields that may be filtered on
|
||||
* @param allowedSortFields fields that may be sorted by
|
||||
*/
|
||||
public record GraphQlRepositoryArgumentPolicy(
|
||||
Set<String> allowedFilterFields, Set<String> allowedSortFields) {
|
||||
|
||||
public GraphQlRepositoryArgumentPolicy {
|
||||
allowedFilterFields = Set.copyOf(allowedFilterFields);
|
||||
allowedSortFields = Set.copyOf(allowedSortFields);
|
||||
}
|
||||
|
||||
/**
|
||||
* Verifies the arguments a request supplied.
|
||||
*
|
||||
* @throws GraphQlRepositoryExposureRejectedException naming the unlisted arguments
|
||||
*/
|
||||
public void verify(Set<String> filterFields, Set<String> sortFields) {
|
||||
var rejected = new TreeSet<String>();
|
||||
filterFields.stream()
|
||||
.filter(field -> !allowedFilterFields.contains(field))
|
||||
.forEach(rejected::add);
|
||||
sortFields.stream().filter(field -> !allowedSortFields.contains(field)).forEach(rejected::add);
|
||||
if (!rejected.isEmpty()) {
|
||||
throw new GraphQlRepositoryExposureRejectedException(
|
||||
"unlisted filter or sort fields " + rejected);
|
||||
}
|
||||
}
|
||||
}
|
||||
-19
@@ -1,19 +0,0 @@
|
||||
package dev.caskeleton.adapter.inbound.graphql.advanced.compat;
|
||||
|
||||
/**
|
||||
* One repository exposed at one schema coordinate.
|
||||
*
|
||||
* @param repositoryName the repository
|
||||
* @param schemaCoordinate the field it backs
|
||||
*/
|
||||
public record GraphQlRepositoryExposure(String repositoryName, String schemaCoordinate) {
|
||||
|
||||
public GraphQlRepositoryExposure {
|
||||
if (repositoryName == null || repositoryName.isBlank()) {
|
||||
throw new IllegalArgumentException("repository name is required");
|
||||
}
|
||||
if (schemaCoordinate == null || schemaCoordinate.isBlank()) {
|
||||
throw new IllegalArgumentException("schema coordinate is required");
|
||||
}
|
||||
}
|
||||
}
|
||||
-21
@@ -1,21 +0,0 @@
|
||||
package dev.caskeleton.adapter.inbound.graphql.advanced.compat;
|
||||
|
||||
/**
|
||||
* Raised when a repository would be exposed without being allowlisted.
|
||||
*
|
||||
* <p>Automatic exposure turns a repository into a public API the moment it is annotated, so the
|
||||
* default has to be refusal rather than registration.
|
||||
*/
|
||||
public class GraphQlRepositoryExposureRejectedException extends RuntimeException {
|
||||
|
||||
private static final long serialVersionUID = 1L;
|
||||
|
||||
/**
|
||||
* Creates the failure.
|
||||
*
|
||||
* @param repositoryName the repository that is not allowlisted
|
||||
*/
|
||||
public GraphQlRepositoryExposureRejectedException(String repositoryName) {
|
||||
super("repository is not allowlisted for GraphQL exposure: " + repositoryName);
|
||||
}
|
||||
}
|
||||
-58
@@ -1,58 +0,0 @@
|
||||
package dev.caskeleton.adapter.inbound.graphql.advanced.compat;
|
||||
|
||||
import java.util.Objects;
|
||||
|
||||
/**
|
||||
* Refuses repository exposure that was not deliberately configured (Advanced plan Task 15).
|
||||
*
|
||||
* <p>This is a compatibility path, not the mainstream API. The Stable route is a resolver calling
|
||||
* an Application use case; automatic exposure exists for the cases where that is genuinely not
|
||||
* worth writing, and it stays behind an allowlist, an argument policy, an explicit pagination
|
||||
* choice and an approved projection.
|
||||
*/
|
||||
public final class GraphQlRepositoryExposureValidator {
|
||||
|
||||
private final GraphQlRepositoryAllowlist allowlist;
|
||||
|
||||
/**
|
||||
* Creates the validator.
|
||||
*
|
||||
* @param allowlist repositories permitted to be exposed
|
||||
*/
|
||||
public GraphQlRepositoryExposureValidator(GraphQlRepositoryAllowlist allowlist) {
|
||||
this.allowlist = Objects.requireNonNull(allowlist);
|
||||
}
|
||||
|
||||
/**
|
||||
* Verifies a repository may be exposed.
|
||||
*
|
||||
* @throws GraphQlRepositoryExposureRejectedException when it is not allowlisted
|
||||
*/
|
||||
public void verify(GraphQlRepositoryExposure exposure) {
|
||||
if (!allowlist.contains(exposure.repositoryName())) {
|
||||
throw new GraphQlRepositoryExposureRejectedException(exposure.repositoryName());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Verifies the full exposure configuration.
|
||||
*
|
||||
* @param exposure the repository and coordinate
|
||||
* @param pagination the pagination policy
|
||||
* @param projection the projection policy
|
||||
* @throws GraphQlRepositoryExposureRejectedException when anything was left to default
|
||||
*/
|
||||
public void verifyConfiguration(
|
||||
GraphQlRepositoryExposure exposure,
|
||||
GraphQlRepositoryPaginationPolicy pagination,
|
||||
GraphQlRepositoryProjectionPolicy projection) {
|
||||
|
||||
verify(exposure);
|
||||
if (pagination.implicitSpringDataDefault()) {
|
||||
throw new GraphQlRepositoryExposureRejectedException(
|
||||
exposure.repositoryName()
|
||||
+ " relies on the implicit offset pagination default; choose a pagination policy");
|
||||
}
|
||||
Objects.requireNonNull(projection, "an approved projection is required");
|
||||
}
|
||||
}
|
||||
-35
@@ -1,35 +0,0 @@
|
||||
package dev.caskeleton.adapter.inbound.graphql.advanced.compat;
|
||||
|
||||
/**
|
||||
* Pagination for an exposed repository, stated rather than inherited.
|
||||
*
|
||||
* <p>Spring Data's automatic exposure paginates by offset, twenty at a time, unless told otherwise.
|
||||
* Both defaults are decisions: offset pagination skips and repeats rows under concurrent writes,
|
||||
* and a page size that arrived by default is one nobody chose.
|
||||
*
|
||||
* @param keysetPagination whether keyset pagination is used instead of offset
|
||||
* @param defaultPageSize page size when the client asks for none
|
||||
* @param maximumPageSize largest page size the client may ask for
|
||||
*/
|
||||
public record GraphQlRepositoryPaginationPolicy(
|
||||
boolean keysetPagination, int defaultPageSize, int maximumPageSize) {
|
||||
|
||||
/** The default Spring Data behaviour, which this platform requires to be chosen explicitly. */
|
||||
public static final int SPRING_DATA_DEFAULT_PAGE_SIZE = 20;
|
||||
|
||||
public GraphQlRepositoryPaginationPolicy {
|
||||
if (defaultPageSize < 1 || maximumPageSize < defaultPageSize) {
|
||||
throw new IllegalArgumentException("invalid repository pagination policy");
|
||||
}
|
||||
}
|
||||
|
||||
/** An explicitly chosen keyset policy. */
|
||||
public static GraphQlRepositoryPaginationPolicy keyset(int defaultPageSize, int maximumPageSize) {
|
||||
return new GraphQlRepositoryPaginationPolicy(true, defaultPageSize, maximumPageSize);
|
||||
}
|
||||
|
||||
/** Whether this policy merely restates Spring Data's defaults rather than choosing them. */
|
||||
public boolean implicitSpringDataDefault() {
|
||||
return !keysetPagination && defaultPageSize == SPRING_DATA_DEFAULT_PAGE_SIZE;
|
||||
}
|
||||
}
|
||||
-39
@@ -1,39 +0,0 @@
|
||||
package dev.caskeleton.adapter.inbound.graphql.advanced.compat;
|
||||
|
||||
import java.util.Set;
|
||||
|
||||
/**
|
||||
* Which projection an exposed repository returns.
|
||||
*
|
||||
* <p>Never the entity or document itself. Returning one exposes every persistence field as API —
|
||||
* including the ones added later, by someone who had no idea this repository was reachable from
|
||||
* GraphQL.
|
||||
*
|
||||
* @param projectionType the approved projection type name
|
||||
* @param exposedFields fields the projection exposes
|
||||
*/
|
||||
public record GraphQlRepositoryProjectionPolicy(String projectionType, Set<String> exposedFields) {
|
||||
|
||||
public GraphQlRepositoryProjectionPolicy {
|
||||
if (projectionType == null || projectionType.isBlank()) {
|
||||
throw new IllegalArgumentException("an approved projection type is required");
|
||||
}
|
||||
exposedFields = Set.copyOf(exposedFields);
|
||||
if (exposedFields.isEmpty()) {
|
||||
throw new IllegalArgumentException("a projection must expose at least one field");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Verifies the projection is not a persistence type.
|
||||
*
|
||||
* @param persistenceTypeNames entity and document type names
|
||||
* @throws GraphQlRepositoryExposureRejectedException when the projection is one of them
|
||||
*/
|
||||
public void verifyNotPersistenceType(Set<String> persistenceTypeNames) {
|
||||
if (persistenceTypeNames.contains(projectionType)) {
|
||||
throw new GraphQlRepositoryExposureRejectedException(
|
||||
projectionType + " is a persistence type and must not be returned directly");
|
||||
}
|
||||
}
|
||||
}
|
||||
+25
@@ -0,0 +1,25 @@
|
||||
package dev.caskeleton.adapter.inbound.graphql.advanced.persisted;
|
||||
|
||||
/**
|
||||
* Raised when an admin command names an operation the registry does not hold.
|
||||
*
|
||||
* <p>A distinct failure from a conflict: "there is nothing here" and "you cannot do that from here"
|
||||
* lead an operator to different next steps, and the admin plane used to report neither — an unknown
|
||||
* id produced a successful-looking audit entry.
|
||||
*/
|
||||
public class GraphQlPersistedOperationNotFoundException extends RuntimeException {
|
||||
|
||||
private static final long serialVersionUID = 1L;
|
||||
|
||||
/** Stable error code. */
|
||||
public static final String CODE = "GRAPHQL_PERSISTED_OPERATION_NOT_FOUND";
|
||||
|
||||
/**
|
||||
* Creates the failure.
|
||||
*
|
||||
* @param operationId the operation that does not exist
|
||||
*/
|
||||
public GraphQlPersistedOperationNotFoundException(String operationId) {
|
||||
super(CODE + ": " + operationId);
|
||||
}
|
||||
}
|
||||
+123
@@ -0,0 +1,123 @@
|
||||
package dev.caskeleton.adapter.inbound.graphql.advanced.persisted;
|
||||
|
||||
import dev.caskeleton.shared.opstore.OperationalRecord;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
import java.util.Set;
|
||||
|
||||
/**
|
||||
* Translates a persisted operation to and from a neutral operational record.
|
||||
*
|
||||
* <p>The whole point of the split. Durable storage for persisted operations has to live in
|
||||
* infrastructure, and infrastructure must not implement a type that belongs to an inbound transport
|
||||
* — a Postgres adapter implementing {@code GraphQlPersistedOperationRegistry} would point the
|
||||
* dependency from the database back at the GraphQL boundary. So the store speaks {@link
|
||||
* OperationalRecord} and knows nothing about GraphQL, and this class is the only place that knows
|
||||
* both vocabularies.
|
||||
*
|
||||
* <p>The encoding is length-framed rather than delimited. A canonical document contains newlines,
|
||||
* an operation name is client-influenced, and a client profile is a free-form string; any delimiter
|
||||
* chosen from those alphabets is a delimiter a value can contain, and the field after it is then
|
||||
* read as something else. Framing each field by its length removes the question.
|
||||
*/
|
||||
public final class GraphQlPersistedOperationRecordMapping {
|
||||
|
||||
/** The key space this capability owns in the operational store. */
|
||||
public static final String NAMESPACE = "graphql.persisted-operation";
|
||||
|
||||
private static final int FIELDS = 9;
|
||||
|
||||
private GraphQlPersistedOperationRecordMapping() {}
|
||||
|
||||
/**
|
||||
* The store key for an operation id.
|
||||
*
|
||||
* @param id the operation id
|
||||
*/
|
||||
public static String keyFor(GraphQlPersistedOperationId id) {
|
||||
Objects.requireNonNull(id, "persisted operation id is required");
|
||||
return id.value();
|
||||
}
|
||||
|
||||
/**
|
||||
* Encodes an operation as a neutral record.
|
||||
*
|
||||
* @param operation the operation to store
|
||||
* @param version the version the caller read, for the store's compare-and-set
|
||||
*/
|
||||
public static OperationalRecord toRecord(GraphQlPersistedOperation operation, long version) {
|
||||
Objects.requireNonNull(operation, "persisted operation is required");
|
||||
StringBuilder encoded = new StringBuilder();
|
||||
write(encoded, operation.id().value());
|
||||
write(encoded, operation.operationName());
|
||||
write(encoded, operation.documentHash());
|
||||
write(encoded, operation.canonicalDocument());
|
||||
write(encoded, operation.schemaContractHash());
|
||||
write(encoded, String.join(",", operation.allowedClientProfiles()));
|
||||
write(encoded, Long.toString(operation.maximumComplexity()));
|
||||
write(encoded, Integer.toString(operation.maximumVariablesBytes()));
|
||||
write(encoded, operation.status().name());
|
||||
return new OperationalRecord(NAMESPACE, keyFor(operation.id()), encoded.toString(), version);
|
||||
}
|
||||
|
||||
/**
|
||||
* Decodes an operation from a neutral record.
|
||||
*
|
||||
* @param record the stored record
|
||||
* @throws IllegalArgumentException when the record is not a persisted operation of this shape
|
||||
*/
|
||||
public static GraphQlPersistedOperation fromRecord(OperationalRecord record) {
|
||||
Objects.requireNonNull(record, "operational record is required");
|
||||
if (!NAMESPACE.equals(record.namespace())) {
|
||||
throw new IllegalArgumentException("operational record belongs to another namespace");
|
||||
}
|
||||
List<String> fields = readAll(record.value());
|
||||
if (fields.size() != FIELDS) {
|
||||
throw new IllegalArgumentException("stored persisted operation has an unexpected shape");
|
||||
}
|
||||
Set<String> profiles = new LinkedHashSet<>();
|
||||
if (!fields.get(5).isEmpty()) {
|
||||
profiles.addAll(List.of(fields.get(5).split(",", -1)));
|
||||
}
|
||||
return new GraphQlPersistedOperation(
|
||||
new GraphQlPersistedOperationId(fields.get(0)),
|
||||
fields.get(1),
|
||||
fields.get(2),
|
||||
fields.get(3),
|
||||
fields.get(4),
|
||||
profiles,
|
||||
Long.parseLong(fields.get(6)),
|
||||
Integer.parseInt(fields.get(7)),
|
||||
GraphQlPersistedOperationStatus.valueOf(fields.get(8)));
|
||||
}
|
||||
|
||||
private static void write(StringBuilder out, String value) {
|
||||
out.append(value.length()).append(':').append(value);
|
||||
}
|
||||
|
||||
private static List<String> readAll(String encoded) {
|
||||
List<String> fields = new java.util.ArrayList<>();
|
||||
int cursor = 0;
|
||||
while (cursor < encoded.length()) {
|
||||
int separator = encoded.indexOf(':', cursor);
|
||||
if (separator < 0) {
|
||||
throw new IllegalArgumentException("stored persisted operation is not length-framed");
|
||||
}
|
||||
int length;
|
||||
try {
|
||||
length = Integer.parseInt(encoded.substring(cursor, separator));
|
||||
} catch (NumberFormatException malformed) {
|
||||
throw new IllegalArgumentException("stored persisted operation has an invalid frame");
|
||||
}
|
||||
int start = separator + 1;
|
||||
int end = start + length;
|
||||
if (length < 0 || end > encoded.length()) {
|
||||
throw new IllegalArgumentException("stored persisted operation frame runs past its value");
|
||||
}
|
||||
fields.add(encoded.substring(start, end));
|
||||
cursor = end;
|
||||
}
|
||||
return List.copyOf(fields);
|
||||
}
|
||||
}
|
||||
+11
-3
@@ -23,9 +23,17 @@ public interface GraphQlPersistedOperationRegistry {
|
||||
Optional<GraphQlPersistedOperation> find(GraphQlPersistedOperationId id);
|
||||
|
||||
/**
|
||||
* Replaces an operation's lifecycle state.
|
||||
* Applies a lifecycle transition and returns the operation as it now stands.
|
||||
*
|
||||
* <p>Used by the admin plane to block an operation during an incident.
|
||||
* <p>Returning the record, and failing when there is nothing to change, is what makes an audit
|
||||
* entry trustworthy. The previous {@code updateStatus} was a no-op for an unknown id, so the
|
||||
* admin plane recorded {@code ABSENT -> BLOCKED} as a successful incident response for an
|
||||
* operation that had never existed — the one entry an operator would rely on afterwards.
|
||||
*
|
||||
* @throws GraphQlPersistedOperationNotFoundException when the operation does not exist
|
||||
* @throws GraphQlPersistedOperationConflictException when the transition is not permitted from
|
||||
* the operation's current state
|
||||
*/
|
||||
void updateStatus(GraphQlPersistedOperationId id, GraphQlPersistedOperationStatus status);
|
||||
GraphQlPersistedOperation apply(
|
||||
GraphQlPersistedOperationId id, GraphQlPersistedOperationTransition transition);
|
||||
}
|
||||
|
||||
+80
@@ -0,0 +1,80 @@
|
||||
package dev.caskeleton.adapter.inbound.graphql.advanced.persisted;
|
||||
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
|
||||
/**
|
||||
* Which lifecycle changes the registry will accept.
|
||||
*
|
||||
* <p>Without a table, {@code updateStatus} accepted anything, and the sequence that mattered was
|
||||
* {@code BLOCKED → DEPRECATED}: an operation stopped during an incident could be made executable
|
||||
* again by a status change that read like a documentation update. Blocking is the emergency
|
||||
* control, so leaving it is the transition that has to be deliberate.
|
||||
*
|
||||
* <p>{@code BLOCKED} is therefore terminal except through {@link #UNBLOCK}, which exists precisely
|
||||
* so that reversing an incident block is its own audited command rather than a side effect of
|
||||
* something else.
|
||||
*/
|
||||
public enum GraphQlPersistedOperationTransition {
|
||||
|
||||
/** First registration of an operation. */
|
||||
REGISTER,
|
||||
|
||||
/** Marks an operation deprecated while clients migrate. It stays executable. */
|
||||
DEPRECATE,
|
||||
|
||||
/** Stops an operation immediately. */
|
||||
BLOCK,
|
||||
|
||||
/** Reverses a block, deliberately and with its own audit entry. */
|
||||
UNBLOCK,
|
||||
|
||||
/** Retires an operation: it is blocked and no longer offered. */
|
||||
RETIRE;
|
||||
|
||||
private static final Map<
|
||||
GraphQlPersistedOperationTransition, Set<GraphQlPersistedOperationStatus>>
|
||||
ALLOWED_FROM =
|
||||
Map.of(
|
||||
DEPRECATE,
|
||||
Set.of(
|
||||
GraphQlPersistedOperationStatus.ACTIVE,
|
||||
GraphQlPersistedOperationStatus.DEPRECATED),
|
||||
BLOCK,
|
||||
Set.of(
|
||||
GraphQlPersistedOperationStatus.ACTIVE,
|
||||
GraphQlPersistedOperationStatus.DEPRECATED),
|
||||
UNBLOCK,
|
||||
Set.of(GraphQlPersistedOperationStatus.BLOCKED),
|
||||
RETIRE,
|
||||
Set.of(
|
||||
GraphQlPersistedOperationStatus.ACTIVE,
|
||||
GraphQlPersistedOperationStatus.DEPRECATED,
|
||||
GraphQlPersistedOperationStatus.BLOCKED));
|
||||
|
||||
/** The state this transition leaves the operation in. */
|
||||
public GraphQlPersistedOperationStatus target() {
|
||||
return switch (this) {
|
||||
case REGISTER, UNBLOCK -> GraphQlPersistedOperationStatus.ACTIVE;
|
||||
case DEPRECATE -> GraphQlPersistedOperationStatus.DEPRECATED;
|
||||
case BLOCK, RETIRE -> GraphQlPersistedOperationStatus.BLOCKED;
|
||||
};
|
||||
}
|
||||
|
||||
/** Whether this transition may be applied to an operation currently in the given state. */
|
||||
public boolean allowedFrom(GraphQlPersistedOperationStatus current) {
|
||||
return this != REGISTER && ALLOWED_FROM.getOrDefault(this, Set.of()).contains(current);
|
||||
}
|
||||
|
||||
/**
|
||||
* Verifies the transition.
|
||||
*
|
||||
* @throws GraphQlPersistedOperationConflictException when the change is not permitted from here
|
||||
*/
|
||||
public void verify(GraphQlPersistedOperationId id, GraphQlPersistedOperationStatus current) {
|
||||
if (!allowedFrom(current)) {
|
||||
throw new GraphQlPersistedOperationConflictException(
|
||||
id.value() + " cannot go " + current + " -> " + name());
|
||||
}
|
||||
}
|
||||
}
|
||||
+96
@@ -0,0 +1,96 @@
|
||||
package dev.caskeleton.adapter.inbound.graphql.advanced.persisted;
|
||||
|
||||
import dev.caskeleton.shared.opstore.OperationalRecord;
|
||||
import dev.caskeleton.shared.opstore.OperationalRecordConflictException;
|
||||
import dev.caskeleton.shared.opstore.OperationalRecordStorePort;
|
||||
import java.util.Objects;
|
||||
import java.util.Optional;
|
||||
|
||||
/**
|
||||
* The persisted-operation registry, backed by whatever durable store the deployment provides.
|
||||
*
|
||||
* <p>This is the registry an adopter runs. The dependency points from here to a neutral contract in
|
||||
* {@code shared-contract}, and the durable implementation of that contract points at the same
|
||||
* contract from the other side — so a Postgres or Redis adapter never names a GraphQL type, and
|
||||
* this leaf never names a datastore. The composition root connects the two and owns no policy.
|
||||
*
|
||||
* <p>Registration and transitions are compare-and-set against the version this registry read. Two
|
||||
* admin planes blocking and approving the same operation a second apart used to resolve by arrival
|
||||
* order, and the loser left no trace; now the loser is told.
|
||||
*/
|
||||
public final class OperationalStoreGraphQlPersistedOperationRegistry
|
||||
implements GraphQlPersistedOperationRegistry {
|
||||
|
||||
private final OperationalRecordStorePort store;
|
||||
|
||||
/**
|
||||
* Creates the registry.
|
||||
*
|
||||
* @param store the deployment's durable operational store
|
||||
*/
|
||||
public OperationalStoreGraphQlPersistedOperationRegistry(OperationalRecordStorePort store) {
|
||||
this.store = Objects.requireNonNull(store, "operational record store is required");
|
||||
}
|
||||
|
||||
@Override
|
||||
public void register(GraphQlPersistedOperation operation) {
|
||||
Objects.requireNonNull(operation, "persisted operation is required");
|
||||
String key = GraphQlPersistedOperationRecordMapping.keyFor(operation.id());
|
||||
Optional<OperationalRecord> stored =
|
||||
store.find(GraphQlPersistedOperationRecordMapping.NAMESPACE, key);
|
||||
if (stored.isPresent()) {
|
||||
GraphQlPersistedOperation existing =
|
||||
GraphQlPersistedOperationRecordMapping.fromRecord(stored.get());
|
||||
if (!existing.canonicalDocument().equals(operation.canonicalDocument())) {
|
||||
throw new GraphQlPersistedOperationConflictException(operation.id().value());
|
||||
}
|
||||
return;
|
||||
}
|
||||
try {
|
||||
store.compareAndSet(
|
||||
GraphQlPersistedOperationRecordMapping.toRecord(
|
||||
operation, OperationalRecord.ABSENT_VERSION),
|
||||
OperationalRecord.ABSENT_VERSION);
|
||||
} catch (OperationalRecordConflictException lost) {
|
||||
// Another instance registered the same id between the read and the write. That is a conflict
|
||||
// in exactly the sense this capability already has a name for.
|
||||
throw new GraphQlPersistedOperationConflictException(operation.id().value());
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public Optional<GraphQlPersistedOperation> find(GraphQlPersistedOperationId id) {
|
||||
Objects.requireNonNull(id, "persisted operation id is required");
|
||||
return store
|
||||
.find(
|
||||
GraphQlPersistedOperationRecordMapping.NAMESPACE,
|
||||
GraphQlPersistedOperationRecordMapping.keyFor(id))
|
||||
.map(GraphQlPersistedOperationRecordMapping::fromRecord);
|
||||
}
|
||||
|
||||
@Override
|
||||
public GraphQlPersistedOperation apply(
|
||||
GraphQlPersistedOperationId id, GraphQlPersistedOperationTransition transition) {
|
||||
|
||||
Objects.requireNonNull(id, "persisted operation id is required");
|
||||
Objects.requireNonNull(transition, "transition is required");
|
||||
OperationalRecord stored =
|
||||
store
|
||||
.find(
|
||||
GraphQlPersistedOperationRecordMapping.NAMESPACE,
|
||||
GraphQlPersistedOperationRecordMapping.keyFor(id))
|
||||
.orElseThrow(() -> new GraphQlPersistedOperationNotFoundException(id.value()));
|
||||
|
||||
GraphQlPersistedOperation current = GraphQlPersistedOperationRecordMapping.fromRecord(stored);
|
||||
transition.verify(id, current.status());
|
||||
GraphQlPersistedOperation next = current.withStatus(transition.target());
|
||||
try {
|
||||
store.compareAndSet(
|
||||
GraphQlPersistedOperationRecordMapping.toRecord(next, stored.version() + 1),
|
||||
stored.version());
|
||||
} catch (OperationalRecordConflictException lost) {
|
||||
throw new GraphQlPersistedOperationConflictException(id.value());
|
||||
}
|
||||
return next;
|
||||
}
|
||||
}
|
||||
+19
-5
@@ -5,9 +5,14 @@ import dev.caskeleton.adapter.inbound.graphql.advanced.security.GraphQlWebSocket
|
||||
/**
|
||||
* Authorizes a replay before any history is delivered.
|
||||
*
|
||||
* <p>Replay reads the past, so the check is stricter than for a live subscription: the actor
|
||||
* resuming must be the actor the cursor was issued to, and must still be authorized now — access
|
||||
* granted when the events were produced may since have been withdrawn.
|
||||
* <p>Replay reads the past, so the check is stricter than for a live subscription: the tenant and
|
||||
* actor resuming must be the ones the cursor was issued to, and must still be authorized now —
|
||||
* access granted when the events were produced may since have been withdrawn.
|
||||
*
|
||||
* <p>Tenant is checked alongside actor rather than assumed to follow from it. Checking only the
|
||||
* actor is correct exactly while one actor identity never spans two tenants, and nothing here
|
||||
* enforces that; when it stops holding, the replay succeeds and hands over history the caller was
|
||||
* never entitled to.
|
||||
*/
|
||||
public final class GraphQlReplayAuthorization {
|
||||
|
||||
@@ -17,17 +22,26 @@ public final class GraphQlReplayAuthorization {
|
||||
* Verifies a replay request.
|
||||
*
|
||||
* @param cursorActorFingerprint the actor the cursor was issued to
|
||||
* @param cursorTenantFingerprint the tenant the cursor was issued to
|
||||
* @param principal the actor presenting it
|
||||
* @param stillAuthorized whether the Application still authorizes this actor for the subscription
|
||||
* @throws GraphQlReplayAuthorizationException when the actor differs or is no longer authorized
|
||||
* @throws GraphQlReplayAuthorizationException when tenant or actor differs, or authorization has
|
||||
* since been withdrawn
|
||||
*/
|
||||
public static void verify(
|
||||
String cursorActorFingerprint, GraphQlWebSocketPrincipal principal, boolean stillAuthorized) {
|
||||
String cursorActorFingerprint,
|
||||
String cursorTenantFingerprint,
|
||||
GraphQlWebSocketPrincipal principal,
|
||||
boolean stillAuthorized) {
|
||||
|
||||
if (cursorActorFingerprint == null
|
||||
|| !cursorActorFingerprint.equals(principal.actorFingerprint())) {
|
||||
throw new GraphQlReplayAuthorizationException();
|
||||
}
|
||||
if (cursorTenantFingerprint == null
|
||||
|| !cursorTenantFingerprint.equals(principal.tenantFingerprint())) {
|
||||
throw new GraphQlReplayAuthorizationException();
|
||||
}
|
||||
if (!stillAuthorized) {
|
||||
throw new GraphQlReplayAuthorizationException();
|
||||
}
|
||||
|
||||
+47
-10
@@ -1,14 +1,25 @@
|
||||
package dev.caskeleton.adapter.inbound.graphql.advanced.replay;
|
||||
|
||||
import dev.caskeleton.adapter.inbound.graphql.advanced.security.GraphQlWebSocketPrincipal;
|
||||
import dev.caskeleton.adapter.inbound.graphql.pagination.GraphQlCursorCodec;
|
||||
import dev.caskeleton.adapter.inbound.graphql.pagination.GraphQlCursorFraming;
|
||||
import dev.caskeleton.adapter.inbound.graphql.pagination.GraphQlCursorPayload;
|
||||
import dev.caskeleton.adapter.inbound.graphql.pagination.GraphQlCursorScope;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
|
||||
/**
|
||||
* A signed resume position for a subscription.
|
||||
*
|
||||
* <p>Signed and bound to the actor and subscription profile for the same reason connection cursors
|
||||
* are: an unsigned resume token is a "start reading from here" parameter, and replay reads history.
|
||||
* <p>Signed and bound to the tenant, the actor and the subscription profile for the same reason
|
||||
* connection cursors are: an unsigned resume token is a "start reading from here" parameter, and
|
||||
* replay reads history.
|
||||
*
|
||||
* <p>The tenant is part of the binding, not left to the actor fingerprint to imply. An actor
|
||||
* identifier that happens to be unique per tenant today stops being a tenant check the moment one
|
||||
* identity can act in two tenants, and the failure is silent — the cursor verifies, the actor
|
||||
* matches, and the replay delivers another tenant's history. Taking the principal rather than a
|
||||
* bare fingerprint makes issuing a cursor without a tenant impossible to express.
|
||||
*
|
||||
* <p>GraphQL itself defines no resume mechanism — this is an extension, and the durability behind
|
||||
* it belongs to the messaging platform.
|
||||
@@ -25,20 +36,21 @@ public final class GraphQlSubscriptionCursor {
|
||||
*
|
||||
* @param codec the signing codec
|
||||
* @param subscriptionProfile the subscription this cursor belongs to
|
||||
* @param actorFingerprint the actor it was issued to
|
||||
* @param principal the tenant and actor it was issued to
|
||||
* @param position the resume position
|
||||
*/
|
||||
public static String issue(
|
||||
GraphQlCursorCodec codec,
|
||||
String subscriptionProfile,
|
||||
String actorFingerprint,
|
||||
GraphQlWebSocketPrincipal principal,
|
||||
GraphQlReplayPosition position) {
|
||||
return codec.encode(
|
||||
GraphQlCursorPayload.of(
|
||||
GraphQlCursorPayload.issue(
|
||||
QUERY_PROFILE,
|
||||
GraphQlCursorPayload.FORWARD,
|
||||
Map.of("id", subscriptionProfile, "sequence", Long.toString(position.sequence())),
|
||||
actorFingerprint));
|
||||
subscriptionProfile,
|
||||
scopeOf(principal)));
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -46,13 +58,38 @@ public final class GraphQlSubscriptionCursor {
|
||||
*
|
||||
* @param codec the signing codec
|
||||
* @param cursor the cursor the client presented
|
||||
* @param actorFingerprint the actor presenting it
|
||||
* @param subscriptionProfile the subscription being resumed
|
||||
* @param principal the tenant and actor presenting it
|
||||
* @throws dev.caskeleton.adapter.inbound.graphql.pagination.GraphQlCursorException when the
|
||||
* cursor was issued for another actor or subscription
|
||||
* cursor was issued for another tenant, actor or subscription
|
||||
*/
|
||||
public static GraphQlReplayPosition resume(
|
||||
GraphQlCursorCodec codec, String cursor, String actorFingerprint) {
|
||||
GraphQlCursorPayload payload = codec.decode(cursor, QUERY_PROFILE, actorFingerprint);
|
||||
GraphQlCursorCodec codec,
|
||||
String cursor,
|
||||
String subscriptionProfile,
|
||||
GraphQlWebSocketPrincipal principal) {
|
||||
GraphQlCursorPayload payload =
|
||||
codec.decode(
|
||||
cursor,
|
||||
new GraphQlCursorScope(
|
||||
QUERY_PROFILE,
|
||||
subscriptionProfile,
|
||||
GraphQlCursorPayload.FORWARD,
|
||||
scopeOf(principal)));
|
||||
return new GraphQlReplayPosition(Long.parseLong(payload.keyset().get("sequence")));
|
||||
}
|
||||
|
||||
/**
|
||||
* The scope fingerprint a cursor is bound to.
|
||||
*
|
||||
* <p>Length-framed rather than concatenated, so a tenant ending in the prefix of an actor cannot
|
||||
* produce the same scope string as a different pair.
|
||||
*/
|
||||
private static String scopeOf(GraphQlWebSocketPrincipal principal) {
|
||||
Objects.requireNonNull(principal, "principal is required");
|
||||
StringBuilder scope = new StringBuilder();
|
||||
GraphQlCursorFraming.write(scope, principal.tenantFingerprint());
|
||||
GraphQlCursorFraming.write(scope, principal.actorFingerprint());
|
||||
return scope.toString();
|
||||
}
|
||||
}
|
||||
|
||||
+7
-3
@@ -6,12 +6,16 @@ import dev.caskeleton.adapter.inbound.graphql.policy.GraphQlOperationType;
|
||||
import java.util.Objects;
|
||||
|
||||
/**
|
||||
* Registers the RSocket transport, once flag, approval, route and consumer all allow it.
|
||||
* Decides whether an RSocket route may be served, and which interaction model it gets.
|
||||
*
|
||||
* <p>Admission, not a handler and not a registration: nothing here touches an {@code RSocket}
|
||||
* acceptor. It answers "is this route allowed, and is this request/stream or request/response",
|
||||
* which is what the runtime needs before it binds anything.
|
||||
*
|
||||
* <p>Experimental, so in production the guard additionally requires an approval profile. The
|
||||
* interaction model comes from the operation type rather than the caller's request.
|
||||
*/
|
||||
public final class GraphQlRSocketHandlerFactory {
|
||||
public final class GraphQlRSocketAdmission {
|
||||
|
||||
private final GraphQlAdvancedModuleGuard guard;
|
||||
private final GraphQlRSocketProperties properties;
|
||||
@@ -23,7 +27,7 @@ public final class GraphQlRSocketHandlerFactory {
|
||||
* @param guard the Advanced capability guard
|
||||
* @param properties transport configuration
|
||||
*/
|
||||
public GraphQlRSocketHandlerFactory(
|
||||
public GraphQlRSocketAdmission(
|
||||
GraphQlAdvancedModuleGuard guard, GraphQlRSocketProperties properties) {
|
||||
this.guard = Objects.requireNonNull(guard);
|
||||
this.properties = Objects.requireNonNull(properties);
|
||||
+7
-3
@@ -7,13 +7,17 @@ import java.time.Clock;
|
||||
import java.util.Objects;
|
||||
|
||||
/**
|
||||
* Creates SSE streams, once the capability is enabled (Advanced plan Task 9).
|
||||
* Decides whether an SSE stream may be opened, and issues its heartbeat schedule if so.
|
||||
*
|
||||
* <p>Admission, not a handler: what it returns is a {@link GraphQlSseHeartbeat} and a {@link
|
||||
* GraphQlSseTermination}, both plain schedules. Nothing here writes an event to a response — the
|
||||
* runtime that owns the response does that, using these bounds.
|
||||
*
|
||||
* <p>The request shape is a POST with a JSON body and {@code Accept: text/event-stream} — the same
|
||||
* request envelope as every other transport, with a streaming response. Authorization and cost
|
||||
* policy are the WebSocket ones; only the delivery mechanism differs.
|
||||
*/
|
||||
public final class GraphQlSseHandlerFactory {
|
||||
public final class GraphQlSseAdmission {
|
||||
|
||||
/** The {@code Accept} value that selects SSE. */
|
||||
public static final String EVENT_STREAM_MEDIA_TYPE = "text/event-stream";
|
||||
@@ -29,7 +33,7 @@ public final class GraphQlSseHandlerFactory {
|
||||
* @param properties connection bounds
|
||||
* @param clock clock used for heartbeats and termination
|
||||
*/
|
||||
public GraphQlSseHandlerFactory(
|
||||
public GraphQlSseAdmission(
|
||||
GraphQlAdvancedModuleGuard guard, GraphQlSseProperties properties, Clock clock) {
|
||||
this.guard = Objects.requireNonNull(guard);
|
||||
this.properties = Objects.requireNonNull(properties);
|
||||
+10
-1
@@ -1,5 +1,6 @@
|
||||
package dev.caskeleton.adapter.inbound.graphql.advanced.subscription;
|
||||
|
||||
import dev.caskeleton.adapter.inbound.graphql.security.GraphQlContextCleanup;
|
||||
import java.util.Queue;
|
||||
import java.util.concurrent.ConcurrentLinkedQueue;
|
||||
import java.util.concurrent.atomic.AtomicBoolean;
|
||||
@@ -10,6 +11,11 @@ import java.util.concurrent.atomic.AtomicBoolean;
|
||||
* <p>An unsubscribed client whose upstream keeps running is the expensive failure here: the Kafka
|
||||
* consumer, the polling task and the nested publishers all continue for a subscriber that has gone,
|
||||
* and nothing in the request path notices.
|
||||
*
|
||||
* <p>Every hook runs exactly once, and one that throws does not stop the rest. The loop used to
|
||||
* abandon the queue at the first failure, so a broken consumer-close left the polling task and the
|
||||
* nested publishers running — the leak the second and third hooks existed to prevent, caused by the
|
||||
* first one failing.
|
||||
*/
|
||||
public final class GraphQlSubscriptionCancellation {
|
||||
|
||||
@@ -40,10 +46,13 @@ public final class GraphQlSubscriptionCancellation {
|
||||
}
|
||||
|
||||
private void drain() {
|
||||
GraphQlContextCleanup cleanup = GraphQlContextCleanup.create();
|
||||
Runnable hook = upstream.poll();
|
||||
while (hook != null) {
|
||||
hook.run();
|
||||
cleanup.register(hook);
|
||||
hook = upstream.poll();
|
||||
}
|
||||
// The same run-all-then-rethrow-with-suppressed semantics the request path already uses.
|
||||
cleanup.close();
|
||||
}
|
||||
}
|
||||
|
||||
+94
-27
@@ -2,8 +2,8 @@ package dev.caskeleton.adapter.inbound.graphql.advanced.subscription;
|
||||
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
import java.util.concurrent.atomic.AtomicBoolean;
|
||||
import java.util.concurrent.atomic.AtomicInteger;
|
||||
import java.util.Objects;
|
||||
import java.util.concurrent.atomic.AtomicReference;
|
||||
|
||||
/**
|
||||
* Graceful shutdown for long-lived subscriptions.
|
||||
@@ -12,13 +12,29 @@ import java.util.concurrent.atomic.AtomicInteger;
|
||||
* storm against an instance that is already leaving. New subscriptions are refused immediately,
|
||||
* existing ones get a bounded window to finish, and the window has a deadline so a stuck stream
|
||||
* cannot delay shutdown forever.
|
||||
*
|
||||
* <p>Phase, count and drain start move together as one immutable value behind a single
|
||||
* compare-and-set, because they are one fact and not three. Held apart they raced in both
|
||||
* directions: a subscription that passed the "not draining" check and then incremented the count
|
||||
* was admitted onto a node that had begun draining in between, and a reader that saw the draining
|
||||
* flag before the separate {@code drainStartedAt} field was written computed the deadline against
|
||||
* {@code null}. Shutdown is exactly when both happen, and exactly when neither is easy to see.
|
||||
*/
|
||||
public final class GraphQlSubscriptionDrainCoordinator {
|
||||
|
||||
/**
|
||||
* The whole coordinator state, replaced atomically.
|
||||
*
|
||||
* @param phase where the node is in its lifetime
|
||||
* @param active subscriptions currently streaming
|
||||
* @param startedAt when draining began; non-null whenever the phase is not {@code ACCEPTING}
|
||||
*/
|
||||
private record State(GraphQlSubscriptionDrainPhase phase, int active, Instant startedAt) {}
|
||||
|
||||
private static final State OPEN = new State(GraphQlSubscriptionDrainPhase.ACCEPTING, 0, null);
|
||||
|
||||
private final Duration drainTimeout;
|
||||
private final AtomicBoolean draining = new AtomicBoolean();
|
||||
private final AtomicInteger active = new AtomicInteger();
|
||||
private Instant drainStartedAt;
|
||||
private final AtomicReference<State> state = new AtomicReference<>(OPEN);
|
||||
|
||||
/**
|
||||
* Creates the coordinator.
|
||||
@@ -33,44 +49,95 @@ public final class GraphQlSubscriptionDrainCoordinator {
|
||||
}
|
||||
|
||||
/**
|
||||
* Registers a new subscription.
|
||||
* Registers a new subscription and returns its lease.
|
||||
*
|
||||
* @throws GraphQlSubscriptionDrainingException while draining
|
||||
* <p>The admission decision and the count increment are the same compare-and-set, so a
|
||||
* subscription is never admitted onto a node that started draining between the two.
|
||||
*
|
||||
* @return the lease to close when the subscription ends
|
||||
* @throws GraphQlSubscriptionDrainingException while draining or once closed
|
||||
*/
|
||||
public void register() {
|
||||
if (draining.get()) {
|
||||
throw new GraphQlSubscriptionDrainingException();
|
||||
public GraphQlSubscriptionLease register() {
|
||||
while (true) {
|
||||
State current = state.get();
|
||||
if (current.phase() != GraphQlSubscriptionDrainPhase.ACCEPTING) {
|
||||
throw new GraphQlSubscriptionDrainingException();
|
||||
}
|
||||
State next = new State(current.phase(), current.active() + 1, current.startedAt());
|
||||
if (state.compareAndSet(current, next)) {
|
||||
return new GraphQlSubscriptionLease(this);
|
||||
}
|
||||
}
|
||||
active.incrementAndGet();
|
||||
}
|
||||
|
||||
/** Records that a subscription finished. */
|
||||
public void deregister() {
|
||||
active.updateAndGet(current -> Math.max(0, current - 1));
|
||||
}
|
||||
|
||||
/** Starts draining; no new subscriptions are accepted from here. */
|
||||
/**
|
||||
* Starts draining; no new subscriptions are accepted from here.
|
||||
*
|
||||
* <p>Idempotent: a second call keeps the original start instant, so a shutdown hook that fires
|
||||
* twice cannot extend the window it is supposed to bound.
|
||||
*
|
||||
* @param now the instant draining began
|
||||
*/
|
||||
public void startDraining(Instant now) {
|
||||
if (draining.compareAndSet(false, true)) {
|
||||
drainStartedAt = now;
|
||||
Objects.requireNonNull(now, "drain start instant is required");
|
||||
while (true) {
|
||||
State current = state.get();
|
||||
if (current.phase() != GraphQlSubscriptionDrainPhase.ACCEPTING) {
|
||||
return;
|
||||
}
|
||||
GraphQlSubscriptionDrainPhase next =
|
||||
current.active() == 0
|
||||
? GraphQlSubscriptionDrainPhase.CLOSED
|
||||
: GraphQlSubscriptionDrainPhase.DRAINING;
|
||||
if (state.compareAndSet(current, new State(next, current.active(), now))) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Whether every subscription has finished, or the drain window has elapsed. */
|
||||
/**
|
||||
* Whether every subscription has finished, or the drain window has elapsed.
|
||||
*
|
||||
* @param now the current instant
|
||||
*/
|
||||
public boolean drained(Instant now) {
|
||||
if (!draining.get()) {
|
||||
return false;
|
||||
}
|
||||
return active.get() == 0 || !now.isBefore(drainStartedAt.plus(drainTimeout));
|
||||
Objects.requireNonNull(now, "current instant is required");
|
||||
State current = state.get();
|
||||
return switch (current.phase()) {
|
||||
case ACCEPTING -> false;
|
||||
case CLOSED -> true;
|
||||
case DRAINING ->
|
||||
current.active() == 0 || !now.isBefore(current.startedAt().plus(drainTimeout));
|
||||
};
|
||||
}
|
||||
|
||||
/** Subscriptions still streaming. */
|
||||
public int activeSubscriptions() {
|
||||
return active.get();
|
||||
return state.get().active();
|
||||
}
|
||||
|
||||
/** Whether the coordinator is draining. */
|
||||
/** Whether the coordinator has stopped accepting new subscriptions. */
|
||||
public boolean draining() {
|
||||
return draining.get();
|
||||
return state.get().phase() != GraphQlSubscriptionDrainPhase.ACCEPTING;
|
||||
}
|
||||
|
||||
/** Where the node is in its subscription lifetime. */
|
||||
public GraphQlSubscriptionDrainPhase phase() {
|
||||
return state.get().phase();
|
||||
}
|
||||
|
||||
/** Releases one lease; called by {@link GraphQlSubscriptionLease#close()}. */
|
||||
void release() {
|
||||
while (true) {
|
||||
State current = state.get();
|
||||
int remaining = Math.max(0, current.active() - 1);
|
||||
GraphQlSubscriptionDrainPhase next =
|
||||
current.phase() == GraphQlSubscriptionDrainPhase.DRAINING && remaining == 0
|
||||
? GraphQlSubscriptionDrainPhase.CLOSED
|
||||
: current.phase();
|
||||
if (state.compareAndSet(current, new State(next, remaining, current.startedAt()))) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+20
@@ -0,0 +1,20 @@
|
||||
package dev.caskeleton.adapter.inbound.graphql.advanced.subscription;
|
||||
|
||||
/**
|
||||
* Where a node is in its subscription lifetime.
|
||||
*
|
||||
* <p>Three phases rather than a boolean, because "draining" and "finished draining" are different
|
||||
* answers to the shutdown question and a boolean can only carry one of them. The transitions are
|
||||
* one-way: a node that has started draining never accepts again, and a closed one never reopens.
|
||||
*/
|
||||
public enum GraphQlSubscriptionDrainPhase {
|
||||
|
||||
/** New subscriptions are admitted. */
|
||||
ACCEPTING,
|
||||
|
||||
/** New subscriptions are refused; existing ones have a bounded window to finish. */
|
||||
DRAINING,
|
||||
|
||||
/** Every subscription finished, or the drain window elapsed. */
|
||||
CLOSED
|
||||
}
|
||||
+10
-2
@@ -1,5 +1,6 @@
|
||||
package dev.caskeleton.adapter.inbound.graphql.advanced.subscription;
|
||||
|
||||
import dev.caskeleton.adapter.inbound.graphql.http.GraphQlRequestSize;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
@@ -24,8 +25,15 @@ public record GraphQlSubscriptionEvent(Map<String, Object> payload, long sequenc
|
||||
}
|
||||
}
|
||||
|
||||
/** An estimate of this event's serialized size, for the byte budget. */
|
||||
/**
|
||||
* This event's serialized size, for the byte budget.
|
||||
*
|
||||
* <p>Counted as canonical JSON, not as {@code Map.toString()}. The two diverge by more than a
|
||||
* constant: {@code toString} writes {@code {a=1}} where JSON writes {@code {"a":1}}, omits the
|
||||
* quoting that dominates a string-heavy payload, and renders a nested list differently again. A
|
||||
* queue bounded by that number is bounded by something that is not the thing being queued.
|
||||
*/
|
||||
public long approximateBytes() {
|
||||
return payload.toString().getBytes(java.nio.charset.StandardCharsets.UTF_8).length;
|
||||
return GraphQlRequestSize.jsonBytes(payload);
|
||||
}
|
||||
}
|
||||
|
||||
+37
@@ -0,0 +1,37 @@
|
||||
package dev.caskeleton.adapter.inbound.graphql.advanced.subscription;
|
||||
|
||||
import java.util.Objects;
|
||||
import java.util.concurrent.atomic.AtomicBoolean;
|
||||
|
||||
/**
|
||||
* A subscription's claim on the node, released exactly once when the stream ends.
|
||||
*
|
||||
* <p>A lease rather than a paired {@code register}/{@code deregister} call, because the paired form
|
||||
* makes correctness depend on the caller: a stream that ended on an error path without its matching
|
||||
* deregister left the count permanently above zero, and the node then drained for the full timeout
|
||||
* on every shutdown while reporting subscriptions that no longer existed. Releasing twice is just
|
||||
* as damaging in the other direction — it decrements someone else's subscription — so the release
|
||||
* is idempotent and the second call does nothing.
|
||||
*/
|
||||
public final class GraphQlSubscriptionLease implements AutoCloseable {
|
||||
|
||||
private final GraphQlSubscriptionDrainCoordinator coordinator;
|
||||
private final AtomicBoolean released = new AtomicBoolean();
|
||||
|
||||
GraphQlSubscriptionLease(GraphQlSubscriptionDrainCoordinator coordinator) {
|
||||
this.coordinator = Objects.requireNonNull(coordinator, "coordinator is required");
|
||||
}
|
||||
|
||||
/** Releases the lease; subsequent calls do nothing. */
|
||||
@Override
|
||||
public void close() {
|
||||
if (released.compareAndSet(false, true)) {
|
||||
coordinator.release();
|
||||
}
|
||||
}
|
||||
|
||||
/** Whether this lease has already been released. */
|
||||
public boolean released() {
|
||||
return released.get();
|
||||
}
|
||||
}
|
||||
+10
-3
@@ -7,12 +7,19 @@ import java.util.List;
|
||||
import java.util.Objects;
|
||||
|
||||
/**
|
||||
* Creates connection lifecycles, once the capability is enabled.
|
||||
* Decides whether a WebSocket connection may be opened, and issues its lifecycle if so.
|
||||
*
|
||||
* <p>Admission, not a handler. It was called a handler factory, which promised a Spring {@code
|
||||
* WebSocketHandler} that reads and writes frames; what it returns is a {@link
|
||||
* GraphQlWebSocketLifecycle} — a state machine with no socket and no I/O. An adopter who wired the
|
||||
* old name where Spring expected a handler found a name that fit and behaviour that did not. The
|
||||
* transport binding stays with the runtime that owns the socket; this owns the decision to let a
|
||||
* connection exist.
|
||||
*
|
||||
* <p>The guard is checked here rather than per message, so a deployment without the flag never
|
||||
* accepts a WebSocket connection at all.
|
||||
*/
|
||||
public final class GraphQlWebSocketHandlerFactory {
|
||||
public final class GraphQlWebSocketAdmission {
|
||||
|
||||
private final GraphQlAdvancedModuleGuard guard;
|
||||
private final GraphQlWebSocketProperties properties;
|
||||
@@ -25,7 +32,7 @@ public final class GraphQlWebSocketHandlerFactory {
|
||||
* @param properties connection bounds
|
||||
* @param clock clock used for lifecycle deadlines
|
||||
*/
|
||||
public GraphQlWebSocketHandlerFactory(
|
||||
public GraphQlWebSocketAdmission(
|
||||
GraphQlAdvancedModuleGuard guard, GraphQlWebSocketProperties properties, Clock clock) {
|
||||
this.guard = Objects.requireNonNull(guard);
|
||||
this.properties = Objects.requireNonNull(properties);
|
||||
+72
@@ -0,0 +1,72 @@
|
||||
package dev.caskeleton.adapter.inbound.graphql.architecture;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
|
||||
/**
|
||||
* Whether a resolver's return container yields one value or many.
|
||||
*
|
||||
* <p>The distinction is what makes the subscription rule correct. The rule used to reject every
|
||||
* {@code Publisher} outside a subscription, and {@code Mono<T>} is a {@code Publisher} — so {@code
|
||||
* Mono<OrderView> order()} on a query, which Spring for GraphQL supports and documents, was refused
|
||||
* by the platform's own boundary check. A query may complete asynchronously; what it may not do is
|
||||
* emit a stream, because the HTTP profile has no way to deliver one.
|
||||
*
|
||||
* <p>Recognised by name rather than by class reference: naming {@code reactor.core.publisher.Mono}
|
||||
* here would drag Reactor into a rule that exists to keep this boundary framework-light, and the
|
||||
* check has to work whether or not the adopter has Reactor at all.
|
||||
*/
|
||||
public enum GraphQlAsyncReturnShape {
|
||||
|
||||
/** Not an async container at all. */
|
||||
SYNCHRONOUS,
|
||||
|
||||
/** Completes once with at most one value: legal on every operation type. */
|
||||
SINGLE_VALUE,
|
||||
|
||||
/** Emits zero or more values over time: legal only on a subscription. */
|
||||
MULTI_VALUE;
|
||||
|
||||
private static final Set<String> SINGLE_VALUE_CONTAINERS =
|
||||
Set.of(
|
||||
"reactor.core.publisher.Mono",
|
||||
"java.util.concurrent.CompletionStage",
|
||||
"java.util.concurrent.CompletableFuture",
|
||||
"java.util.concurrent.Future",
|
||||
"java.util.Optional",
|
||||
"org.springframework.graphql.data.method.annotation.SchemaMapping");
|
||||
|
||||
private static final List<String> MULTI_VALUE_INTERFACES =
|
||||
List.of("org.reactivestreams.Publisher", "java.util.stream.Stream");
|
||||
|
||||
/** Classifies a resolver return type. */
|
||||
public static GraphQlAsyncReturnShape of(Class<?> returnType) {
|
||||
if (returnType == null) {
|
||||
return SYNCHRONOUS;
|
||||
}
|
||||
if (SINGLE_VALUE_CONTAINERS.contains(returnType.getName())) {
|
||||
return SINGLE_VALUE;
|
||||
}
|
||||
return implementsAny(returnType, MULTI_VALUE_INTERFACES) ? MULTI_VALUE : SYNCHRONOUS;
|
||||
}
|
||||
|
||||
/** Whether this shape may be returned from a field that is not a subscription. */
|
||||
public boolean allowedOutsideSubscription() {
|
||||
return this != MULTI_VALUE;
|
||||
}
|
||||
|
||||
private static boolean implementsAny(Class<?> type, List<String> interfaceNames) {
|
||||
if (type == null || type == Object.class) {
|
||||
return false;
|
||||
}
|
||||
if (interfaceNames.contains(type.getName())) {
|
||||
return true;
|
||||
}
|
||||
for (Class<?> implemented : type.getInterfaces()) {
|
||||
if (implementsAny(implemented, interfaceNames)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return implementsAny(type.getSuperclass(), interfaceNames);
|
||||
}
|
||||
}
|
||||
+20
-10
@@ -6,7 +6,6 @@ import java.lang.reflect.Parameter;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
import org.reactivestreams.Publisher;
|
||||
|
||||
/**
|
||||
* Inspects one annotated resolver method against the transport boundary (design §11).
|
||||
@@ -52,19 +51,30 @@ public final class GraphQlControllerInspector {
|
||||
List<String> violations = new ArrayList<>();
|
||||
String coordinate = coordinateOf(method);
|
||||
|
||||
Class<?> returnType = method.getReturnType();
|
||||
String returnRejection = GraphQlReturnTypePolicy.rejection(returnType);
|
||||
if (returnRejection != null) {
|
||||
violations.add(coordinate + " returns " + returnRejection);
|
||||
// The generic type, not the erased one. `Mono<OrderEntity>` erases to `Mono`, which passes
|
||||
// every persistence rule while carrying exactly the type those rules forbid.
|
||||
for (Class<?> referenced : GraphQlTypeGraph.referencedTypes(method.getGenericReturnType())) {
|
||||
String returnRejection = GraphQlReturnTypePolicy.rejection(referenced);
|
||||
if (returnRejection != null) {
|
||||
violations.add(coordinate + " returns " + returnRejection);
|
||||
}
|
||||
}
|
||||
if (Publisher.class.isAssignableFrom(returnType) && !subscription(method)) {
|
||||
violations.add(coordinate + " returns a Publisher outside a subscription");
|
||||
|
||||
GraphQlAsyncReturnShape shape = GraphQlAsyncReturnShape.of(method.getReturnType());
|
||||
if (!shape.allowedOutsideSubscription() && !subscription(method)) {
|
||||
violations.add(
|
||||
coordinate
|
||||
+ " returns a multi-value publisher outside a subscription; a query or mutation may "
|
||||
+ "complete asynchronously but cannot emit a stream");
|
||||
}
|
||||
|
||||
for (Parameter parameter : method.getParameters()) {
|
||||
String argumentRejection = GraphQlInputTypePolicy.rejection(parameter.getType());
|
||||
if (argumentRejection != null) {
|
||||
violations.add(coordinate + " binds " + parameter.getName() + ": " + argumentRejection);
|
||||
for (Class<?> referenced :
|
||||
GraphQlTypeGraph.referencedTypes(parameter.getParameterizedType())) {
|
||||
String argumentRejection = GraphQlInputTypePolicy.rejection(referenced);
|
||||
if (argumentRejection != null) {
|
||||
violations.add(coordinate + " binds " + parameter.getName() + ": " + argumentRejection);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+185
-56
@@ -1,11 +1,13 @@
|
||||
package dev.caskeleton.adapter.inbound.graphql.architecture;
|
||||
|
||||
import java.io.File;
|
||||
import java.io.IOException;
|
||||
import java.io.UncheckedIOException;
|
||||
import java.lang.reflect.Constructor;
|
||||
import java.lang.reflect.Field;
|
||||
import java.lang.reflect.Method;
|
||||
import java.lang.reflect.Parameter;
|
||||
import java.net.JarURLConnection;
|
||||
import java.net.URL;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
@@ -15,6 +17,9 @@ import java.util.Enumeration;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
import java.util.TreeSet;
|
||||
import java.util.function.Supplier;
|
||||
import java.util.jar.JarEntry;
|
||||
import java.util.jar.JarFile;
|
||||
import java.util.stream.Stream;
|
||||
|
||||
/**
|
||||
@@ -35,13 +40,37 @@ public final class GraphQlResolverBoundaryRules {
|
||||
Set.of("EntityManager", "MongoTemplate", "SessionFactory", "DataSource", "JdbcTemplate");
|
||||
|
||||
/**
|
||||
* Type-name suffixes that indicate a repository dependency.
|
||||
* Type-name suffixes that suggest a repository dependency.
|
||||
*
|
||||
* <p>A supporting signal, not a verdict. A name is the weakest evidence available: {@code
|
||||
* OrderRepository} may be a Spring Data interface or an application port that happens to be named
|
||||
* that way, and a rule that decides on the suffix alone both misses the real repository imported
|
||||
* under another name and refuses a legitimate collaborator. So the suffix only counts when the
|
||||
* type also sits in a persistence-shaped package or is an interface — see {@link
|
||||
* #repositoryEvidence}.
|
||||
*
|
||||
* <p>DTO mappers are deliberately absent: a resolver mapping an Application result onto a GraphQL
|
||||
* payload is exactly what it is supposed to do.
|
||||
*/
|
||||
public static final Set<String> FORBIDDEN_TYPE_SUFFIXES = Set.of("Repository", "Dao");
|
||||
|
||||
/** Package name fragments that make a repository-suffixed type a repository in fact. */
|
||||
public static final Set<String> PERSISTENCE_PACKAGE_FRAGMENTS =
|
||||
Set.of(".repository", ".persistence", ".dao", ".jpa", ".mongo");
|
||||
|
||||
/** Interfaces whose presence proves a type is a repository, matched by name. */
|
||||
public static final Set<String> REPOSITORY_INTERFACES =
|
||||
Set.of(
|
||||
"org.springframework.data.repository.Repository",
|
||||
"org.springframework.data.repository.CrudRepository",
|
||||
"org.springframework.data.repository.PagingAndSortingRepository",
|
||||
"org.springframework.data.repository.ListCrudRepository",
|
||||
"org.springframework.data.repository.reactive.ReactiveCrudRepository");
|
||||
|
||||
/** Annotations whose presence proves a type is a repository, matched by name. */
|
||||
public static final Set<String> REPOSITORY_ANNOTATIONS =
|
||||
Set.of("org.springframework.stereotype.Repository");
|
||||
|
||||
private GraphQlResolverBoundaryRules() {}
|
||||
|
||||
/**
|
||||
@@ -66,46 +95,40 @@ public final class GraphQlResolverBoundaryRules {
|
||||
}
|
||||
}
|
||||
|
||||
/** Persistence-access violations across the given classes, in deterministic order. */
|
||||
/**
|
||||
* Persistence-access violations across the given classes, in deterministic order.
|
||||
*
|
||||
* <p>Every declared type is walked as a generic type graph. Checking the erased type would let
|
||||
* {@code Optional<OrderRepository>}, {@code List<OrderEntity>} and {@code Mono<OrderRepository>}
|
||||
* through — the wrapper is what a leak looks like once someone has tidied the signature.
|
||||
*/
|
||||
public static List<String> persistenceViolations(Collection<Class<?>> classes) {
|
||||
List<String> violations = new ArrayList<>();
|
||||
for (Class<?> type : classes) {
|
||||
for (Field field : type.getDeclaredFields()) {
|
||||
if (forbidden(field.getType())) {
|
||||
violations.add(
|
||||
type.getSimpleName()
|
||||
+ "."
|
||||
+ field.getName()
|
||||
+ " depends on "
|
||||
+ field.getType().getName());
|
||||
}
|
||||
record(
|
||||
violations,
|
||||
field.getGenericType(),
|
||||
() -> type.getSimpleName() + "." + field.getName() + " depends on ");
|
||||
}
|
||||
for (Constructor<?> constructor : type.getDeclaredConstructors()) {
|
||||
for (Parameter parameter : constructor.getParameters()) {
|
||||
if (forbidden(parameter.getType())) {
|
||||
violations.add(
|
||||
type.getSimpleName() + " constructor injects " + parameter.getType().getName());
|
||||
}
|
||||
record(
|
||||
violations,
|
||||
parameter.getParameterizedType(),
|
||||
() -> type.getSimpleName() + " constructor injects ");
|
||||
}
|
||||
}
|
||||
for (Method method : type.getDeclaredMethods()) {
|
||||
if (forbidden(method.getReturnType())) {
|
||||
violations.add(
|
||||
type.getSimpleName()
|
||||
+ "#"
|
||||
+ method.getName()
|
||||
+ " returns "
|
||||
+ method.getReturnType().getName());
|
||||
}
|
||||
record(
|
||||
violations,
|
||||
method.getGenericReturnType(),
|
||||
() -> type.getSimpleName() + "#" + method.getName() + " returns ");
|
||||
for (Parameter parameter : method.getParameters()) {
|
||||
if (forbidden(parameter.getType())) {
|
||||
violations.add(
|
||||
type.getSimpleName()
|
||||
+ "#"
|
||||
+ method.getName()
|
||||
+ " accepts "
|
||||
+ parameter.getType().getName());
|
||||
}
|
||||
record(
|
||||
violations,
|
||||
parameter.getParameterizedType(),
|
||||
() -> type.getSimpleName() + "#" + method.getName() + " accepts ");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -113,25 +136,100 @@ public final class GraphQlResolverBoundaryRules {
|
||||
return List.copyOf(violations);
|
||||
}
|
||||
|
||||
private static void record(
|
||||
List<String> violations, java.lang.reflect.Type declared, Supplier<String> prefix) {
|
||||
for (Class<?> referenced : GraphQlTypeGraph.referencedTypes(declared)) {
|
||||
String evidence = repositoryEvidence(referenced);
|
||||
if (evidence != null) {
|
||||
violations.add(prefix.get() + referenced.getName() + " (" + evidence + ")");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Whether a type represents direct persistence or repository access. */
|
||||
public static boolean forbidden(Class<?> type) {
|
||||
if (type == null || type.isPrimitive()) {
|
||||
return false;
|
||||
}
|
||||
Class<?> subject = type.isArray() ? type.getComponentType() : type;
|
||||
if (GraphQlReturnTypePolicy.forbiddenPrefix(subject) != null
|
||||
|| GraphQlReturnTypePolicy.isPersistenceMapped(subject)) {
|
||||
return true;
|
||||
}
|
||||
String simpleName = subject.getSimpleName();
|
||||
if (FORBIDDEN_TYPE_NAMES.contains(simpleName)) {
|
||||
return true;
|
||||
}
|
||||
return FORBIDDEN_TYPE_SUFFIXES.stream().anyMatch(simpleName::endsWith);
|
||||
return repositoryEvidence(type) != null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads every class declared directly in a package from the current classpath.
|
||||
* Why a type counts as persistence access, or {@code null} when it does not.
|
||||
*
|
||||
* <p>Ordered strongest first, so a diagnostic names the reason that would survive a rename. The
|
||||
* name-only signal is last and qualified, because it is the one that is wrong in both directions.
|
||||
*/
|
||||
public static String repositoryEvidence(Class<?> type) {
|
||||
if (type == null || type.isPrimitive()) {
|
||||
return null;
|
||||
}
|
||||
Class<?> subject = type;
|
||||
while (subject.isArray()) {
|
||||
subject = subject.getComponentType();
|
||||
}
|
||||
if (GraphQlReturnTypePolicy.forbiddenPrefix(subject) != null) {
|
||||
return "declared in a persistence or driver package";
|
||||
}
|
||||
if (GraphQlReturnTypePolicy.isPersistenceMapped(subject)) {
|
||||
return "carries a persistence mapping annotation";
|
||||
}
|
||||
if (implementsRepositoryInterface(subject)) {
|
||||
return "implements a Spring Data repository interface";
|
||||
}
|
||||
for (java.lang.annotation.Annotation annotation : subject.getAnnotations()) {
|
||||
if (REPOSITORY_ANNOTATIONS.contains(annotation.annotationType().getName())) {
|
||||
return "annotated as a repository";
|
||||
}
|
||||
}
|
||||
String simpleName = subject.getSimpleName();
|
||||
if (FORBIDDEN_TYPE_NAMES.contains(simpleName)) {
|
||||
return "is a persistence infrastructure type";
|
||||
}
|
||||
boolean repositoryName = FORBIDDEN_TYPE_SUFFIXES.stream().anyMatch(simpleName::endsWith);
|
||||
if (repositoryName && type.isInterface()) {
|
||||
return "is a repository-named interface";
|
||||
}
|
||||
if (repositoryName && persistenceShaped(subject)) {
|
||||
return "is named as a repository and declared in a persistence package";
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the type sits in a package that makes a repository name mean what it says.
|
||||
*
|
||||
* <p>The pair of name-based rules above is what demoting the suffix heuristic looks like in
|
||||
* practice. A repository-named <em>interface</em> is a port by every convention this repository
|
||||
* follows, so it still counts on its own. A repository-named concrete class outside a persistence
|
||||
* package no longer does — that shape is far more often a value object or a view than a
|
||||
* data-access type, and refusing it made the rule something to work around.
|
||||
*/
|
||||
private static boolean persistenceShaped(Class<?> type) {
|
||||
String packageName = "." + type.getPackageName() + ".";
|
||||
return PERSISTENCE_PACKAGE_FRAGMENTS.stream()
|
||||
.anyMatch(fragment -> packageName.contains(fragment + "."));
|
||||
}
|
||||
|
||||
private static boolean implementsRepositoryInterface(Class<?> type) {
|
||||
if (type == null || type == Object.class) {
|
||||
return false;
|
||||
}
|
||||
if (REPOSITORY_INTERFACES.contains(type.getName())) {
|
||||
return true;
|
||||
}
|
||||
for (Class<?> implemented : type.getInterfaces()) {
|
||||
if (implementsRepositoryInterface(implemented)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return implementsRepositoryInterface(type.getSuperclass());
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads every class in a package and its sub-packages from the current classpath.
|
||||
*
|
||||
* <p>Recursive and JAR-aware. The previous scan listed direct children of a {@code file:}
|
||||
* directory only, so an adopter whose controllers sit one package deeper, or whose classes ship
|
||||
* inside a jar — which is to say, every adopter running a packaged application — was checked
|
||||
* against nothing while the rule reported success.
|
||||
*
|
||||
* @throws GraphQlControllerContractException when the package cannot be located, so a rule can
|
||||
* never pass by scanning nothing
|
||||
@@ -147,17 +245,10 @@ public final class GraphQlResolverBoundaryRules {
|
||||
Enumeration<URL> roots = classLoader.getResources(resourcePath);
|
||||
while (roots.hasMoreElements()) {
|
||||
URL root = roots.nextElement();
|
||||
if (!"file".equals(root.getProtocol())) {
|
||||
continue;
|
||||
}
|
||||
Path directory = Path.of(root.toURI());
|
||||
try (Stream<Path> files = Files.list(directory)) {
|
||||
files
|
||||
.filter(Files::isRegularFile)
|
||||
.map(path -> path.getFileName().toString())
|
||||
.filter(name -> name.endsWith(".class"))
|
||||
.map(name -> name.substring(0, name.length() - ".class".length()))
|
||||
.forEach(name -> classNames.add(packageName + "." + name));
|
||||
if ("file".equals(root.getProtocol())) {
|
||||
collectFromDirectory(Path.of(root.toURI()), packageName, classNames);
|
||||
} else if ("jar".equals(root.getProtocol())) {
|
||||
collectFromJar(root, resourcePath, classNames);
|
||||
}
|
||||
}
|
||||
} catch (IOException ex) {
|
||||
@@ -175,10 +266,48 @@ public final class GraphQlResolverBoundaryRules {
|
||||
for (String className : classNames) {
|
||||
try {
|
||||
classes.add(Class.forName(className, false, classLoader));
|
||||
} catch (ClassNotFoundException ex) {
|
||||
} catch (ClassNotFoundException | NoClassDefFoundError ex) {
|
||||
throw new IllegalStateException("cannot load " + className, ex);
|
||||
}
|
||||
}
|
||||
return List.copyOf(classes);
|
||||
}
|
||||
|
||||
private static void collectFromDirectory(Path directory, String packageName, Set<String> into)
|
||||
throws IOException {
|
||||
try (Stream<Path> entries = Files.walk(directory)) {
|
||||
entries
|
||||
.filter(Files::isRegularFile)
|
||||
.filter(path -> path.getFileName().toString().endsWith(".class"))
|
||||
.forEach(
|
||||
path -> {
|
||||
String relative =
|
||||
directory.relativize(path).toString().replace(File.separatorChar, '.');
|
||||
String className =
|
||||
packageName
|
||||
+ "."
|
||||
+ relative.substring(0, relative.length() - ".class".length());
|
||||
if (!className.contains("$")) {
|
||||
into.add(className);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private static void collectFromJar(URL root, String resourcePath, Set<String> into)
|
||||
throws IOException {
|
||||
JarURLConnection connection = (JarURLConnection) root.openConnection();
|
||||
// The connection owns the jar file when caching is on, so it must not be closed here: doing so
|
||||
// would shut a JarFile that the rest of the JVM is still reading from.
|
||||
connection.setUseCaches(true);
|
||||
JarFile jar = connection.getJarFile();
|
||||
Enumeration<JarEntry> entries = jar.entries();
|
||||
while (entries.hasMoreElements()) {
|
||||
String name = entries.nextElement().getName();
|
||||
if (!name.startsWith(resourcePath + "/") || !name.endsWith(".class") || name.contains("$")) {
|
||||
continue;
|
||||
}
|
||||
into.add(name.substring(0, name.length() - ".class".length()).replace('/', '.'));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+112
@@ -0,0 +1,112 @@
|
||||
package dev.caskeleton.adapter.inbound.graphql.architecture;
|
||||
|
||||
import java.lang.reflect.GenericArrayType;
|
||||
import java.lang.reflect.ParameterizedType;
|
||||
import java.lang.reflect.Type;
|
||||
import java.lang.reflect.TypeVariable;
|
||||
import java.lang.reflect.WildcardType;
|
||||
import java.util.ArrayDeque;
|
||||
import java.util.Deque;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.Set;
|
||||
|
||||
/**
|
||||
* Every class a declared type actually reaches, generics included.
|
||||
*
|
||||
* <p>The boundary rules used to inspect {@code Method#getReturnType} and {@code Parameter#getType},
|
||||
* which erase to the container: {@code List<OrderEntity>} reports {@code List}, {@code
|
||||
* Mono<OrderEntity>} reports {@code Mono}, and {@code Optional<OrderRepository>} reports {@code
|
||||
* Optional}. Every one of those passed a rule whose whole purpose was to notice the type inside —
|
||||
* the wrapper is exactly what a resolver leaking an entity looks like in practice.
|
||||
*
|
||||
* <p>Traversal is bounded and cycle-guarded. A type variable can refer to its own bound ({@code <T
|
||||
* extends Comparable<T>>}), and a rule that recursed into that would hang on a perfectly legal
|
||||
* signature.
|
||||
*/
|
||||
public final class GraphQlTypeGraph {
|
||||
|
||||
/** Ceiling on how many distinct types one signature may reach before it is refused. */
|
||||
public static final int MAXIMUM_VISITED_TYPES = 512;
|
||||
|
||||
private GraphQlTypeGraph() {}
|
||||
|
||||
/**
|
||||
* Every concrete class reachable from a declared type.
|
||||
*
|
||||
* <p>Arrays contribute their component type, parameterized types contribute their raw type and
|
||||
* every argument, wildcards and type variables contribute their bounds.
|
||||
*
|
||||
* @param type a declared return, parameter or field type
|
||||
* @return the reachable classes, in first-seen order
|
||||
*/
|
||||
public static Set<Class<?>> referencedTypes(Type type) {
|
||||
Set<Class<?>> found = new LinkedHashSet<>();
|
||||
if (type == null) {
|
||||
return found;
|
||||
}
|
||||
Set<Type> visited = new LinkedHashSet<>();
|
||||
Deque<Type> pending = new ArrayDeque<>();
|
||||
pending.push(type);
|
||||
|
||||
while (!pending.isEmpty() && visited.size() < MAXIMUM_VISITED_TYPES) {
|
||||
Type current = pending.pop();
|
||||
if (current == null || !visited.add(current)) {
|
||||
continue;
|
||||
}
|
||||
if (current instanceof Class<?> raw) {
|
||||
Class<?> component = raw;
|
||||
while (component.isArray()) {
|
||||
component = component.getComponentType();
|
||||
}
|
||||
if (!component.isPrimitive()) {
|
||||
found.add(component);
|
||||
}
|
||||
} else if (current instanceof ParameterizedType parameterized) {
|
||||
pending.push(parameterized.getRawType());
|
||||
for (Type argument : parameterized.getActualTypeArguments()) {
|
||||
pending.push(argument);
|
||||
}
|
||||
} else if (current instanceof GenericArrayType array) {
|
||||
pending.push(array.getGenericComponentType());
|
||||
} else if (current instanceof WildcardType wildcard) {
|
||||
for (Type bound : wildcard.getUpperBounds()) {
|
||||
pending.push(bound);
|
||||
}
|
||||
for (Type bound : wildcard.getLowerBounds()) {
|
||||
pending.push(bound);
|
||||
}
|
||||
} else if (current instanceof TypeVariable<?> variable) {
|
||||
for (Type bound : variable.getBounds()) {
|
||||
pending.push(bound);
|
||||
}
|
||||
}
|
||||
}
|
||||
return found;
|
||||
}
|
||||
|
||||
/**
|
||||
* The raw class a declared type erases to, or {@code null}.
|
||||
*
|
||||
* <p>Used where the container itself is the subject — deciding whether a return type is a
|
||||
* publisher, for instance — as opposed to what it contains.
|
||||
*/
|
||||
public static Class<?> rawType(Type type) {
|
||||
if (type instanceof Class<?> raw) {
|
||||
return raw;
|
||||
}
|
||||
if (type instanceof ParameterizedType parameterized) {
|
||||
return rawType(parameterized.getRawType());
|
||||
}
|
||||
if (type instanceof GenericArrayType array) {
|
||||
Class<?> component = rawType(array.getGenericComponentType());
|
||||
return component == null ? null : component.arrayType();
|
||||
}
|
||||
if (type instanceof WildcardType wildcard) {
|
||||
return wildcard.getUpperBounds().length == 0 ? null : rawType(wildcard.getUpperBounds()[0]);
|
||||
}
|
||||
if (type instanceof TypeVariable<?> variable) {
|
||||
return variable.getBounds().length == 0 ? null : rawType(variable.getBounds()[0]);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -59,7 +59,7 @@ public final class GraphQlPlatformActuatorEndpoint {
|
||||
properties.environment().name(),
|
||||
GraphQlHttpProfile.V1.name(),
|
||||
supportedCapabilities,
|
||||
properties.cursorKeyIds(),
|
||||
properties.cursor().keyIds(),
|
||||
registeredOperations,
|
||||
registeredFetchProfiles);
|
||||
}
|
||||
|
||||
+504
-23
@@ -1,20 +1,71 @@
|
||||
package dev.caskeleton.adapter.inbound.graphql.autoconfigure;
|
||||
|
||||
import dev.caskeleton.adapter.inbound.graphql.build.GraphQlBuildModel;
|
||||
import dev.caskeleton.adapter.inbound.graphql.api.GraphQlClientProfile;
|
||||
import dev.caskeleton.adapter.inbound.graphql.architecture.GraphQlControllerInspector;
|
||||
import dev.caskeleton.adapter.inbound.graphql.architecture.GraphQlResolverBoundaryRules;
|
||||
import dev.caskeleton.adapter.inbound.graphql.architecture.GraphQlTransportTypeRules;
|
||||
import dev.caskeleton.adapter.inbound.graphql.context.TenantContext;
|
||||
import dev.caskeleton.adapter.inbound.graphql.cost.GraphQlComplexityCalculator;
|
||||
import dev.caskeleton.adapter.inbound.graphql.cost.GraphQlCostCatalog;
|
||||
import dev.caskeleton.adapter.inbound.graphql.cost.GraphQlDocumentShapeAnalyzer;
|
||||
import dev.caskeleton.adapter.inbound.graphql.cost.GraphQlStructuralLimitPolicy;
|
||||
import dev.caskeleton.adapter.inbound.graphql.cost.GraphQlStructuralLimits;
|
||||
import dev.caskeleton.adapter.inbound.graphql.error.GraphQlExceptionResolver;
|
||||
import dev.caskeleton.adapter.inbound.graphql.execution.BoundedPreparsedDocumentProvider;
|
||||
import dev.caskeleton.adapter.inbound.graphql.execution.GraphQlExecutionPipeline;
|
||||
import dev.caskeleton.adapter.inbound.graphql.execution.GraphQlExecutionPipelineValidator;
|
||||
import dev.caskeleton.adapter.inbound.graphql.execution.GraphQlPreparsedCacheMetrics;
|
||||
import dev.caskeleton.adapter.inbound.graphql.execution.GraphQlPreparsedCachePolicy;
|
||||
import dev.caskeleton.adapter.inbound.graphql.http.GraphQlJsonStructurePolicy;
|
||||
import dev.caskeleton.adapter.inbound.graphql.observation.GraphQlDataLoaderObservationConvention;
|
||||
import dev.caskeleton.adapter.inbound.graphql.observation.GraphQlMetricCardinalityPolicy;
|
||||
import dev.caskeleton.adapter.inbound.graphql.observation.GraphQlOperationNameCardinality;
|
||||
import dev.caskeleton.adapter.inbound.graphql.observation.GraphQlRequestObservationConvention;
|
||||
import dev.caskeleton.adapter.inbound.graphql.observation.GraphQlResolverObservationConvention;
|
||||
import dev.caskeleton.adapter.inbound.graphql.observation.GraphQlSensitiveAttributeFilter;
|
||||
import dev.caskeleton.adapter.inbound.graphql.policy.GraphQlClientPolicy;
|
||||
import dev.caskeleton.adapter.inbound.graphql.runtime.GraphQlCostBudgetHandler;
|
||||
import dev.caskeleton.adapter.inbound.graphql.runtime.GraphQlDataFetcherExceptionResolver;
|
||||
import dev.caskeleton.adapter.inbound.graphql.runtime.GraphQlDocumentAuthorizationHandler;
|
||||
import dev.caskeleton.adapter.inbound.graphql.runtime.GraphQlExecutionChain;
|
||||
import dev.caskeleton.adapter.inbound.graphql.runtime.GraphQlOperationSelectionHandler;
|
||||
import dev.caskeleton.adapter.inbound.graphql.runtime.GraphQlPlatformInstrumentation;
|
||||
import dev.caskeleton.adapter.inbound.graphql.runtime.GraphQlPlatformWebInterceptor;
|
||||
import dev.caskeleton.adapter.inbound.graphql.runtime.GraphQlPreparsedDocumentAdapter;
|
||||
import dev.caskeleton.adapter.inbound.graphql.runtime.GraphQlPrincipalResolver;
|
||||
import dev.caskeleton.adapter.inbound.graphql.runtime.GraphQlWireErrorMapper;
|
||||
import dev.caskeleton.adapter.inbound.graphql.runtime.servlet.GraphQlRequestBodyLimitFilter;
|
||||
import dev.caskeleton.adapter.inbound.graphql.scalar.GraphQlScalarWiringConfigurer;
|
||||
import dev.caskeleton.adapter.inbound.graphql.schema.GraphQlMappingInspectionGate;
|
||||
import java.util.Set;
|
||||
import dev.caskeleton.adapter.inbound.graphql.schema.GraphQlScalarDefinition;
|
||||
import dev.caskeleton.adapter.inbound.graphql.schema.GraphQlScalarManifest;
|
||||
import dev.caskeleton.adapter.inbound.graphql.security.GraphQlAuthenticationContextFactory;
|
||||
import dev.caskeleton.adapter.inbound.graphql.security.GraphQlAuthorizationInterceptor;
|
||||
import dev.caskeleton.adapter.inbound.graphql.security.GraphQlAuthorizationPolicy;
|
||||
import graphql.execution.instrumentation.Instrumentation;
|
||||
import graphql.execution.preparsed.PreparsedDocumentEntry;
|
||||
import graphql.schema.idl.SchemaPrinter;
|
||||
import java.time.Clock;
|
||||
import java.util.List;
|
||||
import org.springframework.beans.factory.InitializingBean;
|
||||
import org.springframework.beans.factory.ObjectProvider;
|
||||
import org.springframework.boot.autoconfigure.AutoConfiguration;
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnClass;
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean;
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnWebApplication;
|
||||
import org.springframework.boot.context.properties.EnableConfigurationProperties;
|
||||
import org.springframework.boot.graphql.autoconfigure.GraphQlAutoConfiguration;
|
||||
import org.springframework.boot.graphql.autoconfigure.GraphQlProperties;
|
||||
import org.springframework.boot.graphql.autoconfigure.GraphQlSourceBuilderCustomizer;
|
||||
import org.springframework.boot.web.servlet.FilterRegistrationBean;
|
||||
import org.springframework.context.ApplicationContext;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.core.Ordered;
|
||||
import org.springframework.graphql.execution.DataFetcherExceptionResolver;
|
||||
import org.springframework.graphql.execution.GraphQlSource;
|
||||
import org.springframework.graphql.server.WebGraphQlInterceptor;
|
||||
import org.springframework.stereotype.Controller;
|
||||
import org.springframework.util.ClassUtils;
|
||||
|
||||
/**
|
||||
* Assembles the Stable platform and validates it at startup (Stable plan Task 46).
|
||||
@@ -22,11 +73,30 @@ import org.springframework.context.annotation.Configuration;
|
||||
* <p>Composes Stable capabilities only. Advanced capabilities are opt-in and must never arrive
|
||||
* through this configuration — an Advanced capability that activates because the Stable starter is
|
||||
* on the classpath is exactly the accident the module boundary exists to prevent.
|
||||
*
|
||||
* <p>A real {@code @AutoConfiguration}, registered in {@code AutoConfiguration.imports}, and
|
||||
* ordered after Spring Boot's own GraphQL auto-configuration. Both halves matter: without the
|
||||
* registration the class was named auto-configuration while behaving as an ordinary
|
||||
* {@code @Configuration}, so every {@code @ConditionalOnMissingBean} on it was evaluated before an
|
||||
* adopter's beans existed and silently failed to back off. Without the ordering, this would race
|
||||
* the framework's own {@code GraphQlSource} and schema beans.
|
||||
*/
|
||||
@Configuration(proxyBeanMethods = false)
|
||||
@AutoConfiguration(after = GraphQlAutoConfiguration.class)
|
||||
@EnableConfigurationProperties(GraphQlPlatformProperties.class)
|
||||
public class GraphQlPlatformAutoConfiguration {
|
||||
|
||||
/** Client profile applied to a caller with no verified credential. */
|
||||
public static final String DEFAULT_ANONYMOUS_PROFILE = "anonymous";
|
||||
|
||||
/**
|
||||
* Tenant applied to a caller with no verified credential.
|
||||
*
|
||||
* <p>Declared as {@link TenantContext#system} rather than as a credential-derived tenant, because
|
||||
* that is what it is: no credential was verified, so no tenant was proven. An adopter serving
|
||||
* more than one tenant supplies a principal resolver, and the anonymous path then never runs.
|
||||
*/
|
||||
public static final String DEFAULT_ANONYMOUS_TENANT = "public";
|
||||
|
||||
/** The startup validator. */
|
||||
@Bean
|
||||
@ConditionalOnMissingBean
|
||||
@@ -39,22 +109,377 @@ public class GraphQlPlatformAutoConfiguration {
|
||||
*
|
||||
* <p>An {@code InitializingBean} rather than a listener, so an unsafe configuration fails the
|
||||
* refresh instead of being logged after the application has already begun serving.
|
||||
*
|
||||
* <p>This validates the adopter's configuration, which is the only part that varies at runtime.
|
||||
* The Stable/Advanced module direction is a property of the source tree, not of a deployment, so
|
||||
* it is enforced where it can actually fail — {@code GraphQlModuleBoundaryTest} scans the real
|
||||
* imports at build time. Re-checking a compile-time constant during refresh proved nothing and
|
||||
* cost every adopter a startup-time source scan.
|
||||
*/
|
||||
@Bean
|
||||
public InitializingBean graphQlPlatformConfigurationCheck(
|
||||
GraphQlPlatformProperties properties, GraphQlPlatformStartupValidator validator) {
|
||||
GraphQlPlatformProperties properties,
|
||||
GraphQlPlatformStartupValidator validator,
|
||||
GraphQlExecutionPipeline pipeline,
|
||||
GraphQlScalarWiringConfigurer scalarWiring,
|
||||
GraphQlClientPolicy clientPolicy,
|
||||
ObjectProvider<GraphQlRuntimeTransport> transport,
|
||||
ObjectProvider<GraphQlProperties> frameworkProperties) {
|
||||
return () -> {
|
||||
validator.validate(properties);
|
||||
GraphQlExecutionPipelineValidator.validate(GraphQlExecutionPipeline.stable());
|
||||
verifyNoAdvancedCapabilityOnTheStableStarter();
|
||||
GraphQlProperties framework = frameworkProperties.getIfAvailable();
|
||||
validator.validateRuntime(
|
||||
new GraphQlPlatformRuntime(
|
||||
properties,
|
||||
pipeline,
|
||||
scalarWiring,
|
||||
clientPolicy,
|
||||
transport.getIfAvailable(() -> GraphQlRuntimeTransport.NONE),
|
||||
framework == null ? null : framework.getSchema().getIntrospection().isEnabled(),
|
||||
framework == null ? null : framework.getGraphiql().isEnabled()));
|
||||
};
|
||||
}
|
||||
|
||||
/** The Stable execution pipeline. */
|
||||
/**
|
||||
* Checks the resolvers this application actually registered.
|
||||
*
|
||||
* <p>The boundary rules could only be pointed at a package name, which meant the fixture packages
|
||||
* in this leaf's own tests were the only thing ever checked. An adopter's controllers — the ones
|
||||
* that can actually return an entity or inject a repository — were never inspected by anything.
|
||||
* Reading the context is what closes that: it sees the beans that will serve requests, including
|
||||
* the ones contributed by a library the adopter did not write.
|
||||
*/
|
||||
@Bean
|
||||
public InitializingBean graphQlControllerBoundaryCheck(ApplicationContext context) {
|
||||
return () -> {
|
||||
List<Class<?>> controllers = graphQlControllerClasses(context);
|
||||
if (controllers.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
GraphQlTransportTypeRules.assertTransportTypesOnly(controllers);
|
||||
GraphQlResolverBoundaryRules.assertNoPersistenceAccess(controllers);
|
||||
GraphQlTransportTypeRules.assertRawDataFetcherIsInfrastructureOnly(controllers);
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The user classes of every {@code @Controller} bean that declares a GraphQL mapping.
|
||||
*
|
||||
* <p>Proxies are unwrapped: a transactional or secured controller is registered as a CGLIB
|
||||
* subclass whose declared methods carry no annotations, and inspecting that would find nothing.
|
||||
*/
|
||||
private static List<Class<?>> graphQlControllerClasses(ApplicationContext context) {
|
||||
List<Class<?>> controllers = new java.util.ArrayList<>();
|
||||
for (String beanName : context.getBeanNamesForAnnotation(Controller.class)) {
|
||||
Class<?> beanType = context.getType(beanName);
|
||||
if (beanType == null) {
|
||||
continue;
|
||||
}
|
||||
Class<?> userClass = ClassUtils.getUserClass(beanType);
|
||||
for (java.lang.reflect.Method method : userClass.getDeclaredMethods()) {
|
||||
if (!method.isSynthetic() && GraphQlControllerInspector.isResolver(method)) {
|
||||
controllers.add(userClass);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
return List.copyOf(controllers);
|
||||
}
|
||||
|
||||
/**
|
||||
* Detects a servlet runtime.
|
||||
*
|
||||
* <p>Nested conditional configurations rather than a classpath probe in the validator: this is
|
||||
* exactly the question {@code @ConditionalOnWebApplication} answers, and it answers it the same
|
||||
* way Spring Boot decides which transport to wire.
|
||||
*/
|
||||
@Configuration(proxyBeanMethods = false)
|
||||
@ConditionalOnWebApplication(type = ConditionalOnWebApplication.Type.SERVLET)
|
||||
static class ServletRuntimeDetection {
|
||||
|
||||
@Bean
|
||||
GraphQlRuntimeTransport graphQlRuntimeTransport() {
|
||||
return GraphQlRuntimeTransport.SERVLET;
|
||||
}
|
||||
}
|
||||
|
||||
/** Detects a reactive runtime. */
|
||||
@Configuration(proxyBeanMethods = false)
|
||||
@ConditionalOnWebApplication(type = ConditionalOnWebApplication.Type.REACTIVE)
|
||||
static class ReactiveRuntimeDetection {
|
||||
|
||||
@Bean
|
||||
GraphQlRuntimeTransport graphQlRuntimeTransport() {
|
||||
return GraphQlRuntimeTransport.REACTIVE;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Caps the raw request body upstream of the JSON decoder, on a servlet stack.
|
||||
*
|
||||
* <p>Loaded only where servlets exist. The leaf takes the servlet API as {@code compileOnly}, so
|
||||
* on a reactive or non-web application this class is simply not on the classpath and the
|
||||
* condition never matches — which is why the guard is {@code @ConditionalOnClass} as well as
|
||||
* {@code @ConditionalOnWebApplication}.
|
||||
*/
|
||||
@Configuration(proxyBeanMethods = false)
|
||||
@ConditionalOnClass(jakarta.servlet.Filter.class)
|
||||
@ConditionalOnWebApplication(type = ConditionalOnWebApplication.Type.SERVLET)
|
||||
static class ServletRequestBodyLimit {
|
||||
|
||||
/**
|
||||
* Registers the cap with the highest precedence.
|
||||
*
|
||||
* <p>The whole point is to run before anything reads the body, so it has to precede the
|
||||
* decoder, Spring Security's filters and any application filter that might buffer the request.
|
||||
*/
|
||||
@Bean
|
||||
@ConditionalOnMissingBean
|
||||
FilterRegistrationBean<GraphQlRequestBodyLimitFilter> graphQlRequestBodyLimitFilter(
|
||||
GraphQlClientPolicy clientPolicy, ObjectProvider<GraphQlProperties> frameworkProperties) {
|
||||
|
||||
// The body carries the document, the variables and the extensions plus JSON framing, so the
|
||||
// cap is their sum with room for the envelope rather than any one of them.
|
||||
long maxBodyBytes =
|
||||
(long) clientPolicy.maxDocumentBytes()
|
||||
+ clientPolicy.maxVariablesBytes()
|
||||
+ clientPolicy.maxVariablesBytes()
|
||||
+ ENVELOPE_FRAMING_ALLOWANCE_BYTES;
|
||||
|
||||
// The endpoint path follows the framework when the framework is there. It is optional
|
||||
// because the platform can be assembled without Boot's GraphQL auto-configuration — a slice
|
||||
// test, or a composition root that wires the endpoint itself — and a cap that refuses to
|
||||
// exist in those contexts would make the leaf harder to test than to secure.
|
||||
String path = frameworkProperties.getIfAvailable(GraphQlProperties::new).getHttp().getPath();
|
||||
|
||||
FilterRegistrationBean<GraphQlRequestBodyLimitFilter> registration =
|
||||
new FilterRegistrationBean<>(new GraphQlRequestBodyLimitFilter(path, maxBodyBytes));
|
||||
registration.setOrder(Ordered.HIGHEST_PRECEDENCE);
|
||||
registration.addUrlPatterns(path);
|
||||
return registration;
|
||||
}
|
||||
}
|
||||
|
||||
/** Slack for the JSON envelope around the three sized fields. */
|
||||
static final int ENVELOPE_FRAMING_ALLOWANCE_BYTES = 1024;
|
||||
|
||||
/**
|
||||
* The execution pipeline, derived from the handlers that actually run.
|
||||
*
|
||||
* <p>Derived rather than declared: a hand-written stage list can describe a pipeline the code
|
||||
* does not have, and this one cannot. The startup check above validates this value, so a chain
|
||||
* assembled in the wrong order fails the refresh instead of serving requests.
|
||||
*/
|
||||
@Bean
|
||||
@ConditionalOnMissingBean
|
||||
public GraphQlExecutionPipeline graphQlExecutionPipeline() {
|
||||
return GraphQlExecutionPipeline.stable();
|
||||
public GraphQlExecutionPipeline graphQlExecutionPipeline(GraphQlExecutionChain chain) {
|
||||
return chain.pipeline();
|
||||
}
|
||||
|
||||
/** The clock every deadline and expiry check reads. */
|
||||
@Bean
|
||||
@ConditionalOnMissingBean(name = "graphQlPlatformClock")
|
||||
public Clock graphQlPlatformClock() {
|
||||
return Clock.systemUTC();
|
||||
}
|
||||
|
||||
/**
|
||||
* The limits applied to a request when the adopter has not registered a client policy.
|
||||
*
|
||||
* <p>Page size, complexity and introspection follow {@code backend.graphql.*}; the rest are the
|
||||
* calibration starting points from the design. An adopter replaces this bean rather than editing
|
||||
* a table of constants.
|
||||
*/
|
||||
@Bean
|
||||
@ConditionalOnMissingBean
|
||||
public GraphQlClientPolicy graphQlClientPolicy(GraphQlPlatformProperties properties) {
|
||||
return GraphQlClientPolicy.defaults(
|
||||
properties.limits().maximumPageSize(),
|
||||
properties.limits().maximumComplexity(),
|
||||
properties.console().introspectionEnabled());
|
||||
}
|
||||
|
||||
/**
|
||||
* How a request is authenticated.
|
||||
*
|
||||
* <p>Anonymous by default, because authentication belongs to the composition root. This is not a
|
||||
* permissive default in itself: what an anonymous caller may do is decided by the authorization
|
||||
* policy and the client profile, both of which are checked on every request.
|
||||
*/
|
||||
@Bean
|
||||
@ConditionalOnMissingBean
|
||||
public GraphQlPrincipalResolver graphQlPrincipalResolver() {
|
||||
return GraphQlPrincipalResolver.anonymous();
|
||||
}
|
||||
|
||||
/** The only factory allowed to build a request context. */
|
||||
@Bean
|
||||
@ConditionalOnMissingBean
|
||||
public GraphQlAuthenticationContextFactory graphQlAuthenticationContextFactory(
|
||||
Clock graphQlPlatformClock) {
|
||||
return new GraphQlAuthenticationContextFactory(graphQlPlatformClock);
|
||||
}
|
||||
|
||||
/**
|
||||
* Coordinate authorization rules.
|
||||
*
|
||||
* <p>There is no safe default here, so production does not get one. Coordinate rules are
|
||||
* application knowledge: a deny-by-default skeleton policy would answer nothing and adopters
|
||||
* would replace it with an allow-all one, while an allow-by-default policy shipped into
|
||||
* production would be an unauthorized endpoint. So development gets the permissive default that
|
||||
* lets the schema be explored, and {@code backend.graphql.production=true} refuses to start
|
||||
* without an explicit policy.
|
||||
*/
|
||||
@Bean
|
||||
@ConditionalOnMissingBean
|
||||
public GraphQlAuthorizationPolicy graphQlAuthorizationPolicy(
|
||||
GraphQlPlatformProperties properties) {
|
||||
if (properties.production()) {
|
||||
throw new GraphQlPlatformConfigurationException(
|
||||
List.of(
|
||||
"backend.graphql.production=true requires an explicit GraphQlAuthorizationPolicy "
|
||||
+ "bean; the platform has no application coordinates to authorize on its own"));
|
||||
}
|
||||
return GraphQlAuthorizationPolicy.builder().denyByDefault(false).build();
|
||||
}
|
||||
|
||||
/** Registered field costs; unregistered coordinates fall back to a conservative weight. */
|
||||
@Bean
|
||||
@ConditionalOnMissingBean
|
||||
public GraphQlCostCatalog graphQlCostCatalog() {
|
||||
return GraphQlCostCatalog.of();
|
||||
}
|
||||
|
||||
/** Document structure measurement. */
|
||||
@Bean
|
||||
@ConditionalOnMissingBean
|
||||
public GraphQlDocumentShapeAnalyzer graphQlDocumentShapeAnalyzer() {
|
||||
return new GraphQlDocumentShapeAnalyzer();
|
||||
}
|
||||
|
||||
/** Per-field pricing, driven by the page policy the client policy declares. */
|
||||
@Bean
|
||||
@ConditionalOnMissingBean
|
||||
public GraphQlComplexityCalculator graphQlComplexityCalculator(
|
||||
GraphQlCostCatalog catalog, GraphQlClientPolicy clientPolicy) {
|
||||
return new GraphQlComplexityCalculator(
|
||||
catalog, clientPolicy.defaultPageSize(), clientPolicy.maxPageSize());
|
||||
}
|
||||
|
||||
/** Structural ceilings derived from the client policy. */
|
||||
@Bean
|
||||
@ConditionalOnMissingBean
|
||||
public GraphQlStructuralLimitPolicy graphQlStructuralLimitPolicy(
|
||||
GraphQlClientPolicy clientPolicy) {
|
||||
return new GraphQlStructuralLimitPolicy(GraphQlStructuralLimits.from(clientPolicy));
|
||||
}
|
||||
|
||||
/** The executable chain: select the operation, authorize it, judge its cost. */
|
||||
@Bean
|
||||
@ConditionalOnMissingBean
|
||||
public GraphQlExecutionChain graphQlExecutionChain(
|
||||
GraphQlClientPolicy clientPolicy,
|
||||
GraphQlAuthorizationPolicy authorizationPolicy,
|
||||
GraphQlDocumentShapeAnalyzer analyzer,
|
||||
GraphQlStructuralLimitPolicy structuralLimits,
|
||||
GraphQlComplexityCalculator calculator,
|
||||
Clock graphQlPlatformClock) {
|
||||
return GraphQlExecutionChain.stable(
|
||||
new GraphQlOperationSelectionHandler(clientPolicy),
|
||||
new GraphQlDocumentAuthorizationHandler(
|
||||
new GraphQlAuthorizationInterceptor(authorizationPolicy), analyzer, clientPolicy),
|
||||
new GraphQlCostBudgetHandler(
|
||||
analyzer, structuralLimits, calculator, clientPolicy, graphQlPlatformClock));
|
||||
}
|
||||
|
||||
/**
|
||||
* Puts the chain on the real execution path.
|
||||
*
|
||||
* <p>Spring for GraphQL picks up every {@code Instrumentation} bean, so this is what turns the
|
||||
* policy objects from a catalogue into something a request has to pass.
|
||||
*/
|
||||
@Bean
|
||||
@ConditionalOnMissingBean(GraphQlPlatformInstrumentation.class)
|
||||
public Instrumentation graphQlPlatformInstrumentation(GraphQlExecutionChain chain) {
|
||||
return new GraphQlPlatformInstrumentation(chain);
|
||||
}
|
||||
|
||||
/**
|
||||
* The application's deliberate failure mappings.
|
||||
*
|
||||
* <p>Empty by default, which means every unrecognised failure is masked. An adopter replaces this
|
||||
* bean to give its own modelled failures a stable code rather than an opaque internal error.
|
||||
*/
|
||||
@Bean
|
||||
@ConditionalOnMissingBean
|
||||
public GraphQlExceptionResolver graphQlApplicationExceptionMappings() {
|
||||
return GraphQlExceptionResolver.defaults();
|
||||
}
|
||||
|
||||
/** The single vocabulary every client-visible error is produced from. */
|
||||
@Bean
|
||||
@ConditionalOnMissingBean
|
||||
public GraphQlWireErrorMapper graphQlWireErrorMapper(GraphQlExceptionResolver mappings) {
|
||||
return new GraphQlWireErrorMapper(mappings);
|
||||
}
|
||||
|
||||
/** Adapts the mapper onto Spring's data-fetcher exception contract. */
|
||||
@Bean
|
||||
@ConditionalOnMissingBean(GraphQlDataFetcherExceptionResolver.class)
|
||||
public DataFetcherExceptionResolver graphQlDataFetcherExceptionResolver(
|
||||
GraphQlWireErrorMapper mapper) {
|
||||
return new GraphQlDataFetcherExceptionResolver(mapper);
|
||||
}
|
||||
|
||||
/** Shape ceilings for decoded {@code variables} and {@code extensions}. */
|
||||
@Bean
|
||||
@ConditionalOnMissingBean
|
||||
public GraphQlJsonStructurePolicy graphQlJsonStructurePolicy(GraphQlClientPolicy clientPolicy) {
|
||||
return GraphQlJsonStructurePolicy.from(clientPolicy);
|
||||
}
|
||||
|
||||
/** Establishes the request context on the real {@code /graphql} endpoint. */
|
||||
@Bean
|
||||
@ConditionalOnMissingBean(GraphQlPlatformWebInterceptor.class)
|
||||
public WebGraphQlInterceptor graphQlPlatformWebInterceptor(
|
||||
GraphQlPrincipalResolver principalResolver,
|
||||
GraphQlAuthenticationContextFactory contextFactory,
|
||||
GraphQlClientPolicy clientPolicy,
|
||||
GraphQlJsonStructurePolicy structurePolicy,
|
||||
GraphQlPlatformProperties properties,
|
||||
Clock graphQlPlatformClock) {
|
||||
return new GraphQlPlatformWebInterceptor(
|
||||
principalResolver,
|
||||
contextFactory,
|
||||
clientPolicy,
|
||||
structurePolicy,
|
||||
new GraphQlClientProfile(DEFAULT_ANONYMOUS_PROFILE),
|
||||
TenantContext.system(DEFAULT_ANONYMOUS_TENANT),
|
||||
properties.production(),
|
||||
graphQlPlatformClock);
|
||||
}
|
||||
|
||||
/** The approved scalar set; the wiring configurer refuses to wire anything absent from it. */
|
||||
@Bean
|
||||
@ConditionalOnMissingBean
|
||||
public GraphQlScalarManifest graphQlScalarManifest() {
|
||||
return GraphQlScalarManifest.of(
|
||||
GraphQlScalarWiringConfigurer.stableScalars().keySet().stream()
|
||||
.map(GraphQlScalarDefinition::named)
|
||||
.toArray(GraphQlScalarDefinition[]::new));
|
||||
}
|
||||
|
||||
/**
|
||||
* Registers the approved custom scalars through Spring's supported wiring entry point.
|
||||
*
|
||||
* <p>Declared as the concrete type rather than as {@code RuntimeWiringConfigurer} so the startup
|
||||
* check can ask it which scalars it will wire. Spring still picks it up as a configurer.
|
||||
*/
|
||||
@Bean
|
||||
@ConditionalOnMissingBean
|
||||
public GraphQlScalarWiringConfigurer graphQlScalarWiringConfigurer(
|
||||
GraphQlScalarManifest manifest) {
|
||||
return new GraphQlScalarWiringConfigurer(manifest);
|
||||
}
|
||||
|
||||
/** The Stable schema mapping gate. */
|
||||
@@ -83,8 +508,22 @@ public class GraphQlPlatformAutoConfiguration {
|
||||
@Bean
|
||||
@ConditionalOnMissingBean
|
||||
public GraphQlRequestObservationConvention graphQlRequestObservationConvention(
|
||||
GraphQlSensitiveAttributeFilter filter) {
|
||||
return new GraphQlRequestObservationConvention(filter);
|
||||
GraphQlSensitiveAttributeFilter filter, GraphQlOperationNameCardinality operationNames) {
|
||||
return new GraphQlRequestObservationConvention(filter, operationNames);
|
||||
}
|
||||
|
||||
/**
|
||||
* Which operation names may become metric labels.
|
||||
*
|
||||
* <p>Defaults to the deployment's declared list, which is empty unless configured. Empty means
|
||||
* every named operation collapses to one label: correct by default, and legible for any adopter
|
||||
* who names the operations they care about.
|
||||
*/
|
||||
@Bean
|
||||
@ConditionalOnMissingBean
|
||||
public GraphQlOperationNameCardinality graphQlOperationNameCardinality(
|
||||
GraphQlPlatformProperties properties) {
|
||||
return new GraphQlOperationNameCardinality(properties.observedOperationNames());
|
||||
}
|
||||
|
||||
/** The resolver observation convention. */
|
||||
@@ -103,16 +542,58 @@ public class GraphQlPlatformAutoConfiguration {
|
||||
return new GraphQlDataLoaderObservationConvention(filter);
|
||||
}
|
||||
|
||||
private static void verifyNoAdvancedCapabilityOnTheStableStarter() {
|
||||
Set<String> advanced = GraphQlBuildModel.advancedModules();
|
||||
GraphQlBuildModel.stableDependencyEdges()
|
||||
.forEach(
|
||||
(module, dependencies) -> {
|
||||
if (dependencies.stream().anyMatch(advanced::contains)) {
|
||||
throw new GraphQlPlatformConfigurationException(
|
||||
java.util.List.of(
|
||||
"stable module " + module + " depends on an advanced capability"));
|
||||
}
|
||||
});
|
||||
/**
|
||||
* The preparsed document cache bounds.
|
||||
*
|
||||
* <p>Idle expiry and both size bounds come from configuration rather than from a constant, so an
|
||||
* adopter whose documents are large can trade entries for weight without forking the platform.
|
||||
*/
|
||||
@Bean
|
||||
@ConditionalOnMissingBean
|
||||
public GraphQlPreparsedCachePolicy graphQlPreparsedCachePolicy(
|
||||
GraphQlPlatformProperties properties) {
|
||||
return new GraphQlPreparsedCachePolicy(
|
||||
properties.limits().preparsedCacheEntries(),
|
||||
properties.limits().preparsedCacheWeight(),
|
||||
properties.limits().preparsedCacheExpireAfterAccess());
|
||||
}
|
||||
|
||||
/** The bounded parse/validate cache. */
|
||||
@Bean
|
||||
@ConditionalOnMissingBean
|
||||
public BoundedPreparsedDocumentProvider<PreparsedDocumentEntry> graphQlPreparsedDocumentCache(
|
||||
GraphQlPreparsedCachePolicy policy, Clock clock) {
|
||||
return new BoundedPreparsedDocumentProvider<>(
|
||||
policy, new GraphQlPreparsedCacheMetrics(), clock);
|
||||
}
|
||||
|
||||
/**
|
||||
* Hands the bounded cache to graphql-java, which is the only thing that consults one.
|
||||
*
|
||||
* <p>The schema hash is resolved through an {@code ObjectProvider} on first use: this customizer
|
||||
* runs while the {@code GraphQlSource} is still being built, so asking for the schema here would
|
||||
* be asking for the bean currently under construction.
|
||||
*/
|
||||
@Bean
|
||||
@ConditionalOnMissingBean(name = "graphQlPreparsedDocumentCustomizer")
|
||||
public GraphQlSourceBuilderCustomizer graphQlPreparsedDocumentCustomizer(
|
||||
BoundedPreparsedDocumentProvider<PreparsedDocumentEntry> cache,
|
||||
ObjectProvider<GraphQlSource> graphQlSource,
|
||||
GraphQlPlatformProperties properties) {
|
||||
GraphQlPreparsedDocumentAdapter adapter =
|
||||
new GraphQlPreparsedDocumentAdapter(
|
||||
cache, () -> schemaContractHash(graphQlSource), properties.validationPolicyVersion());
|
||||
return builder ->
|
||||
builder.configureGraphQl(graphQl -> graphQl.preparsedDocumentProvider(adapter));
|
||||
}
|
||||
|
||||
private static String schemaContractHash(ObjectProvider<GraphQlSource> graphQlSource) {
|
||||
GraphQlSource source = graphQlSource.getIfAvailable();
|
||||
if (source == null) {
|
||||
// A cache keyed on an unknown schema would survive a schema change, which is the one thing
|
||||
// the schema part of the key exists to prevent. Partition it instead of guessing.
|
||||
return "schema-unavailable";
|
||||
}
|
||||
return GraphQlPreparsedDocumentAdapter.sha256(new SchemaPrinter().print(source.schema()));
|
||||
}
|
||||
}
|
||||
|
||||
+200
-96
@@ -1,12 +1,20 @@
|
||||
package dev.caskeleton.adapter.inbound.graphql.autoconfigure;
|
||||
|
||||
import dev.caskeleton.adapter.inbound.graphql.execution.GraphQlExecutionProfile;
|
||||
import java.time.Duration;
|
||||
import java.util.Set;
|
||||
import org.springframework.boot.context.properties.ConfigurationProperties;
|
||||
import org.springframework.boot.context.properties.bind.DefaultValue;
|
||||
|
||||
/**
|
||||
* The platform's configuration surface (design §23).
|
||||
*
|
||||
* <p>Every default is declared with {@link DefaultValue}, which is the only kind of default the
|
||||
* binder actually applies. A primitive with no annotation binds to zero, and this record's zeros —
|
||||
* a page size of nothing, a complexity budget of nothing — are exactly the values the startup
|
||||
* validator refuses, so the platform used to refuse to start until an operator supplied two numbers
|
||||
* that have perfectly good defaults.
|
||||
*
|
||||
* <p>The unsupported capabilities appear here as explicit flags rather than being absent. A
|
||||
* deployment that tries to enable multipart upload, HTTP array batching, a request-wide transaction
|
||||
* or automatic repository exposure should fail at startup with a clear reason — silently ignoring
|
||||
@@ -15,84 +23,202 @@ import org.springframework.boot.context.properties.ConfigurationProperties;
|
||||
* @param production whether production rules apply
|
||||
* @param environment environment governing introspection and GraphiQL
|
||||
* @param executionProfile runtime execution profile
|
||||
* @param graphiqlEnabled whether GraphiQL is served
|
||||
* @param introspectionEnabled whether introspection is answered
|
||||
* @param maximumPageSize largest connection page size
|
||||
* @param maximumComplexity largest accepted complexity score
|
||||
* @param cursorKeyIds signing key identities for cursors
|
||||
* @param multipartUploadEnabled unsupported; must stay false
|
||||
* @param httpArrayBatchEnabled unsupported; must stay false
|
||||
* @param requestWideTransactionEnabled unsupported; must stay false
|
||||
* @param repositoryAutoExposureEnabled unsupported outside the Advanced compatibility capability
|
||||
* @param responseCacheEnabled unsupported; must stay false
|
||||
* @param advancedCapabilitiesOnStableStarter whether Advanced modules leaked into the Stable
|
||||
* starter
|
||||
* @param console query console and introspection exposure
|
||||
* @param limits page and cost ceilings
|
||||
* @param cursor cursor signing key ring
|
||||
* @param unsupported capabilities this platform deliberately does not implement
|
||||
* @param unbridgedBlockingResolvers resolvers that block without an approved bridge
|
||||
*/
|
||||
@ConfigurationProperties("backend.graphql")
|
||||
public record GraphQlPlatformProperties(
|
||||
boolean production,
|
||||
GraphQlPlatformEnvironment environment,
|
||||
GraphQlExecutionProfile executionProfile,
|
||||
boolean graphiqlEnabled,
|
||||
boolean introspectionEnabled,
|
||||
int maximumPageSize,
|
||||
long maximumComplexity,
|
||||
Set<String> cursorKeyIds,
|
||||
boolean multipartUploadEnabled,
|
||||
boolean httpArrayBatchEnabled,
|
||||
boolean requestWideTransactionEnabled,
|
||||
boolean repositoryAutoExposureEnabled,
|
||||
boolean responseCacheEnabled,
|
||||
boolean advancedCapabilitiesOnStableStarter,
|
||||
Set<String> unbridgedBlockingResolvers) {
|
||||
@DefaultValue("false") boolean production,
|
||||
@DefaultValue("PRODUCTION_PUBLIC") GraphQlPlatformEnvironment environment,
|
||||
@DefaultValue("BLOCKING_MVC") GraphQlExecutionProfile executionProfile,
|
||||
@DefaultValue Console console,
|
||||
@DefaultValue Limits limits,
|
||||
@DefaultValue Cursor cursor,
|
||||
@DefaultValue Unsupported unsupported,
|
||||
@DefaultValue("v1") String validationPolicyVersion,
|
||||
@DefaultValue Set<String> observedOperationNames,
|
||||
@DefaultValue Set<String> unbridgedBlockingResolvers) {
|
||||
|
||||
public GraphQlPlatformProperties {
|
||||
// Belt and braces for programmatic construction: the binder honours @DefaultValue, but this
|
||||
// record is also built directly in tests and by adopters composing a policy in Java.
|
||||
environment = environment == null ? GraphQlPlatformEnvironment.PRODUCTION_PUBLIC : environment;
|
||||
executionProfile =
|
||||
executionProfile == null ? GraphQlExecutionProfile.BLOCKING_MVC : executionProfile;
|
||||
cursorKeyIds = cursorKeyIds == null ? Set.of() : Set.copyOf(cursorKeyIds);
|
||||
console = console == null ? Console.disabled() : console;
|
||||
limits = limits == null ? Limits.defaults() : limits;
|
||||
cursor = cursor == null ? Cursor.none() : cursor;
|
||||
unsupported = unsupported == null ? Unsupported.none() : unsupported;
|
||||
// Part of the preparsed cache key: bumping it invalidates every cached validation, which is
|
||||
// what an adopter needs when their own validation rules change without the schema changing.
|
||||
validationPolicyVersion =
|
||||
validationPolicyVersion == null || validationPolicyVersion.isBlank()
|
||||
? "v1"
|
||||
: validationPolicyVersion;
|
||||
// The operation names allowed to become metric labels. Empty collapses them all, which is the
|
||||
// safe default: a cardinality bound that has to be switched on is one nobody has switched on.
|
||||
observedOperationNames =
|
||||
observedOperationNames == null ? Set.of() : Set.copyOf(observedOperationNames);
|
||||
unbridgedBlockingResolvers =
|
||||
unbridgedBlockingResolvers == null ? Set.of() : Set.copyOf(unbridgedBlockingResolvers);
|
||||
}
|
||||
|
||||
/**
|
||||
* Query console and schema disclosure.
|
||||
*
|
||||
* @param graphiqlEnabled whether GraphiQL is served
|
||||
* @param introspectionEnabled whether introspection is answered
|
||||
*/
|
||||
public record Console(
|
||||
@DefaultValue("false") boolean graphiqlEnabled,
|
||||
@DefaultValue("false") boolean introspectionEnabled) {
|
||||
|
||||
/**
|
||||
* Neither the console nor introspection, which is the only safe default for an unknown host.
|
||||
*/
|
||||
public static Console disabled() {
|
||||
return new Console(false, false);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Page, cost and cache ceilings.
|
||||
*
|
||||
* @param maximumPageSize largest connection page size
|
||||
* @param maximumComplexity largest accepted pre-execution complexity score
|
||||
* @param preparsedCacheEntries largest number of cached parsed documents
|
||||
* @param preparsedCacheWeight largest total cached document weight, in characters
|
||||
* @param preparsedCacheExpireAfterAccess how long an unused cached document is kept
|
||||
*/
|
||||
public record Limits(
|
||||
@DefaultValue("100") int maximumPageSize,
|
||||
@DefaultValue("10000") long maximumComplexity,
|
||||
@DefaultValue("1000") long preparsedCacheEntries,
|
||||
@DefaultValue("10000000") long preparsedCacheWeight,
|
||||
@DefaultValue("30m") Duration preparsedCacheExpireAfterAccess) {
|
||||
|
||||
/** The declared defaults, for programmatic construction. */
|
||||
public static Limits defaults() {
|
||||
return new Limits(100, 10_000, 1_000, 10_000_000, Duration.ofMinutes(30));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The cursor signing key ring.
|
||||
*
|
||||
* @param keyIds signing key identities; the keys themselves never appear in configuration
|
||||
*/
|
||||
public record Cursor(@DefaultValue Set<String> keyIds) {
|
||||
|
||||
public Cursor {
|
||||
keyIds = keyIds == null ? Set.of() : Set.copyOf(keyIds);
|
||||
}
|
||||
|
||||
/** An empty key ring, which production refuses to start with. */
|
||||
public static Cursor none() {
|
||||
return new Cursor(Set.of());
|
||||
}
|
||||
|
||||
/** A key ring with the given identities. */
|
||||
public static Cursor of(Set<String> keyIds) {
|
||||
return new Cursor(keyIds);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Capabilities the platform does not implement.
|
||||
*
|
||||
* <p>Present as flags so enabling one fails the boot instead of being ignored.
|
||||
*
|
||||
* @param multipartUpload GraphQL multipart upload
|
||||
* @param httpArrayBatch HTTP array batching
|
||||
* @param requestWideTransaction one database transaction spanning a whole request
|
||||
* @param repositoryAutoExposure automatic repository exposure as GraphQL fields
|
||||
* @param responseCache cross-request response caching
|
||||
* @param advancedCapabilitiesOnStableStarter Advanced modules reachable from the Stable starter
|
||||
*/
|
||||
public record Unsupported(
|
||||
@DefaultValue("false") boolean multipartUpload,
|
||||
@DefaultValue("false") boolean httpArrayBatch,
|
||||
@DefaultValue("false") boolean requestWideTransaction,
|
||||
@DefaultValue("false") boolean repositoryAutoExposure,
|
||||
@DefaultValue("false") boolean responseCache,
|
||||
@DefaultValue("false") boolean advancedCapabilitiesOnStableStarter) {
|
||||
|
||||
/** Nothing unsupported enabled. */
|
||||
public static Unsupported none() {
|
||||
return new Unsupported(false, false, false, false, false, false);
|
||||
}
|
||||
|
||||
/** Returns a copy with one capability toggled, for startup-validation tests. */
|
||||
public Unsupported with(String capability, boolean enabled) {
|
||||
return new Unsupported(
|
||||
"multipart".equals(capability) ? enabled : multipartUpload,
|
||||
"arrayBatch".equals(capability) ? enabled : httpArrayBatch,
|
||||
"requestWideTransaction".equals(capability) ? enabled : requestWideTransaction,
|
||||
"repositoryAutoExposure".equals(capability) ? enabled : repositoryAutoExposure,
|
||||
"responseCache".equals(capability) ? enabled : responseCache,
|
||||
"advancedOnStableStarter".equals(capability)
|
||||
? enabled
|
||||
: advancedCapabilitiesOnStableStarter);
|
||||
}
|
||||
}
|
||||
|
||||
/** Safe production defaults. */
|
||||
public static GraphQlPlatformProperties productionDefaults() {
|
||||
return new GraphQlPlatformProperties(
|
||||
true,
|
||||
GraphQlPlatformEnvironment.PRODUCTION_INTERNAL,
|
||||
GraphQlExecutionProfile.BLOCKING_MVC,
|
||||
false,
|
||||
false,
|
||||
100,
|
||||
10_000,
|
||||
Set.of("cursor-key-1"),
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
Console.disabled(),
|
||||
Limits.defaults(),
|
||||
Cursor.of(Set.of("cursor-key-1")),
|
||||
Unsupported.none(),
|
||||
"v1",
|
||||
Set.of(),
|
||||
Set.of());
|
||||
}
|
||||
|
||||
/** Returns a copy with GraphiQL enabled or disabled. */
|
||||
public GraphQlPlatformProperties withGraphiqlEnabled(boolean enabled) {
|
||||
return withConsole(new Console(enabled, console.introspectionEnabled()));
|
||||
}
|
||||
|
||||
/** Returns a copy with introspection enabled or disabled. */
|
||||
public GraphQlPlatformProperties withIntrospectionEnabled(boolean enabled) {
|
||||
return withConsole(new Console(console.graphiqlEnabled(), enabled));
|
||||
}
|
||||
|
||||
/** Returns a copy with a different console policy. */
|
||||
public GraphQlPlatformProperties withConsole(Console replacement) {
|
||||
return new GraphQlPlatformProperties(
|
||||
production,
|
||||
environment,
|
||||
executionProfile,
|
||||
enabled,
|
||||
introspectionEnabled,
|
||||
maximumPageSize,
|
||||
maximumComplexity,
|
||||
cursorKeyIds,
|
||||
multipartUploadEnabled,
|
||||
httpArrayBatchEnabled,
|
||||
requestWideTransactionEnabled,
|
||||
repositoryAutoExposureEnabled,
|
||||
responseCacheEnabled,
|
||||
advancedCapabilitiesOnStableStarter,
|
||||
replacement,
|
||||
limits,
|
||||
cursor,
|
||||
unsupported,
|
||||
validationPolicyVersion,
|
||||
observedOperationNames,
|
||||
unbridgedBlockingResolvers);
|
||||
}
|
||||
|
||||
/** Returns a copy with different limits. */
|
||||
public GraphQlPlatformProperties withLimits(Limits replacement) {
|
||||
return new GraphQlPlatformProperties(
|
||||
production,
|
||||
environment,
|
||||
executionProfile,
|
||||
console,
|
||||
replacement,
|
||||
cursor,
|
||||
unsupported,
|
||||
validationPolicyVersion,
|
||||
observedOperationNames,
|
||||
unbridgedBlockingResolvers);
|
||||
}
|
||||
|
||||
@@ -102,17 +228,12 @@ public record GraphQlPlatformProperties(
|
||||
production,
|
||||
environment,
|
||||
executionProfile,
|
||||
graphiqlEnabled,
|
||||
introspectionEnabled,
|
||||
maximumPageSize,
|
||||
maximumComplexity,
|
||||
Set.copyOf(keyIds),
|
||||
multipartUploadEnabled,
|
||||
httpArrayBatchEnabled,
|
||||
requestWideTransactionEnabled,
|
||||
repositoryAutoExposureEnabled,
|
||||
responseCacheEnabled,
|
||||
advancedCapabilitiesOnStableStarter,
|
||||
console,
|
||||
limits,
|
||||
Cursor.of(keyIds),
|
||||
unsupported,
|
||||
validationPolicyVersion,
|
||||
observedOperationNames,
|
||||
unbridgedBlockingResolvers);
|
||||
}
|
||||
|
||||
@@ -122,19 +243,12 @@ public record GraphQlPlatformProperties(
|
||||
production,
|
||||
environment,
|
||||
executionProfile,
|
||||
graphiqlEnabled,
|
||||
introspectionEnabled,
|
||||
maximumPageSize,
|
||||
maximumComplexity,
|
||||
cursorKeyIds,
|
||||
"multipart".equals(capability) ? enabled : multipartUploadEnabled,
|
||||
"arrayBatch".equals(capability) ? enabled : httpArrayBatchEnabled,
|
||||
"requestWideTransaction".equals(capability) ? enabled : requestWideTransactionEnabled,
|
||||
"repositoryAutoExposure".equals(capability) ? enabled : repositoryAutoExposureEnabled,
|
||||
"responseCache".equals(capability) ? enabled : responseCacheEnabled,
|
||||
"advancedOnStableStarter".equals(capability)
|
||||
? enabled
|
||||
: advancedCapabilitiesOnStableStarter,
|
||||
console,
|
||||
limits,
|
||||
cursor,
|
||||
unsupported.with(capability, enabled),
|
||||
validationPolicyVersion,
|
||||
observedOperationNames,
|
||||
unbridgedBlockingResolvers);
|
||||
}
|
||||
|
||||
@@ -144,17 +258,12 @@ public record GraphQlPlatformProperties(
|
||||
production,
|
||||
environment,
|
||||
executionProfile,
|
||||
graphiqlEnabled,
|
||||
introspectionEnabled,
|
||||
maximumPageSize,
|
||||
maximumComplexity,
|
||||
cursorKeyIds,
|
||||
multipartUploadEnabled,
|
||||
httpArrayBatchEnabled,
|
||||
requestWideTransactionEnabled,
|
||||
repositoryAutoExposureEnabled,
|
||||
responseCacheEnabled,
|
||||
advancedCapabilitiesOnStableStarter,
|
||||
console,
|
||||
limits,
|
||||
cursor,
|
||||
unsupported,
|
||||
validationPolicyVersion,
|
||||
observedOperationNames,
|
||||
Set.copyOf(coordinates));
|
||||
}
|
||||
|
||||
@@ -164,17 +273,12 @@ public record GraphQlPlatformProperties(
|
||||
production,
|
||||
environment,
|
||||
profile,
|
||||
graphiqlEnabled,
|
||||
introspectionEnabled,
|
||||
maximumPageSize,
|
||||
maximumComplexity,
|
||||
cursorKeyIds,
|
||||
multipartUploadEnabled,
|
||||
httpArrayBatchEnabled,
|
||||
requestWideTransactionEnabled,
|
||||
repositoryAutoExposureEnabled,
|
||||
responseCacheEnabled,
|
||||
advancedCapabilitiesOnStableStarter,
|
||||
console,
|
||||
limits,
|
||||
cursor,
|
||||
unsupported,
|
||||
validationPolicyVersion,
|
||||
observedOperationNames,
|
||||
unbridgedBlockingResolvers);
|
||||
}
|
||||
}
|
||||
|
||||
+43
@@ -0,0 +1,43 @@
|
||||
package dev.caskeleton.adapter.inbound.graphql.autoconfigure;
|
||||
|
||||
import dev.caskeleton.adapter.inbound.graphql.execution.GraphQlExecutionPipeline;
|
||||
import dev.caskeleton.adapter.inbound.graphql.policy.GraphQlClientPolicy;
|
||||
import dev.caskeleton.adapter.inbound.graphql.scalar.GraphQlScalarWiringConfigurer;
|
||||
import java.util.Objects;
|
||||
|
||||
/**
|
||||
* What the context actually assembled, gathered so it can be validated as one thing.
|
||||
*
|
||||
* <p>The platform's beans are all replaceable, which is the point of
|
||||
* {@code @ConditionalOnMissingBean} — and also the risk. An adopter can supply a pipeline with
|
||||
* authorization after execution, a scalar manifest naming a scalar with no coercion, or a client
|
||||
* policy whose page ceiling is higher than the one the operator configured. None of those fail at
|
||||
* wiring time; they fail at request time, in production, quietly.
|
||||
*
|
||||
* @param properties the bound configuration
|
||||
* @param pipeline the pipeline that will run, derived from the registered handler chain
|
||||
* @param scalarWiring the scalar wiring that will be applied to the schema
|
||||
* @param clientPolicy the limits a request will be measured against
|
||||
* @param transport the server this context is actually running on
|
||||
* @param frameworkIntrospectionEnabled {@code spring.graphql.schema.introspection.enabled}, or
|
||||
* {@code null} when the framework properties are not on the classpath
|
||||
* @param frameworkGraphiqlEnabled {@code spring.graphql.graphiql.enabled}, or {@code null} when the
|
||||
* framework properties are not on the classpath
|
||||
*/
|
||||
public record GraphQlPlatformRuntime(
|
||||
GraphQlPlatformProperties properties,
|
||||
GraphQlExecutionPipeline pipeline,
|
||||
GraphQlScalarWiringConfigurer scalarWiring,
|
||||
GraphQlClientPolicy clientPolicy,
|
||||
GraphQlRuntimeTransport transport,
|
||||
Boolean frameworkIntrospectionEnabled,
|
||||
Boolean frameworkGraphiqlEnabled) {
|
||||
|
||||
public GraphQlPlatformRuntime {
|
||||
Objects.requireNonNull(properties, "properties are required");
|
||||
Objects.requireNonNull(pipeline, "pipeline is required");
|
||||
Objects.requireNonNull(scalarWiring, "scalar wiring is required");
|
||||
Objects.requireNonNull(clientPolicy, "client policy is required");
|
||||
transport = transport == null ? GraphQlRuntimeTransport.NONE : transport;
|
||||
}
|
||||
}
|
||||
+113
-11
@@ -32,42 +32,43 @@ public final class GraphQlPlatformStartupValidator {
|
||||
|
||||
// Reported once even when both the production flag and the environment forbid it, so a single
|
||||
// misconfiguration does not appear as two problems.
|
||||
if (properties.graphiqlEnabled()
|
||||
if (properties.console().graphiqlEnabled()
|
||||
&& (properties.production() || !properties.environment().graphiqlAllowed())) {
|
||||
problems.add(
|
||||
"GraphiQL must not be enabled in " + properties.environment() + " or in production");
|
||||
}
|
||||
if (properties.production() && properties.cursorKeyIds().isEmpty()) {
|
||||
if (properties.production() && properties.cursor().keyIds().isEmpty()) {
|
||||
problems.add("a cursor signing key is required; unsigned cursors are client-editable");
|
||||
}
|
||||
if (properties.introspectionEnabled() && !properties.environment().introspectionAllowed()) {
|
||||
if (properties.console().introspectionEnabled()
|
||||
&& !properties.environment().introspectionAllowed()) {
|
||||
problems.add("introspection is not permitted in " + properties.environment());
|
||||
}
|
||||
if (properties.maximumPageSize() < 1) {
|
||||
if (properties.limits().maximumPageSize() < 1) {
|
||||
problems.add("maximum page size must be positive");
|
||||
}
|
||||
if (properties.maximumComplexity() < 1) {
|
||||
if (properties.limits().maximumComplexity() < 1) {
|
||||
problems.add("maximum complexity must be positive");
|
||||
}
|
||||
|
||||
if (properties.multipartUploadEnabled()) {
|
||||
if (properties.unsupported().multipartUpload()) {
|
||||
problems.add(
|
||||
"GraphQL multipart upload is unsupported; use the Fileserver upload reservation");
|
||||
}
|
||||
if (properties.httpArrayBatchEnabled()) {
|
||||
if (properties.unsupported().httpArrayBatch()) {
|
||||
problems.add("HTTP array batching is unsupported");
|
||||
}
|
||||
if (properties.requestWideTransactionEnabled()) {
|
||||
if (properties.unsupported().requestWideTransaction()) {
|
||||
problems.add("request-wide database transactions are unsupported; use one mutation use case");
|
||||
}
|
||||
if (properties.repositoryAutoExposureEnabled()) {
|
||||
if (properties.unsupported().repositoryAutoExposure()) {
|
||||
problems.add(
|
||||
"automatic repository exposure is unsupported outside the Advanced compatibility capability");
|
||||
}
|
||||
if (properties.responseCacheEnabled()) {
|
||||
if (properties.unsupported().responseCache()) {
|
||||
problems.add("response caching is unsupported until an actor/tenant cache key model exists");
|
||||
}
|
||||
if (properties.advancedCapabilitiesOnStableStarter()) {
|
||||
if (properties.unsupported().advancedCapabilitiesOnStableStarter()) {
|
||||
problems.add("the Stable starter must not activate Advanced capabilities");
|
||||
}
|
||||
if (properties.executionProfile() == GraphQlExecutionProfile.REACTIVE_WEBFLUX
|
||||
@@ -78,4 +79,105 @@ public final class GraphQlPlatformStartupValidator {
|
||||
}
|
||||
return List.copyOf(problems);
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates the beans the context actually assembled, not the ones the platform ships.
|
||||
*
|
||||
* @throws GraphQlPlatformConfigurationException listing every problem found
|
||||
*/
|
||||
public void validateRuntime(GraphQlPlatformRuntime runtime) {
|
||||
List<String> problems = runtimeProblems(runtime);
|
||||
if (!problems.isEmpty()) {
|
||||
throw new GraphQlPlatformConfigurationException(problems);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Problems with the assembled runtime, in a deterministic order.
|
||||
*
|
||||
* <p>Every check here is on an injected bean rather than on a platform constant. Validating
|
||||
* {@code GraphQlExecutionPipeline.stable()} would prove the platform's own default is well formed
|
||||
* and say nothing about the pipeline an adopter actually replaced it with — which is the only one
|
||||
* that will serve requests.
|
||||
*/
|
||||
public List<String> runtimeProblems(GraphQlPlatformRuntime runtime) {
|
||||
List<String> problems = new ArrayList<>();
|
||||
|
||||
problems.addAll(
|
||||
dev.caskeleton.adapter.inbound.graphql.execution.GraphQlExecutionPipelineValidator.problems(
|
||||
runtime.pipeline()));
|
||||
|
||||
if (!runtime.transport().supports(runtime.properties().executionProfile())) {
|
||||
problems.add(
|
||||
"backend.graphql.execution-profile is "
|
||||
+ runtime.properties().executionProfile()
|
||||
+ " but this context runs on "
|
||||
+ runtime.transport()
|
||||
+ "; the composition root chooses the server and the two must agree");
|
||||
}
|
||||
// Independent of the declared profile: on an event loop, a resolver that blocks without a
|
||||
// bridge stalls every other request sharing the thread. MIXED_CONTROLLED is accepted on a
|
||||
// reactive transport precisely because the crossings are declared, so an undeclared one is the
|
||||
// condition that makes the profile a lie.
|
||||
if (runtime.transport() == GraphQlRuntimeTransport.REACTIVE
|
||||
&& !runtime.properties().unbridgedBlockingResolvers().isEmpty()) {
|
||||
problems.add(
|
||||
"resolvers block without an executor bridge on a reactive transport: "
|
||||
+ new java.util.TreeSet<>(runtime.properties().unbridgedBlockingResolvers()));
|
||||
}
|
||||
|
||||
try {
|
||||
runtime.scalarWiring().wiredScalars();
|
||||
} catch (RuntimeException unwirable) {
|
||||
problems.add("scalar manifest cannot be wired: " + unwirable.getMessage());
|
||||
}
|
||||
|
||||
if (runtime.clientPolicy().maxPageSize() > runtime.properties().limits().maximumPageSize()) {
|
||||
problems.add(
|
||||
"client policy maxPageSize ("
|
||||
+ runtime.clientPolicy().maxPageSize()
|
||||
+ ") exceeds backend.graphql.limits.maximum-page-size ("
|
||||
+ runtime.properties().limits().maximumPageSize()
|
||||
+ ")");
|
||||
}
|
||||
if (runtime.clientPolicy().maxComplexity()
|
||||
> runtime.properties().limits().maximumComplexity()) {
|
||||
problems.add(
|
||||
"client policy maxComplexity ("
|
||||
+ runtime.clientPolicy().maxComplexity()
|
||||
+ ") exceeds backend.graphql.limits.maximum-complexity ("
|
||||
+ runtime.properties().limits().maximumComplexity()
|
||||
+ ")");
|
||||
}
|
||||
if (runtime.clientPolicy().introspectionAllowed()
|
||||
&& !runtime.properties().console().introspectionEnabled()) {
|
||||
problems.add(
|
||||
"client policy allows introspection while backend.graphql.console.introspection-enabled "
|
||||
+ "is false; one of the two is not what the operator configured");
|
||||
}
|
||||
|
||||
// The framework flags are what a request actually meets. A platform that says introspection is
|
||||
// off while `spring.graphql.schema.introspection.enabled` says it is on has two answers to one
|
||||
// question, and the client gets the framework's.
|
||||
if (runtime.frameworkIntrospectionEnabled() != null
|
||||
&& runtime.frameworkIntrospectionEnabled()
|
||||
!= runtime.properties().console().introspectionEnabled()) {
|
||||
problems.add(
|
||||
"backend.graphql.console.introspection-enabled ("
|
||||
+ runtime.properties().console().introspectionEnabled()
|
||||
+ ") contradicts spring.graphql.schema.introspection.enabled ("
|
||||
+ runtime.frameworkIntrospectionEnabled()
|
||||
+ ")");
|
||||
}
|
||||
if (runtime.frameworkGraphiqlEnabled() != null
|
||||
&& runtime.frameworkGraphiqlEnabled() != runtime.properties().console().graphiqlEnabled()) {
|
||||
problems.add(
|
||||
"backend.graphql.console.graphiql-enabled ("
|
||||
+ runtime.properties().console().graphiqlEnabled()
|
||||
+ ") contradicts spring.graphql.graphiql.enabled ("
|
||||
+ runtime.frameworkGraphiqlEnabled()
|
||||
+ ")");
|
||||
}
|
||||
return List.copyOf(problems);
|
||||
}
|
||||
}
|
||||
|
||||
+44
@@ -0,0 +1,44 @@
|
||||
package dev.caskeleton.adapter.inbound.graphql.autoconfigure;
|
||||
|
||||
import dev.caskeleton.adapter.inbound.graphql.execution.GraphQlExecutionProfile;
|
||||
|
||||
/**
|
||||
* The server this context is actually running on, as opposed to the one it was configured for.
|
||||
*
|
||||
* <p>The two used to be unable to disagree, because the leaf shipped an embedded servlet container
|
||||
* itself — which made {@code REACTIVE_WEBFLUX} a profile nobody could ever run. Now the composition
|
||||
* root chooses the server, so the two can disagree, and something has to notice.
|
||||
*/
|
||||
public enum GraphQlRuntimeTransport {
|
||||
|
||||
/** A servlet application: Spring MVC owns the {@code /graphql} route. */
|
||||
SERVLET,
|
||||
|
||||
/** A reactive application: WebFlux owns the {@code /graphql} route. */
|
||||
REACTIVE,
|
||||
|
||||
/** No web server — a plain application context, a test slice, or a batch process. */
|
||||
NONE;
|
||||
|
||||
/**
|
||||
* Whether an execution profile can run on this transport.
|
||||
*
|
||||
* <p>{@link #NONE} accepts every profile: a context with no server has no route to contradict,
|
||||
* and failing there would break every non-web test that assembles the platform.
|
||||
*
|
||||
* <p>{@code MIXED_CONTROLLED} runs on both by definition — it is the profile for a deployment
|
||||
* that crosses between blocking and reactive work through declared bridges. Accepting it here is
|
||||
* not a loophole: a reactive context still refuses to start with resolvers that block without a
|
||||
* bridge, whichever profile is declared.
|
||||
*/
|
||||
public boolean supports(GraphQlExecutionProfile profile) {
|
||||
if (this == NONE || profile == GraphQlExecutionProfile.MIXED_CONTROLLED) {
|
||||
return true;
|
||||
}
|
||||
return switch (this) {
|
||||
case SERVLET -> profile == GraphQlExecutionProfile.BLOCKING_MVC;
|
||||
case REACTIVE -> profile == GraphQlExecutionProfile.REACTIVE_WEBFLUX;
|
||||
case NONE -> true;
|
||||
};
|
||||
}
|
||||
}
|
||||
+57
-3
@@ -177,11 +177,65 @@ public enum GraphQlChangeKind {
|
||||
GraphQlCompatibilityImpact.BREAKING,
|
||||
"removing a scalar breaks existing operations"),
|
||||
|
||||
/** A scalar's declared serialization contract changed. */
|
||||
SCALAR_COERCION_CHANGED(
|
||||
/**
|
||||
* A scalar's SDL declaration changed.
|
||||
*
|
||||
* <p>Not a coercion change. Whether {@code DateTime} still parses the same strings is a property
|
||||
* of its {@code Coercing} implementation, which the SDL does not contain — swapping the codec
|
||||
* while leaving the SDL alone was invisible here, and editing the description raised a false
|
||||
* breaking change. Coercion compatibility belongs to the scalar manifest's codec version, and
|
||||
* this kind now says only what it can see.
|
||||
*/
|
||||
SCALAR_DECLARATION_CHANGED(
|
||||
GraphQlCompatibilityImpact.REVIEW_REQUIRED,
|
||||
GraphQlCompatibilityImpact.REVIEW_REQUIRED,
|
||||
"a changed scalar declaration needs its manifest codec version checked for a coercion change"),
|
||||
|
||||
/**
|
||||
* A type kept its name but changed kind, such as {@code type Foo} becoming {@code input Foo}.
|
||||
*
|
||||
* <p>Compared before anything else. Field-by-field comparison of two different kinds produces
|
||||
* plausible per-field findings and misses the only one that matters: every operation naming the
|
||||
* type breaks, whatever its fields now are.
|
||||
*/
|
||||
TYPE_KIND_CHANGED(
|
||||
GraphQlCompatibilityImpact.BREAKING,
|
||||
GraphQlCompatibilityImpact.BREAKING,
|
||||
"changing a scalar coercion requires a new scalar or a new scalar manifest version"),
|
||||
"a type that changed kind breaks every operation naming it"),
|
||||
|
||||
/**
|
||||
* A default value was removed from an argument or input field.
|
||||
*
|
||||
* <p>Breaking for a non-null input: the default was the reason omitting it was legal.
|
||||
*/
|
||||
INPUT_DEFAULT_REMOVED(
|
||||
GraphQlCompatibilityImpact.BREAKING,
|
||||
GraphQlCompatibilityImpact.BREAKING,
|
||||
"removing a default makes a previously omissible input required"),
|
||||
|
||||
/** A default value changed, so an omitted input now means something different. */
|
||||
INPUT_DEFAULT_CHANGED(
|
||||
GraphQlCompatibilityImpact.REVIEW_REQUIRED,
|
||||
GraphQlCompatibilityImpact.REVIEW_REQUIRED,
|
||||
"a changed default silently changes what an omitted input means"),
|
||||
|
||||
/** A default value was added, which makes a previously required input omissible. */
|
||||
INPUT_DEFAULT_ADDED(
|
||||
GraphQlCompatibilityImpact.COMPATIBLE,
|
||||
GraphQlCompatibilityImpact.REVIEW_REQUIRED,
|
||||
"a new default is accepting but changes generated models"),
|
||||
|
||||
/**
|
||||
* The directives applied to a schema element changed.
|
||||
*
|
||||
* <p>Distinct from a directive definition change. {@code @deprecated} appearing on a field, or
|
||||
* {@code @oneOf} disappearing from an input, changes what clients are told and what the engine
|
||||
* enforces, and comparing only definitions could not see either.
|
||||
*/
|
||||
APPLIED_DIRECTIVE_CHANGED(
|
||||
GraphQlCompatibilityImpact.REVIEW_REQUIRED,
|
||||
GraphQlCompatibilityImpact.REVIEW_REQUIRED,
|
||||
"a change to applied directives changes advertised or enforced behaviour"),
|
||||
|
||||
/** A directive definition appeared. */
|
||||
DIRECTIVE_ADDED(
|
||||
|
||||
+119
-1
@@ -58,12 +58,14 @@ public final class GraphQlSchemaComparator {
|
||||
List<GraphQlSchemaChange> changes = new ArrayList<>();
|
||||
|
||||
compareTypePresence(previous, candidate, changes);
|
||||
compareTypeKinds(previous, candidate, changes);
|
||||
compareOutputTypes(previous, candidate, changes);
|
||||
compareInputTypes(previous, candidate, changes);
|
||||
compareEnums(previous, candidate, changes);
|
||||
compareUnions(previous, candidate, changes);
|
||||
compareScalars(previous, candidate, changes);
|
||||
compareDirectives(previous, candidate, changes);
|
||||
compareAppliedDirectives(previous, candidate, changes);
|
||||
|
||||
return new GraphQlCompatibilityReport(changes.stream().sorted(DETERMINISTIC_ORDER).toList());
|
||||
}
|
||||
@@ -84,6 +86,121 @@ public final class GraphQlSchemaComparator {
|
||||
.forEach(name -> changes.add(GraphQlSchemaChange.of(name, GraphQlChangeKind.TYPE_ADDED)));
|
||||
}
|
||||
|
||||
/**
|
||||
* Reports types that kept their name and changed kind.
|
||||
*
|
||||
* <p>Runs before the per-kind comparisons, which only ever look at types of their own kind and
|
||||
* would therefore report {@code type Foo -> input Foo} as a removal from one map and an addition
|
||||
* to another, or as nothing at all.
|
||||
*/
|
||||
private static void compareTypeKinds(
|
||||
TypeDefinitionRegistry previous,
|
||||
TypeDefinitionRegistry candidate,
|
||||
List<GraphQlSchemaChange> changes) {
|
||||
|
||||
Map<String, TypeDefinition> previousTypes = previous.types();
|
||||
Map<String, TypeDefinition> candidateTypes = candidate.types();
|
||||
for (String name : new TreeSet<>(previousTypes.keySet())) {
|
||||
TypeDefinition after = candidateTypes.get(name);
|
||||
if (after == null) {
|
||||
continue;
|
||||
}
|
||||
if (!previousTypes.get(name).getClass().equals(after.getClass())) {
|
||||
changes.add(GraphQlSchemaChange.of(name, GraphQlChangeKind.TYPE_KIND_CHANGED));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reports changes to the directives applied to types and their fields.
|
||||
*
|
||||
* <p>Applied directives, not definitions: {@code @deprecated} appearing on a field and
|
||||
* {@code @oneOf} disappearing from an input are both behaviour changes that leave every directive
|
||||
* definition untouched.
|
||||
*/
|
||||
private static void compareAppliedDirectives(
|
||||
TypeDefinitionRegistry previous,
|
||||
TypeDefinitionRegistry candidate,
|
||||
List<GraphQlSchemaChange> changes) {
|
||||
|
||||
Map<String, TypeDefinition> previousTypes = previous.types();
|
||||
Map<String, TypeDefinition> candidateTypes = candidate.types();
|
||||
for (String name : new TreeSet<>(previousTypes.keySet())) {
|
||||
TypeDefinition before = previousTypes.get(name);
|
||||
TypeDefinition after = candidateTypes.get(name);
|
||||
if (after == null || !before.getClass().equals(after.getClass())) {
|
||||
continue;
|
||||
}
|
||||
if (!appliedDirectives(before).equals(appliedDirectives(after))) {
|
||||
changes.add(GraphQlSchemaChange.of(name, GraphQlChangeKind.APPLIED_DIRECTIVE_CHANGED));
|
||||
}
|
||||
if (before instanceof ImplementingTypeDefinition<?> beforeType
|
||||
&& after instanceof ImplementingTypeDefinition<?> afterType) {
|
||||
compareFieldDirectives(name, beforeType, afterType, changes);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static void compareFieldDirectives(
|
||||
String typeName,
|
||||
ImplementingTypeDefinition<?> before,
|
||||
ImplementingTypeDefinition<?> after,
|
||||
List<GraphQlSchemaChange> changes) {
|
||||
|
||||
Map<String, FieldDefinition> candidateFields =
|
||||
byName(after.getFieldDefinitions(), FieldDefinition::getName);
|
||||
for (FieldDefinition field : before.getFieldDefinitions()) {
|
||||
FieldDefinition candidateField = candidateFields.get(field.getName());
|
||||
if (candidateField == null) {
|
||||
continue;
|
||||
}
|
||||
if (!appliedDirectives(field).equals(appliedDirectives(candidateField))) {
|
||||
changes.add(
|
||||
GraphQlSchemaChange.of(
|
||||
typeName + "." + field.getName(), GraphQlChangeKind.APPLIED_DIRECTIVE_CHANGED));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Applied directives, printed and sorted so declaration order is not a change. */
|
||||
private static Set<String> appliedDirectives(graphql.language.Node<?> node) {
|
||||
List<graphql.language.Directive> directives =
|
||||
node instanceof graphql.language.DirectivesContainer<?> container
|
||||
? container.getDirectives()
|
||||
: List.of();
|
||||
return directives.stream()
|
||||
.map(GraphQlSchemaComparator::print)
|
||||
.collect(Collectors.toCollection(TreeSet::new));
|
||||
}
|
||||
|
||||
/**
|
||||
* Reports a change to an input's default value.
|
||||
*
|
||||
* <p>A default is part of the input contract: removing one from a non-null input makes every
|
||||
* request that omitted the field invalid, and changing one silently changes what omitting it
|
||||
* means. Neither shows up as a type change, which is all the comparator used to look at.
|
||||
*/
|
||||
private static void compareInputDefault(
|
||||
String coordinate,
|
||||
InputValueDefinition before,
|
||||
InputValueDefinition after,
|
||||
List<GraphQlSchemaChange> changes) {
|
||||
|
||||
String beforeDefault =
|
||||
before.getDefaultValue() == null ? null : print(before.getDefaultValue());
|
||||
String afterDefault = after.getDefaultValue() == null ? null : print(after.getDefaultValue());
|
||||
if (java.util.Objects.equals(beforeDefault, afterDefault)) {
|
||||
return;
|
||||
}
|
||||
if (beforeDefault == null) {
|
||||
changes.add(GraphQlSchemaChange.of(coordinate, GraphQlChangeKind.INPUT_DEFAULT_ADDED));
|
||||
} else if (afterDefault == null) {
|
||||
changes.add(GraphQlSchemaChange.of(coordinate, GraphQlChangeKind.INPUT_DEFAULT_REMOVED));
|
||||
} else {
|
||||
changes.add(GraphQlSchemaChange.of(coordinate, GraphQlChangeKind.INPUT_DEFAULT_CHANGED));
|
||||
}
|
||||
}
|
||||
|
||||
private static void compareOutputTypes(
|
||||
TypeDefinitionRegistry previous,
|
||||
TypeDefinitionRegistry candidate,
|
||||
@@ -268,6 +385,7 @@ public final class GraphQlSchemaComparator {
|
||||
GraphQlChangeKind.INPUT_FIELD_RELAXED,
|
||||
GraphQlChangeKind.INPUT_FIELD_TYPE_CHANGED,
|
||||
changes);
|
||||
compareInputDefault(coordinate, previousFields.get(fieldName), candidateField, changes);
|
||||
}
|
||||
|
||||
candidateFields.keySet().stream()
|
||||
@@ -396,7 +514,7 @@ public final class GraphQlSchemaComparator {
|
||||
}
|
||||
if (!print(previousScalars.get(name)).equals(print(after))) {
|
||||
changes.add(
|
||||
GraphQlSchemaChange.of("scalar " + name, GraphQlChangeKind.SCALAR_COERCION_CHANGED));
|
||||
GraphQlSchemaChange.of("scalar " + name, GraphQlChangeKind.SCALAR_DECLARATION_CHANGED));
|
||||
}
|
||||
}
|
||||
candidateScalars.keySet().stream()
|
||||
|
||||
+60
@@ -0,0 +1,60 @@
|
||||
package dev.caskeleton.adapter.inbound.graphql.context;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.Objects;
|
||||
import java.util.Optional;
|
||||
|
||||
/**
|
||||
* The request context reduced to values an application command can carry.
|
||||
*
|
||||
* <p>An anti-corruption boundary, and the direction is the whole point. {@link
|
||||
* GraphQlRequestContext} is an inbound transport type: it knows about client profiles, operation
|
||||
* ids and GraphQL locales. Handing it to a use case would make {@code application-core} — and then
|
||||
* every persistence and HTTP-client adapter the use case reaches — compile against the GraphQL
|
||||
* boundary, so a change to a transport concern would ripple to the database layer and a non-GraphQL
|
||||
* caller could not construct a command at all.
|
||||
*
|
||||
* <p>What crosses instead is this: four values with no transport vocabulary, which a REST, gRPC or
|
||||
* scheduled caller can produce just as easily.
|
||||
*
|
||||
* @param actorId the acting identity, or {@code null} for an unauthenticated caller
|
||||
* @param tenantId the tenant the work belongs to
|
||||
* @param deadline when the caller stops waiting
|
||||
* @param traceId correlation identity for logs and downstream calls
|
||||
*/
|
||||
public record GraphQlCommandAttribution(
|
||||
String actorId, String tenantId, Instant deadline, String traceId) {
|
||||
|
||||
public GraphQlCommandAttribution {
|
||||
Objects.requireNonNull(tenantId, "tenant is required");
|
||||
Objects.requireNonNull(traceId, "trace id is required");
|
||||
// Required, because the request context it comes from cannot exist without one. A command that
|
||||
// travelled without a deadline would run until something else timed out, which is the point at
|
||||
// which the caller has already given up and the work is being done for nobody.
|
||||
Objects.requireNonNull(deadline, "deadline is required");
|
||||
}
|
||||
|
||||
/**
|
||||
* Maps a request context onto the values a command carries.
|
||||
*
|
||||
* @param context the inbound request context
|
||||
*/
|
||||
public static GraphQlCommandAttribution from(GraphQlRequestContext context) {
|
||||
Objects.requireNonNull(context, "request context is required");
|
||||
return new GraphQlCommandAttribution(
|
||||
context.actor().authenticated() ? context.actor().value() : null,
|
||||
context.tenant().value(),
|
||||
context.deadline().value(),
|
||||
context.traceId());
|
||||
}
|
||||
|
||||
/** The acting identity, absent for an unauthenticated caller. */
|
||||
public Optional<String> actor() {
|
||||
return Optional.ofNullable(actorId);
|
||||
}
|
||||
|
||||
/** When the caller stops waiting. */
|
||||
public Instant deadlineAt() {
|
||||
return deadline;
|
||||
}
|
||||
}
|
||||
+295
@@ -0,0 +1,295 @@
|
||||
package dev.caskeleton.adapter.inbound.graphql.cost;
|
||||
|
||||
import graphql.language.Argument;
|
||||
import graphql.language.Definition;
|
||||
import graphql.language.Document;
|
||||
import graphql.language.Field;
|
||||
import graphql.language.FragmentDefinition;
|
||||
import graphql.language.FragmentSpread;
|
||||
import graphql.language.InlineFragment;
|
||||
import graphql.language.IntValue;
|
||||
import graphql.language.OperationDefinition;
|
||||
import graphql.language.Selection;
|
||||
import graphql.language.SelectionSet;
|
||||
import graphql.language.Value;
|
||||
import graphql.language.VariableReference;
|
||||
import graphql.schema.GraphQLFieldDefinition;
|
||||
import graphql.schema.GraphQLFieldsContainer;
|
||||
import graphql.schema.GraphQLList;
|
||||
import graphql.schema.GraphQLNonNull;
|
||||
import graphql.schema.GraphQLObjectType;
|
||||
import graphql.schema.GraphQLSchema;
|
||||
import graphql.schema.GraphQLType;
|
||||
import java.math.BigInteger;
|
||||
import java.util.ArrayDeque;
|
||||
import java.util.Deque;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
|
||||
/**
|
||||
* Scores a whole document against the cost catalogue, before any resolver runs.
|
||||
*
|
||||
* <p>{@link GraphQlComplexityCalculator} prices one field; this walks the selection tree so a
|
||||
* request has a single number to judge. The walk is schema-aware on purpose: a coordinate is {@code
|
||||
* TypeName.fieldName}, and the type half only exists once each selection set has been resolved
|
||||
* against the schema. Guessing it from the operation root would price {@code order { customer {
|
||||
* orders { … } } }} as three root fields and miss the multiplication entirely.
|
||||
*
|
||||
* <p>Cardinality comes from the request, not from the schema: a connection's children are
|
||||
* multiplied by the effective page size, and a page size supplied through a variable is resolved
|
||||
* from the request variables rather than assumed to be the default. That is the difference between
|
||||
* a budget and a suggestion — {@code first: $n} would otherwise cost the same at 1 and at 1000.
|
||||
*
|
||||
* <p>Traversal is bounded, and fragment cycles are cut by tracking the expansion path. This runs on
|
||||
* documents that have passed validation, but the bound stays because the scorer is also used from
|
||||
* the pre-execution path where a hostile document is exactly what it is meant to price.
|
||||
*/
|
||||
public final class GraphQlDocumentComplexityScorer {
|
||||
|
||||
/** Prefix that marks an introspection field, which is gated rather than priced. */
|
||||
public static final String INTROSPECTION_FIELD_PREFIX = "__";
|
||||
|
||||
private final GraphQlComplexityCalculator calculator;
|
||||
private final int maximumVisitedNodes;
|
||||
|
||||
/** Creates a scorer with the default traversal budget. */
|
||||
public GraphQlDocumentComplexityScorer(GraphQlComplexityCalculator calculator) {
|
||||
this(calculator, 200_000);
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a scorer.
|
||||
*
|
||||
* @param calculator per-field pricing
|
||||
* @param maximumVisitedNodes traversal budget; exceeding it rejects the document
|
||||
*/
|
||||
public GraphQlDocumentComplexityScorer(
|
||||
GraphQlComplexityCalculator calculator, int maximumVisitedNodes) {
|
||||
this.calculator = Objects.requireNonNull(calculator, "complexity calculator is required");
|
||||
if (maximumVisitedNodes < 1) {
|
||||
throw new IllegalArgumentException("traversal budget must be positive");
|
||||
}
|
||||
this.maximumVisitedNodes = maximumVisitedNodes;
|
||||
}
|
||||
|
||||
/**
|
||||
* Scores one operation of a document.
|
||||
*
|
||||
* @param schema schema the document was validated against
|
||||
* @param document the parsed document
|
||||
* @param operation the selected operation
|
||||
* @param variables the request variables, used to resolve page sizes
|
||||
* @throws GraphQlComplexityRejectedException when a requested page exceeds the maximum
|
||||
* @throws GraphQlStructuralLimitViolation when traversal exceeds the node budget
|
||||
*/
|
||||
public GraphQlComplexityResult score(
|
||||
GraphQLSchema schema,
|
||||
Document document,
|
||||
OperationDefinition operation,
|
||||
Map<String, Object> variables) {
|
||||
|
||||
Objects.requireNonNull(schema, "schema is required");
|
||||
Objects.requireNonNull(document, "document is required");
|
||||
Objects.requireNonNull(operation, "operation is required");
|
||||
|
||||
Map<String, FragmentDefinition> fragments = new LinkedHashMap<>();
|
||||
for (Definition<?> definition : document.getDefinitions()) {
|
||||
if (definition instanceof FragmentDefinition fragment) {
|
||||
fragments.put(fragment.getName(), fragment);
|
||||
}
|
||||
}
|
||||
|
||||
GraphQLObjectType root = rootType(schema, operation);
|
||||
if (root == null) {
|
||||
// The schema does not define this operation type; validation rejects the document, and
|
||||
// pricing a tree with no root would be inventing a number.
|
||||
return new GraphQlComplexityResult(0);
|
||||
}
|
||||
long total =
|
||||
selectionSetCost(
|
||||
schema,
|
||||
root,
|
||||
operation.getSelectionSet(),
|
||||
fragments,
|
||||
variables == null ? Map.of() : variables,
|
||||
new Counter(),
|
||||
new ArrayDeque<>());
|
||||
return new GraphQlComplexityResult(total);
|
||||
}
|
||||
|
||||
private long selectionSetCost(
|
||||
GraphQLSchema schema,
|
||||
GraphQLFieldsContainer parent,
|
||||
SelectionSet selectionSet,
|
||||
Map<String, FragmentDefinition> fragments,
|
||||
Map<String, Object> variables,
|
||||
Counter counter,
|
||||
Deque<String> expansionPath) {
|
||||
|
||||
if (selectionSet == null || parent == null) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
long total = 0;
|
||||
for (Selection<?> selection : selectionSet.getSelections()) {
|
||||
counter.visit(maximumVisitedNodes);
|
||||
|
||||
if (selection instanceof Field field) {
|
||||
total =
|
||||
Math.addExact(
|
||||
total,
|
||||
fieldCost(schema, parent, field, fragments, variables, counter, expansionPath));
|
||||
} else if (selection instanceof InlineFragment inlineFragment) {
|
||||
GraphQLFieldsContainer target =
|
||||
inlineFragment.getTypeCondition() == null
|
||||
? parent
|
||||
: fieldsContainer(schema, inlineFragment.getTypeCondition().getName());
|
||||
total =
|
||||
Math.addExact(
|
||||
total,
|
||||
selectionSetCost(
|
||||
schema,
|
||||
target,
|
||||
inlineFragment.getSelectionSet(),
|
||||
fragments,
|
||||
variables,
|
||||
counter,
|
||||
expansionPath));
|
||||
} else if (selection instanceof FragmentSpread spread) {
|
||||
FragmentDefinition fragment = fragments.get(spread.getName());
|
||||
// A fragment already on this path is a cycle. Validation rejects it, but the scorer must
|
||||
// terminate on its own or the defence becomes the denial of service.
|
||||
if (fragment == null || expansionPath.contains(spread.getName())) {
|
||||
continue;
|
||||
}
|
||||
GraphQLFieldsContainer target =
|
||||
fragment.getTypeCondition() == null
|
||||
? parent
|
||||
: fieldsContainer(schema, fragment.getTypeCondition().getName());
|
||||
expansionPath.push(spread.getName());
|
||||
total =
|
||||
Math.addExact(
|
||||
total,
|
||||
selectionSetCost(
|
||||
schema,
|
||||
target,
|
||||
fragment.getSelectionSet(),
|
||||
fragments,
|
||||
variables,
|
||||
counter,
|
||||
expansionPath));
|
||||
expansionPath.pop();
|
||||
}
|
||||
}
|
||||
return total;
|
||||
}
|
||||
|
||||
private long fieldCost(
|
||||
GraphQLSchema schema,
|
||||
GraphQLFieldsContainer parent,
|
||||
Field field,
|
||||
Map<String, FragmentDefinition> fragments,
|
||||
Map<String, Object> variables,
|
||||
Counter counter,
|
||||
Deque<String> expansionPath) {
|
||||
|
||||
if (field.getName().startsWith(INTROSPECTION_FIELD_PREFIX)) {
|
||||
// Introspection is an allow/deny decision made by the authorization stage. Pricing it here
|
||||
// would let an allowed introspection query consume the data budget it was never spending.
|
||||
return 0;
|
||||
}
|
||||
|
||||
GraphQLFieldDefinition definition = parent.getFieldDefinition(field.getName());
|
||||
String coordinate = parent.getName() + "." + field.getName();
|
||||
GraphQLFieldsContainer childContainer =
|
||||
definition == null ? null : fieldsContainer(unwrap(definition.getType()));
|
||||
long childCost =
|
||||
selectionSetCost(
|
||||
schema,
|
||||
childContainer,
|
||||
field.getSelectionSet(),
|
||||
fragments,
|
||||
variables,
|
||||
counter,
|
||||
expansionPath);
|
||||
|
||||
Integer first = pageArgument(field, "first", variables);
|
||||
Integer last = pageArgument(field, "last", variables);
|
||||
if (first != null || last != null) {
|
||||
return calculator.connectionCost(coordinate, first, last, childCost).total();
|
||||
}
|
||||
return calculator.fieldCost(coordinate, childCost).total();
|
||||
}
|
||||
|
||||
private static Integer pageArgument(Field field, String name, Map<String, Object> variables) {
|
||||
for (Argument argument : field.getArguments()) {
|
||||
if (!argument.getName().equals(name)) {
|
||||
continue;
|
||||
}
|
||||
return intValue(argument.getValue(), variables);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private static Integer intValue(Value<?> value, Map<String, Object> variables) {
|
||||
if (value instanceof IntValue intValue) {
|
||||
BigInteger raw = intValue.getValue();
|
||||
// A literal outside int range cannot be a page size; treating it as the maximum lets the
|
||||
// calculator reject it rather than silently overflowing to something affordable.
|
||||
return raw.bitLength() >= Integer.SIZE ? Integer.MAX_VALUE : raw.intValue();
|
||||
}
|
||||
if (value instanceof VariableReference reference) {
|
||||
Object supplied = variables.get(reference.getName());
|
||||
if (supplied instanceof Number number) {
|
||||
long asLong = number.longValue();
|
||||
return asLong > Integer.MAX_VALUE ? Integer.MAX_VALUE : (int) asLong;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private static GraphQLObjectType rootType(GraphQLSchema schema, OperationDefinition operation) {
|
||||
OperationDefinition.Operation kind =
|
||||
operation.getOperation() == null
|
||||
? OperationDefinition.Operation.QUERY
|
||||
: operation.getOperation();
|
||||
return switch (kind) {
|
||||
case QUERY -> schema.getQueryType();
|
||||
case MUTATION -> schema.getMutationType();
|
||||
case SUBSCRIPTION -> schema.getSubscriptionType();
|
||||
};
|
||||
}
|
||||
|
||||
private static GraphQLFieldsContainer fieldsContainer(GraphQLSchema schema, String typeName) {
|
||||
GraphQLType type = schema.getType(typeName);
|
||||
return fieldsContainer(type);
|
||||
}
|
||||
|
||||
private static GraphQLFieldsContainer fieldsContainer(GraphQLType type) {
|
||||
GraphQLType unwrapped = unwrap(type);
|
||||
return unwrapped instanceof GraphQLFieldsContainer container ? container : null;
|
||||
}
|
||||
|
||||
private static GraphQLType unwrap(GraphQLType type) {
|
||||
GraphQLType current = type;
|
||||
while (current instanceof GraphQLNonNull nonNull) {
|
||||
current = nonNull.getWrappedType();
|
||||
}
|
||||
while (current instanceof GraphQLList list) {
|
||||
current = unwrap(list.getWrappedType());
|
||||
}
|
||||
return current;
|
||||
}
|
||||
|
||||
private static final class Counter {
|
||||
|
||||
private int visited;
|
||||
|
||||
void visit(int budget) {
|
||||
if (++visited > budget) {
|
||||
throw GraphQlStructuralLimitViolation.of("COMPLEXITY_TRAVERSAL", visited, budget);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+132
-23
@@ -16,10 +16,13 @@ import graphql.language.SelectionSet;
|
||||
import graphql.language.Value;
|
||||
import graphql.parser.Parser;
|
||||
import java.util.ArrayDeque;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Deque;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
|
||||
/**
|
||||
* Measures a document's structure before execution (design §18).
|
||||
@@ -61,11 +64,27 @@ public final class GraphQlDocumentShapeAnalyzer {
|
||||
}
|
||||
|
||||
/**
|
||||
* Measures a parsed document.
|
||||
* Measures every operation in a document.
|
||||
*
|
||||
* <p>Kept for callers that judge a document before one operation has been chosen. Once an
|
||||
* operation is selected, {@link #analyze(Document, OperationDefinition)} is the honest
|
||||
* measurement: summing operations the request will not run charges a client for a document it
|
||||
* only sent one part of, and — worse in the other direction — averages away the one that matters.
|
||||
*
|
||||
* @throws GraphQlStructuralLimitViolation when traversal exceeds the node budget
|
||||
*/
|
||||
public GraphQlDocumentShape analyze(Document document) {
|
||||
return analyze(document, null);
|
||||
}
|
||||
|
||||
/**
|
||||
* Measures one selected operation and the fragments it can actually reach.
|
||||
*
|
||||
* @param document the parsed document
|
||||
* @param operation the selected operation, or {@code null} to measure every operation
|
||||
* @throws GraphQlStructuralLimitViolation when traversal exceeds the node budget
|
||||
*/
|
||||
public GraphQlDocumentShape analyze(Document document, OperationDefinition operation) {
|
||||
Map<String, FragmentDefinition> fragments = new LinkedHashMap<>();
|
||||
int operationCount = 0;
|
||||
for (Definition<?> definition : document.getDefinitions()) {
|
||||
@@ -77,29 +96,63 @@ public final class GraphQlDocumentShapeAnalyzer {
|
||||
}
|
||||
|
||||
Counters counters = new Counters();
|
||||
for (Definition<?> definition : document.getDefinitions()) {
|
||||
if (definition instanceof OperationDefinition operation) {
|
||||
walk(operation.getSelectionSet(), fragments, counters, 1, new ArrayDeque<>());
|
||||
}
|
||||
List<OperationDefinition> measured =
|
||||
operation != null ? List.of(operation) : operations(document);
|
||||
Set<String> reachableFragments = new LinkedHashSet<>();
|
||||
for (OperationDefinition candidate : measured) {
|
||||
walk(
|
||||
candidate.getSelectionSet(),
|
||||
fragments,
|
||||
counters,
|
||||
1,
|
||||
new ArrayDeque<>(),
|
||||
reachableFragments);
|
||||
}
|
||||
|
||||
return new GraphQlDocumentShape(
|
||||
counters.depth,
|
||||
counters.fields,
|
||||
counters.aliases,
|
||||
fragments.size(),
|
||||
// Fragments the walk could actually reach. Counting every definition would charge a client
|
||||
// for fragments the selected operation never spreads, and let an unreachable one raise the
|
||||
// count until an honest request is refused.
|
||||
operation != null ? reachableFragments.size() : fragments.size(),
|
||||
counters.fragmentSpreads,
|
||||
operationCount,
|
||||
operation != null ? 1 : operationCount,
|
||||
counters.inputNestingDepth);
|
||||
}
|
||||
|
||||
/** Whether a document selects any introspection field. */
|
||||
/** Whether any operation in a document selects an introspection field. */
|
||||
public boolean selectsIntrospection(Document document) {
|
||||
return document.getDefinitions().stream()
|
||||
.anyMatch(
|
||||
definition ->
|
||||
definition instanceof OperationDefinition operation
|
||||
&& selectsIntrospection(operation.getSelectionSet()));
|
||||
return selectsIntrospection(document, null);
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the selected operation reaches an introspection field.
|
||||
*
|
||||
* <p>Named fragments are expanded. The gate used to walk only fields and inline fragments, so
|
||||
* {@code query Q { ...I } fragment I on Query { __schema { types { name } } }} passed a check
|
||||
* whose entire purpose was to stop that query — the same class already expanded fragments for
|
||||
* shape counting, which made the omission invisible.
|
||||
*
|
||||
* @param document the parsed document
|
||||
* @param operation the selected operation, or {@code null} to check every operation
|
||||
*/
|
||||
public boolean selectsIntrospection(Document document, OperationDefinition operation) {
|
||||
Map<String, FragmentDefinition> fragments = new LinkedHashMap<>();
|
||||
for (Definition<?> definition : document.getDefinitions()) {
|
||||
if (definition instanceof FragmentDefinition fragment) {
|
||||
fragments.put(fragment.getName(), fragment);
|
||||
}
|
||||
}
|
||||
List<OperationDefinition> candidates =
|
||||
operation != null ? List.of(operation) : operations(document);
|
||||
for (OperationDefinition candidate : candidates) {
|
||||
if (selectsIntrospection(candidate.getSelectionSet(), fragments, new ArrayDeque<>())) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -108,24 +161,60 @@ public final class GraphQlDocumentShapeAnalyzer {
|
||||
* @throws GraphQlStructuralLimitViolation when introspection is selected but not permitted
|
||||
*/
|
||||
public void verifyIntrospection(Document document, boolean introspectionAllowed) {
|
||||
if (!introspectionAllowed && selectsIntrospection(document)) {
|
||||
verifyIntrospection(document, null, introspectionAllowed);
|
||||
}
|
||||
|
||||
/**
|
||||
* Rejects introspection reached by the selected operation.
|
||||
*
|
||||
* @throws GraphQlStructuralLimitViolation when introspection is selected but not permitted
|
||||
*/
|
||||
public void verifyIntrospection(
|
||||
Document document, OperationDefinition operation, boolean introspectionAllowed) {
|
||||
if (!introspectionAllowed && selectsIntrospection(document, operation)) {
|
||||
throw GraphQlStructuralLimitViolation.of("INTROSPECTION", 1, 0);
|
||||
}
|
||||
}
|
||||
|
||||
private boolean selectsIntrospection(SelectionSet selectionSet) {
|
||||
private static List<OperationDefinition> operations(Document document) {
|
||||
List<OperationDefinition> operations = new ArrayList<>();
|
||||
for (Definition<?> definition : document.getDefinitions()) {
|
||||
if (definition instanceof OperationDefinition operation) {
|
||||
operations.add(operation);
|
||||
}
|
||||
}
|
||||
return operations;
|
||||
}
|
||||
|
||||
private boolean selectsIntrospection(
|
||||
SelectionSet selectionSet,
|
||||
Map<String, FragmentDefinition> fragments,
|
||||
Deque<String> expansionPath) {
|
||||
|
||||
if (selectionSet == null) {
|
||||
return false;
|
||||
}
|
||||
for (Selection<?> selection : selectionSet.getSelections()) {
|
||||
if (selection instanceof Field field) {
|
||||
if (field.getName().startsWith(INTROSPECTION_FIELD_PREFIX)
|
||||
|| selectsIntrospection(field.getSelectionSet())) {
|
||||
|| selectsIntrospection(field.getSelectionSet(), fragments, expansionPath)) {
|
||||
return true;
|
||||
}
|
||||
} else if (selection instanceof InlineFragment inlineFragment) {
|
||||
if (selectsIntrospection(inlineFragment.getSelectionSet(), fragments, expansionPath)) {
|
||||
return true;
|
||||
}
|
||||
} else if (selection instanceof FragmentSpread spread) {
|
||||
FragmentDefinition fragment = fragments.get(spread.getName());
|
||||
if (fragment == null || expansionPath.contains(spread.getName())) {
|
||||
continue;
|
||||
}
|
||||
expansionPath.push(spread.getName());
|
||||
boolean found = selectsIntrospection(fragment.getSelectionSet(), fragments, expansionPath);
|
||||
expansionPath.pop();
|
||||
if (found) {
|
||||
return true;
|
||||
}
|
||||
} else if (selection instanceof InlineFragment inlineFragment
|
||||
&& selectsIntrospection(inlineFragment.getSelectionSet())) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
@@ -136,7 +225,8 @@ public final class GraphQlDocumentShapeAnalyzer {
|
||||
Map<String, FragmentDefinition> fragments,
|
||||
Counters counters,
|
||||
int depth,
|
||||
Deque<String> expansionPath) {
|
||||
Deque<String> expansionPath,
|
||||
Set<String> reachableFragments) {
|
||||
|
||||
if (selectionSet == null) {
|
||||
return;
|
||||
@@ -153,17 +243,36 @@ public final class GraphQlDocumentShapeAnalyzer {
|
||||
}
|
||||
counters.inputNestingDepth =
|
||||
Math.max(counters.inputNestingDepth, argumentNestingDepth(field.getArguments()));
|
||||
walk(field.getSelectionSet(), fragments, counters, depth + 1, expansionPath);
|
||||
walk(
|
||||
field.getSelectionSet(),
|
||||
fragments,
|
||||
counters,
|
||||
depth + 1,
|
||||
expansionPath,
|
||||
reachableFragments);
|
||||
} else if (selection instanceof InlineFragment inlineFragment) {
|
||||
walk(inlineFragment.getSelectionSet(), fragments, counters, depth + 1, expansionPath);
|
||||
walk(
|
||||
inlineFragment.getSelectionSet(),
|
||||
fragments,
|
||||
counters,
|
||||
depth + 1,
|
||||
expansionPath,
|
||||
reachableFragments);
|
||||
} else if (selection instanceof FragmentSpread spread) {
|
||||
counters.fragmentSpreads++;
|
||||
FragmentDefinition fragment = fragments.get(spread.getName());
|
||||
// A fragment already on this expansion path is a cycle; expanding it again would not
|
||||
// terminate, and the document is rejected by validation anyway.
|
||||
if (fragment != null && !expansionPath.contains(spread.getName())) {
|
||||
reachableFragments.add(spread.getName());
|
||||
expansionPath.push(spread.getName());
|
||||
walk(fragment.getSelectionSet(), fragments, counters, depth, expansionPath);
|
||||
walk(
|
||||
fragment.getSelectionSet(),
|
||||
fragments,
|
||||
counters,
|
||||
depth,
|
||||
expansionPath,
|
||||
reachableFragments);
|
||||
expansionPath.pop();
|
||||
}
|
||||
}
|
||||
|
||||
+17
-6
@@ -13,9 +13,13 @@ import java.util.function.BiFunction;
|
||||
* Runs a batch load in ordered chunks under one context and budget (design §13).
|
||||
*
|
||||
* <p>Every chunk receives the same actor, tenant and deadline: a chunk that ran with a different
|
||||
* scope would produce a result set mixing two tenants inside one logical batch. The budget is
|
||||
* checked between chunks so a batch that has already exhausted the request deadline stops instead
|
||||
* of issuing more work.
|
||||
* scope would produce a result set mixing two tenants inside one logical batch.
|
||||
*
|
||||
* <p>The budget is checked before and after every chunk. Checking only before it meant the last
|
||||
* chunk could run unbounded — a batch that started with a millisecond left was allowed to issue one
|
||||
* more downstream call and wait for it however long it took, which is the case the budget exists
|
||||
* for. Bounding the call itself is the loader's job, and the deadline is handed to it for that;
|
||||
* this check is what stops the batch continuing past a budget that has already gone.
|
||||
*/
|
||||
public final class GraphQlBatchExecutor {
|
||||
|
||||
@@ -56,14 +60,21 @@ public final class GraphQlBatchExecutor {
|
||||
Map<K, V> loaded = new LinkedHashMap<>();
|
||||
|
||||
for (List<K> chunk : chunker.chunk(keys)) {
|
||||
if (Duration.between(started, clock.instant()).compareTo(budget) > 0) {
|
||||
throw new GraphQlBatchTimeoutException(policy.loaderName().value());
|
||||
}
|
||||
requireBudget(started, budget);
|
||||
loaded.putAll(loadChunk.apply(chunk, context));
|
||||
// After, too: a chunk that overran the budget must not have its result used and must not be
|
||||
// followed by another one.
|
||||
requireBudget(started, budget);
|
||||
}
|
||||
return Map.copyOf(loaded);
|
||||
}
|
||||
|
||||
private void requireBudget(java.time.Instant started, Duration budget) {
|
||||
if (Duration.between(started, clock.instant()).compareTo(budget) > 0) {
|
||||
throw new GraphQlBatchTimeoutException(policy.loaderName().value());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The budget for this batch: the loader's own timeout, never more than the request has left.
|
||||
*
|
||||
|
||||
+33
-3
@@ -11,6 +11,12 @@ import java.util.Set;
|
||||
* <p>Keys the loader did not return become {@link GraphQlBatchValue.Missing}, and keys it failed on
|
||||
* become {@link GraphQlBatchValue.Failed}. Flattening both to null is the defect this mapper exists
|
||||
* to prevent — it makes a dependency outage indistinguishable from empty data.
|
||||
*
|
||||
* <p>A null value means <em>missing</em>, in both loader shapes. The two used to disagree: a mapped
|
||||
* loader returning {@code {k: null}} produced {@code Present(null)} while an ordered loader
|
||||
* returning {@code [null]} produced {@code Missing}, so the same "no value for this key" answer
|
||||
* meant two different things depending on which loader shape a field happened to use — and only one
|
||||
* of them triggered the missing-key policy.
|
||||
*/
|
||||
public final class GraphQlBatchResultMapper {
|
||||
|
||||
@@ -39,19 +45,42 @@ public final class GraphQlBatchResultMapper {
|
||||
public <K, V> GraphQlBatchResult<K, V> map(
|
||||
List<K> keys, Map<K, V> loaded, Set<K> failedKeys, String errorCode) {
|
||||
|
||||
requireOnlyRequestedKeys(keys, loaded);
|
||||
|
||||
var result = new LinkedHashMap<K, GraphQlBatchValue<V>>();
|
||||
for (K key : keys) {
|
||||
V value = loaded.get(key);
|
||||
if (failedKeys.contains(key)) {
|
||||
result.put(key, GraphQlBatchValue.failed(errorCode));
|
||||
} else if (loaded.containsKey(key)) {
|
||||
result.put(key, GraphQlBatchValue.present(loaded.get(key)));
|
||||
} else {
|
||||
} else if (value == null) {
|
||||
result.put(key, GraphQlBatchValue.missing());
|
||||
} else {
|
||||
result.put(key, GraphQlBatchValue.present(value));
|
||||
}
|
||||
}
|
||||
return new GraphQlBatchResult<>(result);
|
||||
}
|
||||
|
||||
/**
|
||||
* Refuses a result that answers keys nobody asked for.
|
||||
*
|
||||
* <p>Matching cardinality is not the same as matching keys. A loader that returned the right
|
||||
* number of entries under different keys used to pass: every requested key resolved to {@code
|
||||
* Missing}, which reads as "the rows do not exist" rather than "the loader answered the wrong
|
||||
* question", and the field quietly rendered null.
|
||||
*/
|
||||
private static <K, V> void requireOnlyRequestedKeys(List<K> keys, Map<K, V> loaded) {
|
||||
Set<K> requested = new java.util.LinkedHashSet<>(keys);
|
||||
Set<K> unrequested =
|
||||
loaded.keySet().stream()
|
||||
.filter(key -> !requested.contains(key))
|
||||
.collect(java.util.stream.Collectors.toCollection(java.util.LinkedHashSet::new));
|
||||
if (!unrequested.isEmpty()) {
|
||||
throw new IllegalArgumentException(
|
||||
"loader returned " + unrequested.size() + " key(s) that were not requested");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Maps ordered-loader output.
|
||||
*
|
||||
@@ -73,6 +102,7 @@ public final class GraphQlBatchResultMapper {
|
||||
var result = new LinkedHashMap<K, GraphQlBatchValue<V>>();
|
||||
for (int index = 0; index < keys.size(); index++) {
|
||||
V value = orderedValues.get(index);
|
||||
// Same rule as the mapped shape: null is the absence of a value, not a present null.
|
||||
result.put(
|
||||
keys.get(index),
|
||||
value == null ? GraphQlBatchValue.missing() : GraphQlBatchValue.present(value));
|
||||
|
||||
+117
-19
@@ -1,8 +1,13 @@
|
||||
package dev.caskeleton.adapter.inbound.graphql.execution;
|
||||
|
||||
import java.time.Clock;
|
||||
import java.time.Instant;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
import java.util.concurrent.CompletionException;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import java.util.function.Function;
|
||||
|
||||
/**
|
||||
@@ -14,7 +19,15 @@ import java.util.function.Function;
|
||||
* data leak.
|
||||
*
|
||||
* <p>Eviction is least-recently-used and bounded by both entry count and total document weight,
|
||||
* because the key space is client-controlled.
|
||||
* because the key space is client-controlled. Entries also expire after a period without access, so
|
||||
* a burst of one-off documents does not hold memory until enough later traffic pushes it out.
|
||||
*
|
||||
* <p>A miss parses outside the lock. The whole method used to be {@code synchronized}, which made
|
||||
* one slow parse block every other request including the ones that would have hit the cache — the
|
||||
* cache's own miss path became the contention point it existed to remove. Concurrent misses on the
|
||||
* <em>same</em> key still parse once: they are the one case where waiting is cheaper than parsing,
|
||||
* and letting a cold popular document be parsed by every arriving request is how a cache turns a
|
||||
* deploy into a CPU spike.
|
||||
*
|
||||
* @param <D> the cached parsed-document type
|
||||
*/
|
||||
@@ -22,7 +35,11 @@ public final class BoundedPreparsedDocumentProvider<D> {
|
||||
|
||||
private final GraphQlPreparsedCachePolicy policy;
|
||||
private final GraphQlPreparsedCacheMetrics metrics;
|
||||
private final Clock clock;
|
||||
private final Map<GraphQlPreparsedCacheKey, Entry<D>> cache;
|
||||
private final ConcurrentHashMap<GraphQlPreparsedCacheKey, CompletableFuture<D>> inFlight =
|
||||
new ConcurrentHashMap<>();
|
||||
private final Object lock = new Object();
|
||||
private long weight;
|
||||
|
||||
/**
|
||||
@@ -30,11 +47,13 @@ public final class BoundedPreparsedDocumentProvider<D> {
|
||||
*
|
||||
* @param policy cache bounds
|
||||
* @param metrics counters
|
||||
* @param clock the clock idle expiry is measured against
|
||||
*/
|
||||
public BoundedPreparsedDocumentProvider(
|
||||
GraphQlPreparsedCachePolicy policy, GraphQlPreparsedCacheMetrics metrics) {
|
||||
this.policy = Objects.requireNonNull(policy);
|
||||
this.metrics = Objects.requireNonNull(metrics);
|
||||
GraphQlPreparsedCachePolicy policy, GraphQlPreparsedCacheMetrics metrics, Clock clock) {
|
||||
this.policy = Objects.requireNonNull(policy, "cache policy is required");
|
||||
this.metrics = Objects.requireNonNull(metrics, "cache metrics are required");
|
||||
this.clock = Objects.requireNonNull(clock, "clock is required");
|
||||
this.cache = new LinkedHashMap<>(16, 0.75f, true);
|
||||
}
|
||||
|
||||
@@ -45,33 +64,60 @@ public final class BoundedPreparsedDocumentProvider<D> {
|
||||
* @param documentWeight the document's size, used for the weight bound
|
||||
* @param parseAndValidate invoked on a miss
|
||||
*/
|
||||
public synchronized D getDocument(
|
||||
public D getDocument(
|
||||
GraphQlPreparsedCacheKey key,
|
||||
long documentWeight,
|
||||
Function<GraphQlPreparsedCacheKey, D> parseAndValidate) {
|
||||
|
||||
Entry<D> cached = cache.get(key);
|
||||
Objects.requireNonNull(key, "cache key is required");
|
||||
Objects.requireNonNull(parseAndValidate, "parse function is required");
|
||||
|
||||
Instant now = clock.instant();
|
||||
D cached = lookup(key, now);
|
||||
if (cached != null) {
|
||||
metrics.recordHit();
|
||||
return cached.document();
|
||||
return cached;
|
||||
}
|
||||
|
||||
CompletableFuture<D> mine = new CompletableFuture<>();
|
||||
CompletableFuture<D> leader = inFlight.putIfAbsent(key, mine);
|
||||
if (leader != null) {
|
||||
metrics.recordCoalesced();
|
||||
return await(leader);
|
||||
}
|
||||
|
||||
metrics.recordMiss();
|
||||
D document = parseAndValidate.apply(key);
|
||||
cache.put(key, new Entry<>(document, Math.max(1, documentWeight)));
|
||||
weight += Math.max(1, documentWeight);
|
||||
evictIfNeeded();
|
||||
return document;
|
||||
try {
|
||||
D document = parseAndValidate.apply(key);
|
||||
store(key, document, documentWeight, clock.instant());
|
||||
mine.complete(document);
|
||||
return document;
|
||||
} catch (RuntimeException failure) {
|
||||
// Failures are not cached: an invalid document is the client's to fix, and remembering the
|
||||
// rejection would make a later schema deploy unable to accept a document it now supports.
|
||||
mine.completeExceptionally(failure);
|
||||
throw failure;
|
||||
} finally {
|
||||
inFlight.remove(key, mine);
|
||||
}
|
||||
}
|
||||
|
||||
/** Entries currently cached. */
|
||||
public synchronized int size() {
|
||||
return cache.size();
|
||||
/** Entries currently cached, after expiring anything idle. */
|
||||
public int size() {
|
||||
Instant now = clock.instant();
|
||||
synchronized (lock) {
|
||||
expireIdle(now);
|
||||
return cache.size();
|
||||
}
|
||||
}
|
||||
|
||||
/** Total weight currently cached. */
|
||||
public synchronized long weight() {
|
||||
return weight;
|
||||
/** Total weight currently cached, after expiring anything idle. */
|
||||
public long weight() {
|
||||
Instant now = clock.instant();
|
||||
synchronized (lock) {
|
||||
expireIdle(now);
|
||||
return weight;
|
||||
}
|
||||
}
|
||||
|
||||
/** The counters. */
|
||||
@@ -79,6 +125,45 @@ public final class BoundedPreparsedDocumentProvider<D> {
|
||||
return metrics;
|
||||
}
|
||||
|
||||
private D lookup(GraphQlPreparsedCacheKey key, Instant now) {
|
||||
synchronized (lock) {
|
||||
expireIdle(now);
|
||||
Entry<D> cached = cache.get(key);
|
||||
if (cached == null) {
|
||||
return null;
|
||||
}
|
||||
// Access refreshes the idle deadline, which is what expire-after-access means: a document
|
||||
// still being used stays, and only the ones nobody asks for any more leave.
|
||||
cache.put(key, new Entry<>(cached.document(), cached.weight(), now));
|
||||
return cached.document();
|
||||
}
|
||||
}
|
||||
|
||||
private void store(GraphQlPreparsedCacheKey key, D document, long documentWeight, Instant now) {
|
||||
long entryWeight = Math.max(1, documentWeight);
|
||||
synchronized (lock) {
|
||||
Entry<D> previous = cache.put(key, new Entry<>(document, entryWeight, now));
|
||||
if (previous != null) {
|
||||
weight -= previous.weight();
|
||||
}
|
||||
weight += entryWeight;
|
||||
expireIdle(now);
|
||||
evictIfNeeded();
|
||||
}
|
||||
}
|
||||
|
||||
private void expireIdle(Instant now) {
|
||||
var entries = cache.entrySet().iterator();
|
||||
while (entries.hasNext()) {
|
||||
Map.Entry<GraphQlPreparsedCacheKey, Entry<D>> entry = entries.next();
|
||||
if (!now.isBefore(entry.getValue().lastAccessAt().plus(policy.expireAfterAccess()))) {
|
||||
weight -= entry.getValue().weight();
|
||||
entries.remove();
|
||||
metrics.recordExpiry();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void evictIfNeeded() {
|
||||
while (cache.size() > policy.maximumEntries() || weight > policy.maximumWeight()) {
|
||||
var oldest = cache.entrySet().iterator();
|
||||
@@ -92,5 +177,18 @@ public final class BoundedPreparsedDocumentProvider<D> {
|
||||
}
|
||||
}
|
||||
|
||||
private record Entry<D>(D document, long weight) {}
|
||||
private D await(CompletableFuture<D> leader) {
|
||||
try {
|
||||
return leader.join();
|
||||
} catch (CompletionException wrapped) {
|
||||
// The leader's failure is this caller's failure too, but it belongs to them unwrapped: a
|
||||
// CompletionException in a resolver stack says nothing about the document that was rejected.
|
||||
if (wrapped.getCause() instanceof RuntimeException cause) {
|
||||
throw cause;
|
||||
}
|
||||
throw wrapped;
|
||||
}
|
||||
}
|
||||
|
||||
private record Entry<D>(D document, long weight, Instant lastAccessAt) {}
|
||||
}
|
||||
|
||||
+10
-2
@@ -18,13 +18,21 @@ public record GraphQlExecutionPipeline(List<GraphQlExecutionStage> stages) {
|
||||
stages = List.copyOf(stages);
|
||||
}
|
||||
|
||||
/** The Stable pipeline: context, authorization, parse/validate, cost, execute. */
|
||||
/**
|
||||
* The Stable pipeline: context, parse/validate, authorization, cost, execute.
|
||||
*
|
||||
* <p>Parsing precedes authorization because authorization has nothing to decide before it. A
|
||||
* coordinate rule is keyed by {@code Type.field} and an operation rule by the selected operation,
|
||||
* and neither exists until the document has been parsed and one operation has been chosen.
|
||||
* Authorizing first would either authorize a request whose shape is still unknown, or force the
|
||||
* authorization stage to parse the document itself — a second parser on the hostile-input path.
|
||||
*/
|
||||
public static GraphQlExecutionPipeline stable() {
|
||||
return new GraphQlExecutionPipeline(
|
||||
List.of(
|
||||
GraphQlExecutionStage.CONTEXT,
|
||||
GraphQlExecutionStage.AUTHORIZATION,
|
||||
GraphQlExecutionStage.PARSE_VALIDATE,
|
||||
GraphQlExecutionStage.AUTHORIZATION,
|
||||
GraphQlExecutionStage.COST,
|
||||
GraphQlExecutionStage.EXECUTE));
|
||||
}
|
||||
|
||||
+8
@@ -15,12 +15,20 @@ public final class GraphQlExecutionPipelineValidator {
|
||||
/** Ordering constraints every pipeline must satisfy, as (earlier, later) pairs. */
|
||||
private static final List<GraphQlExecutionStage[]> ORDERING_CONSTRAINTS =
|
||||
List.of(
|
||||
new GraphQlExecutionStage[] {
|
||||
GraphQlExecutionStage.CONTEXT, GraphQlExecutionStage.PARSE_VALIDATE
|
||||
},
|
||||
new GraphQlExecutionStage[] {
|
||||
GraphQlExecutionStage.CONTEXT, GraphQlExecutionStage.AUTHORIZATION
|
||||
},
|
||||
new GraphQlExecutionStage[] {
|
||||
GraphQlExecutionStage.PERSISTED_LOOKUP, GraphQlExecutionStage.PARSE_VALIDATE
|
||||
},
|
||||
// Authorization is keyed by coordinates and by the selected operation, so the document
|
||||
// has to be parsed and one operation chosen before it can decide anything.
|
||||
new GraphQlExecutionStage[] {
|
||||
GraphQlExecutionStage.PARSE_VALIDATE, GraphQlExecutionStage.AUTHORIZATION
|
||||
},
|
||||
new GraphQlExecutionStage[] {
|
||||
GraphQlExecutionStage.PARSE_VALIDATE, GraphQlExecutionStage.COST
|
||||
},
|
||||
|
||||
+12
-8
@@ -3,10 +3,14 @@ package dev.caskeleton.adapter.inbound.graphql.execution;
|
||||
/**
|
||||
* The ordered stages of GraphQL request execution (design §9, §18).
|
||||
*
|
||||
* <p>The order is a security property, not a preference. Context must exist before authorization
|
||||
* can decide anything; a persisted lookup has to happen before parsing or the registry cannot
|
||||
* supply the document; and cost has to be judged before resolvers run, because a budget checked
|
||||
* afterwards has already been spent.
|
||||
* <p>The order is a security property, not a preference. Context must exist before anything can
|
||||
* decide who is calling; a persisted lookup has to happen before parsing or the registry cannot
|
||||
* supply the document; authorization needs the parsed document, because a coordinate rule has no
|
||||
* coordinate to check until one operation has been selected; and cost has to be judged before
|
||||
* resolvers run, because a budget checked afterwards has already been spent.
|
||||
*
|
||||
* <p>Constants are declared in execution order, which is also the order {@code
|
||||
* GraphQlExecutionPipeline.stable()} composes them in.
|
||||
*/
|
||||
public enum GraphQlExecutionStage {
|
||||
|
||||
@@ -16,12 +20,12 @@ public enum GraphQlExecutionStage {
|
||||
/** Resolve an operation ID to its approved document (Advanced persisted-operation capability). */
|
||||
PERSISTED_LOOKUP(false),
|
||||
|
||||
/** Operation-level authorization, before the document is executed. */
|
||||
AUTHORIZATION(true),
|
||||
|
||||
/** Parse and validate the document against the schema. */
|
||||
/** Parse and validate the document against the schema, and select one operation. */
|
||||
PARSE_VALIDATE(true),
|
||||
|
||||
/** Operation and coordinate authorization, before the document is executed. */
|
||||
AUTHORIZATION(true),
|
||||
|
||||
/** Structural and complexity budgets, before any resolver runs. */
|
||||
COST(true),
|
||||
|
||||
|
||||
+27
@@ -13,6 +13,8 @@ public final class GraphQlPreparsedCacheMetrics {
|
||||
private final AtomicLong hits = new AtomicLong();
|
||||
private final AtomicLong misses = new AtomicLong();
|
||||
private final AtomicLong evictions = new AtomicLong();
|
||||
private final AtomicLong expiries = new AtomicLong();
|
||||
private final AtomicLong coalesced = new AtomicLong();
|
||||
|
||||
/** Records a cache hit. */
|
||||
public void recordHit() {
|
||||
@@ -29,6 +31,21 @@ public final class GraphQlPreparsedCacheMetrics {
|
||||
evictions.incrementAndGet();
|
||||
}
|
||||
|
||||
/** Records an entry dropped for being idle past its expiry. */
|
||||
public void recordExpiry() {
|
||||
expiries.incrementAndGet();
|
||||
}
|
||||
|
||||
/**
|
||||
* Records a miss that waited for another caller's parse instead of parsing again.
|
||||
*
|
||||
* <p>Counted apart from misses so the two questions stay separable: how often the cache did not
|
||||
* have the document, and how often concurrent demand for one cold document was coalesced.
|
||||
*/
|
||||
public void recordCoalesced() {
|
||||
coalesced.incrementAndGet();
|
||||
}
|
||||
|
||||
/** Cache hits so far. */
|
||||
public long hits() {
|
||||
return hits.get();
|
||||
@@ -44,6 +61,16 @@ public final class GraphQlPreparsedCacheMetrics {
|
||||
return evictions.get();
|
||||
}
|
||||
|
||||
/** Entries dropped for being idle past their expiry. */
|
||||
public long expiries() {
|
||||
return expiries.get();
|
||||
}
|
||||
|
||||
/** Misses that waited for another caller's parse. */
|
||||
public long coalesced() {
|
||||
return coalesced.get();
|
||||
}
|
||||
|
||||
/** Hit ratio, or {@code 0} when nothing has been looked up yet. */
|
||||
public double hitRatio() {
|
||||
long total = hits.get() + misses.get();
|
||||
|
||||
+151
@@ -0,0 +1,151 @@
|
||||
package dev.caskeleton.adapter.inbound.graphql.http;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.Comparator;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
/**
|
||||
* A parsed {@code Accept} header, ordered the way the client asked for.
|
||||
*
|
||||
* <p>Two properties of the header are easy to drop and expensive to get wrong. {@code q=0} is not a
|
||||
* weak preference, it is a refusal — {@code application/graphql-response+json;q=0} means "never
|
||||
* send me that" — and quality ranks the client's alternatives against each other. Iterating the
|
||||
* server's own preference list and returning the first type that appears anywhere in the header
|
||||
* ignores both, which is how a refused media type gets sent as if it had been requested.
|
||||
*
|
||||
* <p>Parsing is done here rather than with the framework's {@code MediaType} because this module is
|
||||
* framework free, and the grammar involved is a comma-separated list with one parameter that
|
||||
* matters. A malformed entry is dropped rather than failing the request: a client that sends
|
||||
* nonsense alongside a usable type gets the usable type, and one that sends only nonsense gets the
|
||||
* same answer as one that sent nothing acceptable.
|
||||
*
|
||||
* @param type the type half, lowercased, for example {@code application}
|
||||
* @param subtype the subtype half, lowercased, for example {@code graphql-response+json}
|
||||
* @param quality the {@code q} parameter, defaulting to {@code 1.0}
|
||||
* @param specificity how concrete the entry is: 2 for a full type, 1 for {@code type/*}, 0 for
|
||||
* {@code * / *}
|
||||
* @param order the entry's position in the header, which breaks ties in the client's stated order
|
||||
*/
|
||||
public record GraphQlAcceptHeader(
|
||||
String type, String subtype, double quality, int specificity, int order) {
|
||||
|
||||
private static final double DEFAULT_QUALITY = 1.0;
|
||||
private static final String WILDCARD = "*";
|
||||
|
||||
// Precompiled with an explicit limit: String.split drops trailing empty results, which would
|
||||
// silently change how a header ending in a comma is read.
|
||||
private static final Pattern ENTRY_SEPARATOR = Pattern.compile(",");
|
||||
private static final Pattern PARAMETER_SEPARATOR = Pattern.compile(";");
|
||||
|
||||
/**
|
||||
* Parses an {@code Accept} header into entries ranked most acceptable first.
|
||||
*
|
||||
* <p>Ranked by quality, then by specificity, then by the order the client wrote them. Entries
|
||||
* with {@code q=0} are dropped, because they are refusals and must never be selectable.
|
||||
*
|
||||
* @param accept the raw header, possibly {@code null}
|
||||
*/
|
||||
public static List<GraphQlAcceptHeader> parse(String accept) {
|
||||
if (accept == null || accept.isBlank()) {
|
||||
return List.of();
|
||||
}
|
||||
List<GraphQlAcceptHeader> entries = new ArrayList<>();
|
||||
String[] parts = ENTRY_SEPARATOR.split(accept, -1);
|
||||
for (int index = 0; index < parts.length; index++) {
|
||||
GraphQlAcceptHeader entry = parseEntry(parts[index], index);
|
||||
if (entry != null && entry.quality() > 0) {
|
||||
entries.add(entry);
|
||||
}
|
||||
}
|
||||
entries.sort(
|
||||
Comparator.<GraphQlAcceptHeader>comparingDouble(GraphQlAcceptHeader::quality)
|
||||
.reversed()
|
||||
.thenComparing(
|
||||
Comparator.<GraphQlAcceptHeader>comparingInt(GraphQlAcceptHeader::specificity)
|
||||
.reversed())
|
||||
.thenComparingInt(GraphQlAcceptHeader::order));
|
||||
return List.copyOf(entries);
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether this entry explicitly refuses a media type.
|
||||
*
|
||||
* <p>Only a refusal that names the type or its subtype family counts. A {@code * / *;q=0} entry
|
||||
* is dropped at parse time and never reaches here.
|
||||
*/
|
||||
public static boolean refuses(String accept, String mediaType) {
|
||||
if (accept == null || accept.isBlank()) {
|
||||
return false;
|
||||
}
|
||||
String[] parts = ENTRY_SEPARATOR.split(accept, -1);
|
||||
for (int index = 0; index < parts.length; index++) {
|
||||
GraphQlAcceptHeader entry = parseEntry(parts[index], index);
|
||||
if (entry != null && entry.quality() == 0 && entry.matches(mediaType)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/** Whether a concrete media type is covered by this entry. */
|
||||
public boolean matches(String mediaType) {
|
||||
if (mediaType == null) {
|
||||
return false;
|
||||
}
|
||||
int separator = mediaType.indexOf('/');
|
||||
if (separator < 0) {
|
||||
return false;
|
||||
}
|
||||
String candidateType = mediaType.substring(0, separator).strip().toLowerCase(Locale.ROOT);
|
||||
String candidateSubtype = mediaType.substring(separator + 1).strip().toLowerCase(Locale.ROOT);
|
||||
return (WILDCARD.equals(type) || type.equals(candidateType))
|
||||
&& (WILDCARD.equals(subtype) || subtype.equals(candidateSubtype));
|
||||
}
|
||||
|
||||
private static GraphQlAcceptHeader parseEntry(String raw, int order) {
|
||||
String entry = raw.strip();
|
||||
if (entry.isEmpty()) {
|
||||
return null;
|
||||
}
|
||||
int parameterStart = entry.indexOf(';');
|
||||
String base = (parameterStart < 0 ? entry : entry.substring(0, parameterStart)).strip();
|
||||
int separator = base.indexOf('/');
|
||||
if (separator < 0) {
|
||||
return null;
|
||||
}
|
||||
String type = base.substring(0, separator).strip().toLowerCase(Locale.ROOT);
|
||||
String subtype = base.substring(separator + 1).strip().toLowerCase(Locale.ROOT);
|
||||
if (type.isEmpty() || subtype.isEmpty()) {
|
||||
return null;
|
||||
}
|
||||
// `*/subtype` is not a shape the grammar allows, and treating it as a wildcard would let a
|
||||
// malformed header match more than a well-formed one.
|
||||
if (WILDCARD.equals(type) && !WILDCARD.equals(subtype)) {
|
||||
return null;
|
||||
}
|
||||
double quality =
|
||||
parameterStart < 0 ? DEFAULT_QUALITY : qualityOf(entry.substring(parameterStart + 1));
|
||||
int specificity = WILDCARD.equals(type) ? 0 : WILDCARD.equals(subtype) ? 1 : 2;
|
||||
return new GraphQlAcceptHeader(type, subtype, quality, specificity, order);
|
||||
}
|
||||
|
||||
private static double qualityOf(String parameters) {
|
||||
for (String parameter : PARAMETER_SEPARATOR.split(parameters, -1)) {
|
||||
String candidate = parameter.strip();
|
||||
if (!candidate.regionMatches(true, 0, "q=", 0, 2)) {
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
double value = Double.parseDouble(candidate.substring(2).strip());
|
||||
// Out-of-range values are not meaningful quality; treating them as the default keeps a
|
||||
// sloppy client working without letting `q=5` outrank an honest `q=1`.
|
||||
return value < 0 || value > 1 ? DEFAULT_QUALITY : value;
|
||||
} catch (NumberFormatException malformed) {
|
||||
return DEFAULT_QUALITY;
|
||||
}
|
||||
}
|
||||
return DEFAULT_QUALITY;
|
||||
}
|
||||
}
|
||||
+8
-4
@@ -11,8 +11,8 @@ import java.util.Map;
|
||||
*
|
||||
* @param query the GraphQL document
|
||||
* @param operationName selected operation name, or {@code null}
|
||||
* @param variables variable values, never {@code null}
|
||||
* @param extensions protocol extensions, never {@code null}
|
||||
* @param variables variable values, never {@code null}, null entries preserved
|
||||
* @param extensions protocol extensions, never {@code null}, null entries preserved
|
||||
*/
|
||||
public record GraphQlHttpRequestEnvelope(
|
||||
String query,
|
||||
@@ -21,8 +21,12 @@ public record GraphQlHttpRequestEnvelope(
|
||||
Map<String, Object> extensions) {
|
||||
|
||||
public GraphQlHttpRequestEnvelope {
|
||||
variables = variables == null ? Map.of() : Map.copyOf(variables);
|
||||
extensions = extensions == null ? Map.of() : Map.copyOf(extensions);
|
||||
// Deep and null-preserving. `Map.copyOf` threw on `{"id": null}` — a legal variables object
|
||||
// whose explicit null is a different instruction from omitting the key — and left nested maps
|
||||
// and lists shared with the decoder, so the value a validator checked was not necessarily the
|
||||
// value an executor later read.
|
||||
variables = GraphQlJsonValues.immutableObject(variables);
|
||||
extensions = GraphQlJsonValues.immutableObject(extensions);
|
||||
}
|
||||
|
||||
/** An envelope carrying only a document. */
|
||||
|
||||
+100
@@ -0,0 +1,100 @@
|
||||
package dev.caskeleton.adapter.inbound.graphql.http;
|
||||
|
||||
import dev.caskeleton.adapter.inbound.graphql.policy.GraphQlClientPolicy;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
|
||||
/**
|
||||
* Bounds the shape of decoded {@code variables} and {@code extensions}.
|
||||
*
|
||||
* <p>A byte limit bounds how much a client can send; it does not bound what that costs to process.
|
||||
* Sixty kilobytes of {@code [[[[[…]]]]]} is small on the wire and expensive to walk, coerce and
|
||||
* validate, and the same bytes as one enormous list turn into one enormous coercion loop. So depth,
|
||||
* element count and key count get their own budgets.
|
||||
*
|
||||
* <p>Diagnostics report the dimension and the two counts. A variable value never appears: these are
|
||||
* exactly the inputs that carry identifiers, tokens and personal data.
|
||||
*/
|
||||
public final class GraphQlJsonStructurePolicy {
|
||||
|
||||
/** Stable request-error code for every structural rejection of a JSON input. */
|
||||
public static final String CODE = "GRAPHQL_INPUT_SHAPE_REJECTED";
|
||||
|
||||
private final int maxDepth;
|
||||
private final int maxListElements;
|
||||
private final int maxObjectKeys;
|
||||
|
||||
/**
|
||||
* Creates the policy.
|
||||
*
|
||||
* @param maxDepth deepest accepted nesting of objects and arrays
|
||||
* @param maxListElements most accepted elements in one array
|
||||
* @param maxObjectKeys most accepted keys in one object
|
||||
*/
|
||||
public GraphQlJsonStructurePolicy(int maxDepth, int maxListElements, int maxObjectKeys) {
|
||||
if (maxDepth < 1 || maxListElements < 1 || maxObjectKeys < 1) {
|
||||
throw new IllegalArgumentException("JSON structure limits must be positive");
|
||||
}
|
||||
this.maxDepth = maxDepth;
|
||||
this.maxListElements = maxListElements;
|
||||
this.maxObjectKeys = maxObjectKeys;
|
||||
}
|
||||
|
||||
/**
|
||||
* Derives the policy from a client policy.
|
||||
*
|
||||
* <p>Input nesting reuses the document depth budget, because a variable tree and a selection tree
|
||||
* are walked by the same kind of recursion and there is no reason for a client to need one deeper
|
||||
* than the other. Key count reuses the list-element budget for the same reason.
|
||||
*/
|
||||
public static GraphQlJsonStructurePolicy from(GraphQlClientPolicy policy) {
|
||||
Objects.requireNonNull(policy, "client policy is required");
|
||||
return new GraphQlJsonStructurePolicy(
|
||||
policy.maxDepth(), policy.maxInputListElements(), policy.maxInputListElements());
|
||||
}
|
||||
|
||||
/** The deepest accepted nesting. */
|
||||
public int maxDepth() {
|
||||
return maxDepth;
|
||||
}
|
||||
|
||||
/**
|
||||
* Verifies a decoded JSON object.
|
||||
*
|
||||
* @param field field name used in the diagnostic, for example {@code variables}
|
||||
* @param value the decoded object
|
||||
* @throws GraphQlRequestFormatException on the first exceeded dimension
|
||||
*/
|
||||
public void verify(String field, Map<String, Object> value) {
|
||||
if (value == null || value.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
walk(field, value, 1);
|
||||
}
|
||||
|
||||
private void walk(String field, Object value, int depth) {
|
||||
if (depth > maxDepth) {
|
||||
throw rejection(field, "DEPTH", depth, maxDepth);
|
||||
}
|
||||
if (value instanceof Map<?, ?> object) {
|
||||
if (object.size() > maxObjectKeys) {
|
||||
throw rejection(field, "OBJECT_KEYS", object.size(), maxObjectKeys);
|
||||
}
|
||||
object.values().forEach(entry -> walk(field, entry, depth + 1));
|
||||
return;
|
||||
}
|
||||
if (value instanceof List<?> list) {
|
||||
if (list.size() > maxListElements) {
|
||||
throw rejection(field, "LIST_ELEMENTS", list.size(), maxListElements);
|
||||
}
|
||||
list.forEach(element -> walk(field, element, depth + 1));
|
||||
}
|
||||
}
|
||||
|
||||
private static GraphQlRequestFormatException rejection(
|
||||
String field, String dimension, int observed, int allowed) {
|
||||
return new GraphQlRequestFormatException(
|
||||
CODE + " " + field + " " + dimension + ": " + observed + " > " + allowed);
|
||||
}
|
||||
}
|
||||
+56
@@ -0,0 +1,56 @@
|
||||
package dev.caskeleton.adapter.inbound.graphql.http;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collections;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* Copies decoded JSON so it is immutable without losing what the client actually sent.
|
||||
*
|
||||
* <p>{@code Map.copyOf} cannot be used here, and the reason is a correctness bug rather than a
|
||||
* style preference: it throws on a null value, and a null variable is legal, meaningful GraphQL
|
||||
* input. The three cases {@code {"a": 1}}, {@code {"a": null}} and {@code {}} coerce differently —
|
||||
* a value, an explicit null, and an absent argument that falls back to its default — so collapsing
|
||||
* the middle one into an exception makes valid requests fail.
|
||||
*
|
||||
* <p>The copy is deep. A shallow copy leaves the nested maps and lists shared with whatever decoded
|
||||
* them, so the envelope a validator inspected and the envelope an executor later reads are not
|
||||
* guaranteed to be the same value.
|
||||
*/
|
||||
public final class GraphQlJsonValues {
|
||||
|
||||
private GraphQlJsonValues() {}
|
||||
|
||||
/**
|
||||
* A deep, null-preserving, unmodifiable copy of a decoded JSON object.
|
||||
*
|
||||
* @param value the decoded object, or {@code null}
|
||||
* @return an unmodifiable copy; an empty map when {@code value} is {@code null}
|
||||
*/
|
||||
public static Map<String, Object> immutableObject(Map<String, Object> value) {
|
||||
if (value == null || value.isEmpty()) {
|
||||
return Map.of();
|
||||
}
|
||||
Map<String, Object> copy = new LinkedHashMap<>(value.size());
|
||||
value.forEach((key, entry) -> copy.put(key, immutableValue(entry)));
|
||||
return Collections.unmodifiableMap(copy);
|
||||
}
|
||||
|
||||
/** A deep, null-preserving, unmodifiable copy of any decoded JSON value. */
|
||||
@SuppressWarnings("unchecked")
|
||||
public static Object immutableValue(Object value) {
|
||||
if (value instanceof Map<?, ?> map) {
|
||||
return immutableObject((Map<String, Object>) map);
|
||||
}
|
||||
if (value instanceof List<?> list) {
|
||||
List<Object> copy = new ArrayList<>(list.size());
|
||||
list.forEach(element -> copy.add(immutableValue(element)));
|
||||
return Collections.unmodifiableList(copy);
|
||||
}
|
||||
// Everything else a JSON decoder produces is already immutable: String, Boolean, the boxed
|
||||
// numbers, BigDecimal, BigInteger — and null, which must survive as null.
|
||||
return value;
|
||||
}
|
||||
}
|
||||
+41
-12
@@ -1,7 +1,9 @@
|
||||
package dev.caskeleton.adapter.inbound.graphql.http;
|
||||
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Set;
|
||||
|
||||
/**
|
||||
* Media types of the Stable HTTP profile (design §9.1).
|
||||
@@ -39,6 +41,21 @@ public final class GraphQlMediaTypes {
|
||||
/**
|
||||
* Chooses the response media type for an {@code Accept} header.
|
||||
*
|
||||
* <p>The client's ranking decides, not the server's. Walking the server's preference list first
|
||||
* and returning the first type named anywhere in the header ignored both quality and refusal, so
|
||||
* {@code application/graphql-response+json;q=0, application/json} — a client saying "anything but
|
||||
* that one" — was answered with exactly the refused type.
|
||||
*
|
||||
* <p>Absent or blank {@code Accept} means no constraint, so the profile's preferred type is
|
||||
* returned. A wildcard is matched like any other entry, at its own quality and specificity, which
|
||||
* is what lets {@code * / *;q=0.1, application/json;q=0.9} pick JSON rather than the wildcard.
|
||||
*
|
||||
* <p>Where the client ranked two producible types equally — same quality, same specificity, as in
|
||||
* a plain {@code application/json, application/graphql-response+json} — it has expressed no
|
||||
* preference between them, and the server's own preference breaks the tie. That is the one place
|
||||
* server preference still applies, and it applies only after the client's ranking has been
|
||||
* exhausted.
|
||||
*
|
||||
* @param accept raw {@code Accept} header, possibly {@code null}
|
||||
* @return the negotiated media type, or {@code null} when nothing acceptable was offered
|
||||
*/
|
||||
@@ -46,21 +63,33 @@ public final class GraphQlMediaTypes {
|
||||
if (accept == null || accept.isBlank()) {
|
||||
return GRAPHQL_RESPONSE_JSON;
|
||||
}
|
||||
List<String> offered = List.of(accept.split(","));
|
||||
for (String candidate : PRODUCIBLE) {
|
||||
for (String entry : offered) {
|
||||
if (baseType(entry).equals(candidate)) {
|
||||
return candidate;
|
||||
}
|
||||
|
||||
Set<String> bestTier = new LinkedHashSet<>();
|
||||
double tierQuality = 0;
|
||||
int tierSpecificity = -1;
|
||||
|
||||
for (GraphQlAcceptHeader entry : GraphQlAcceptHeader.parse(accept)) {
|
||||
List<String> matches =
|
||||
PRODUCIBLE.stream()
|
||||
// A concrete refusal outranks a wildcard acceptance: `*/*, application/json;q=0`
|
||||
// accepts everything and then names one exception, and the exception is the specific
|
||||
// instruction.
|
||||
.filter(candidate -> entry.matches(candidate))
|
||||
.filter(candidate -> !GraphQlAcceptHeader.refuses(accept, candidate))
|
||||
.toList();
|
||||
if (matches.isEmpty()) {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
for (String entry : offered) {
|
||||
String base = baseType(entry);
|
||||
if ("*/*".equals(base) || "application/*".equals(base)) {
|
||||
return GRAPHQL_RESPONSE_JSON;
|
||||
if (bestTier.isEmpty()) {
|
||||
tierQuality = entry.quality();
|
||||
tierSpecificity = entry.specificity();
|
||||
} else if (entry.quality() != tierQuality || entry.specificity() != tierSpecificity) {
|
||||
break;
|
||||
}
|
||||
bestTier.addAll(matches);
|
||||
}
|
||||
return null;
|
||||
|
||||
return PRODUCIBLE.stream().filter(bestTier::contains).findFirst().orElse(null);
|
||||
}
|
||||
|
||||
private static String baseType(String mediaType) {
|
||||
|
||||
+46
@@ -1,6 +1,8 @@
|
||||
package dev.caskeleton.adapter.inbound.graphql.http;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* Measured sizes of one request envelope, in bytes.
|
||||
@@ -36,6 +38,50 @@ public record GraphQlRequestSize(int documentBytes, int variablesBytes, int exte
|
||||
return documentBytes + variablesBytes + extensionsBytes;
|
||||
}
|
||||
|
||||
/**
|
||||
* The UTF-8 size of a decoded JSON value, as a canonical encoding without whitespace.
|
||||
*
|
||||
* <p>Computed by walking the decoded value rather than by re-serialising it, because this module
|
||||
* is framework free and must not acquire a JSON library to measure one. The number is what the
|
||||
* content costs in memory, which is the quantity the limit is protecting; it is deliberately not
|
||||
* a claim about the exact bytes the client sent, since escaping and whitespace are the encoder's
|
||||
* business and neither is attacker-controlled in a way the count would miss.
|
||||
*
|
||||
* @param value a decoded JSON value, or {@code null}
|
||||
*/
|
||||
public static int jsonBytes(Object value) {
|
||||
if (value == null) {
|
||||
return 4; // "null"
|
||||
}
|
||||
if (value instanceof String text) {
|
||||
return utf8Length(text) + 2; // surrounding quotes
|
||||
}
|
||||
if (value instanceof Map<?, ?> object) {
|
||||
int bytes = 2; // braces
|
||||
boolean first = true;
|
||||
for (Map.Entry<?, ?> entry : object.entrySet()) {
|
||||
if (!first) {
|
||||
bytes++; // comma
|
||||
}
|
||||
first = false;
|
||||
bytes += utf8Length(String.valueOf(entry.getKey())) + 3; // quotes and colon
|
||||
bytes += jsonBytes(entry.getValue());
|
||||
}
|
||||
return bytes;
|
||||
}
|
||||
if (value instanceof List<?> list) {
|
||||
int bytes = 2; // brackets
|
||||
for (int index = 0; index < list.size(); index++) {
|
||||
if (index > 0) {
|
||||
bytes++; // comma
|
||||
}
|
||||
bytes += jsonBytes(list.get(index));
|
||||
}
|
||||
return bytes;
|
||||
}
|
||||
return utf8Length(String.valueOf(value));
|
||||
}
|
||||
|
||||
private static int utf8Length(String value) {
|
||||
return value == null ? 0 : value.getBytes(StandardCharsets.UTF_8).length;
|
||||
}
|
||||
|
||||
-80
@@ -1,80 +0,0 @@
|
||||
package dev.caskeleton.adapter.inbound.graphql.http.mvc;
|
||||
|
||||
import dev.caskeleton.adapter.inbound.graphql.http.GraphQlHttpExecutor;
|
||||
import dev.caskeleton.adapter.inbound.graphql.http.GraphQlHttpProfile;
|
||||
import dev.caskeleton.adapter.inbound.graphql.http.GraphQlRequestEnvelopeValidator;
|
||||
import dev.caskeleton.adapter.inbound.graphql.policy.GraphQlClientPolicy;
|
||||
import java.time.Clock;
|
||||
import java.util.concurrent.ExecutorService;
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnBean;
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean;
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnWebApplication;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
|
||||
/**
|
||||
* Wires the blocking MVC transport when the application runs the servlet stack.
|
||||
*
|
||||
* <p>Conditional on a servlet web application and on {@code BLOCKING_MVC} being the selected
|
||||
* execution profile, so a reactive deployment never gets a blocking transport by accident. Every
|
||||
* bean backs off if the application defines its own.
|
||||
*/
|
||||
@Configuration(proxyBeanMethods = false)
|
||||
@ConditionalOnWebApplication(type = ConditionalOnWebApplication.Type.SERVLET)
|
||||
@ConditionalOnProperty(
|
||||
prefix = "backend.graphql",
|
||||
name = "execution-profile",
|
||||
havingValue = "BLOCKING_MVC",
|
||||
matchIfMissing = true)
|
||||
public class GraphQlMvcAutoConfiguration {
|
||||
|
||||
/** Default bounded pool size when virtual threads are not in use. */
|
||||
public static final int DEFAULT_BOUNDED_POOL_SIZE = 64;
|
||||
|
||||
/**
|
||||
* The thread policy for resolver work.
|
||||
*
|
||||
* <p>Virtual threads by default: the blocking profile exists for JPA and blocking SDK work, and a
|
||||
* thread-per-request model with virtual threads is what makes that affordable on Java 21.
|
||||
*/
|
||||
@Bean
|
||||
@ConditionalOnMissingBean
|
||||
public GraphQlMvcExecutorPolicy graphQlMvcExecutorPolicy() {
|
||||
return GraphQlMvcExecutorPolicy.VIRTUAL_THREAD;
|
||||
}
|
||||
|
||||
/** The executor resolver work runs on. */
|
||||
@Bean(destroyMethod = "shutdown")
|
||||
@ConditionalOnMissingBean(name = "graphQlMvcExecutorService")
|
||||
public ExecutorService graphQlMvcExecutorService(GraphQlMvcExecutorPolicy policy) {
|
||||
return policy.createExecutor(DEFAULT_BOUNDED_POOL_SIZE);
|
||||
}
|
||||
|
||||
/** Pre-parse envelope limits derived from the client policy. */
|
||||
@Bean
|
||||
@ConditionalOnMissingBean
|
||||
@ConditionalOnBean(GraphQlClientPolicy.class)
|
||||
public GraphQlRequestEnvelopeValidator graphQlRequestEnvelopeValidator(
|
||||
GraphQlClientPolicy clientPolicy) {
|
||||
return GraphQlRequestEnvelopeValidator.forPolicy(clientPolicy);
|
||||
}
|
||||
|
||||
/** The MVC transport adapter. */
|
||||
@Bean
|
||||
@ConditionalOnMissingBean
|
||||
@ConditionalOnBean({GraphQlHttpExecutor.class, GraphQlRequestEnvelopeValidator.class})
|
||||
public GraphQlMvcTransportAdapter graphQlMvcTransportAdapter(
|
||||
GraphQlRequestEnvelopeValidator validator,
|
||||
GraphQlHttpExecutor executor,
|
||||
ExecutorService graphQlMvcExecutorService,
|
||||
GraphQlMvcExecutorPolicy policy) {
|
||||
return new GraphQlMvcTransportAdapter(
|
||||
GraphQlHttpProfile.V1,
|
||||
validator,
|
||||
executor,
|
||||
graphQlMvcExecutorService,
|
||||
policy,
|
||||
Clock.systemUTC());
|
||||
}
|
||||
}
|
||||
-49
@@ -1,49 +0,0 @@
|
||||
package dev.caskeleton.adapter.inbound.graphql.http.mvc;
|
||||
|
||||
import java.util.concurrent.ExecutorService;
|
||||
import java.util.concurrent.Executors;
|
||||
import java.util.concurrent.ThreadFactory;
|
||||
|
||||
/**
|
||||
* How the blocking MVC profile provides threads for resolver work (design §10).
|
||||
*
|
||||
* <p>Both options allow blocking resolvers, which is the entire point of the {@code BLOCKING_MVC}
|
||||
* profile: JPA, blocking Mongo and blocking SDKs are legitimate here. What is not allowed is
|
||||
* unbounded concurrency — a virtual thread per request still has a bounded connection pool behind
|
||||
* it, and a platform-thread executor is explicitly bounded.
|
||||
*/
|
||||
public enum GraphQlMvcExecutorPolicy {
|
||||
|
||||
/** Java 21 virtual threads: one carrier-light thread per request. */
|
||||
VIRTUAL_THREAD(true),
|
||||
|
||||
/** A bounded platform-thread pool, for deployments not yet on virtual threads. */
|
||||
BOUNDED_PLATFORM_THREAD(true);
|
||||
|
||||
private final boolean blockingAllowed;
|
||||
|
||||
GraphQlMvcExecutorPolicy(boolean blockingAllowed) {
|
||||
this.blockingAllowed = blockingAllowed;
|
||||
}
|
||||
|
||||
/** Whether a blocking resolver may run under this policy. */
|
||||
public boolean blockingAllowed() {
|
||||
return blockingAllowed;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates the executor this policy describes.
|
||||
*
|
||||
* @param boundedPoolSize thread count used by {@link #BOUNDED_PLATFORM_THREAD}
|
||||
*/
|
||||
public ExecutorService createExecutor(int boundedPoolSize) {
|
||||
if (this == VIRTUAL_THREAD) {
|
||||
return Executors.newVirtualThreadPerTaskExecutor();
|
||||
}
|
||||
if (boundedPoolSize < 1) {
|
||||
throw new IllegalArgumentException("bounded pool size must be positive");
|
||||
}
|
||||
ThreadFactory threadFactory = Thread.ofPlatform().name("graphql-mvc-", 0).factory();
|
||||
return Executors.newFixedThreadPool(boundedPoolSize, threadFactory);
|
||||
}
|
||||
}
|
||||
-157
@@ -1,157 +0,0 @@
|
||||
package dev.caskeleton.adapter.inbound.graphql.http.mvc;
|
||||
|
||||
import dev.caskeleton.adapter.inbound.graphql.context.GraphQlRequestContext;
|
||||
import dev.caskeleton.adapter.inbound.graphql.http.GraphQlExecutionOutcome;
|
||||
import dev.caskeleton.adapter.inbound.graphql.http.GraphQlHttpContractException;
|
||||
import dev.caskeleton.adapter.inbound.graphql.http.GraphQlHttpExecutor;
|
||||
import dev.caskeleton.adapter.inbound.graphql.http.GraphQlHttpProfile;
|
||||
import dev.caskeleton.adapter.inbound.graphql.http.GraphQlHttpRequestEnvelope;
|
||||
import dev.caskeleton.adapter.inbound.graphql.http.GraphQlHttpResponse;
|
||||
import dev.caskeleton.adapter.inbound.graphql.http.GraphQlHttpResponseFactory;
|
||||
import dev.caskeleton.adapter.inbound.graphql.http.GraphQlRequestEnvelopeValidator;
|
||||
import java.time.Clock;
|
||||
import java.time.Duration;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.CancellationException;
|
||||
import java.util.concurrent.ExecutionException;
|
||||
import java.util.concurrent.ExecutorService;
|
||||
import java.util.concurrent.Future;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.concurrent.TimeoutException;
|
||||
|
||||
/**
|
||||
* The blocking MVC transport (design §10, Stable plan Task 18).
|
||||
*
|
||||
* <p>Runs execution on the configured executor and bounds it by the request deadline rather than
|
||||
* waiting indefinitely. When the deadline passes the task is cancelled with an interrupt, so a
|
||||
* resolver that respects interruption stops; the design is explicit that interruption alone is not
|
||||
* sufficient, which is why the deadline is also propagated down to the database and HTTP client
|
||||
* budgets.
|
||||
*
|
||||
* <p>No Reactor or WebFlux type appears in this contract, and no transaction is opened here — the
|
||||
* transaction belongs to the Application service the resolver calls.
|
||||
*/
|
||||
public final class GraphQlMvcTransportAdapter {
|
||||
|
||||
private final GraphQlHttpProfile profile;
|
||||
private final GraphQlRequestEnvelopeValidator validator;
|
||||
private final GraphQlHttpExecutor executor;
|
||||
private final ExecutorService executorService;
|
||||
private final GraphQlMvcExecutorPolicy executorPolicy;
|
||||
private final Clock clock;
|
||||
|
||||
/**
|
||||
* Creates the adapter.
|
||||
*
|
||||
* @param profile transport profile in force
|
||||
* @param validator pre-parse envelope limits
|
||||
* @param executor GraphQL execution seam
|
||||
* @param executorService threads resolver work runs on
|
||||
* @param executorPolicy which thread policy {@code executorService} implements
|
||||
* @param clock clock used to compute the remaining request budget
|
||||
*/
|
||||
public GraphQlMvcTransportAdapter(
|
||||
GraphQlHttpProfile profile,
|
||||
GraphQlRequestEnvelopeValidator validator,
|
||||
GraphQlHttpExecutor executor,
|
||||
ExecutorService executorService,
|
||||
GraphQlMvcExecutorPolicy executorPolicy,
|
||||
Clock clock) {
|
||||
if (profile == null
|
||||
|| validator == null
|
||||
|| executor == null
|
||||
|| executorService == null
|
||||
|| executorPolicy == null
|
||||
|| clock == null) {
|
||||
throw new IllegalArgumentException("MVC transport adapter dependencies are required");
|
||||
}
|
||||
if (!executorPolicy.blockingAllowed()) {
|
||||
throw new IllegalArgumentException("the MVC transport requires a blocking-capable executor");
|
||||
}
|
||||
this.profile = profile;
|
||||
this.validator = validator;
|
||||
this.executor = executor;
|
||||
this.executorService = executorService;
|
||||
this.executorPolicy = executorPolicy;
|
||||
this.clock = clock;
|
||||
}
|
||||
|
||||
/** The thread policy resolver work runs under. */
|
||||
public GraphQlMvcExecutorPolicy executorPolicy() {
|
||||
return executorPolicy;
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles one HTTP exchange.
|
||||
*
|
||||
* <p>Never throws for a client-caused failure: a transport violation becomes a response with the
|
||||
* status the profile mandates, so the error contract stays in one place.
|
||||
*/
|
||||
public GraphQlHttpResponse handle(
|
||||
String method,
|
||||
String contentType,
|
||||
String accept,
|
||||
GraphQlHttpRequestEnvelope envelope,
|
||||
GraphQlRequestContext context) {
|
||||
|
||||
GraphQlHttpResponseFactory responses = GraphQlHttpResponseFactory.preferredV1();
|
||||
try {
|
||||
profile.validateMethod(method);
|
||||
profile.validateContentType(contentType);
|
||||
String negotiated = profile.negotiateResponseContentType(accept);
|
||||
responses = GraphQlHttpResponseFactory.v1(negotiated);
|
||||
validator.validateEnvelope(envelope);
|
||||
|
||||
GraphQlExecutionOutcome outcome = executeWithinDeadline(envelope, context);
|
||||
return outcome.failed()
|
||||
? responses.fieldError(outcome.data(), outcome.errors())
|
||||
: responses.success(outcome.data());
|
||||
} catch (GraphQlHttpContractException ex) {
|
||||
return responses.requestError(ex);
|
||||
}
|
||||
}
|
||||
|
||||
private GraphQlExecutionOutcome executeWithinDeadline(
|
||||
GraphQlHttpRequestEnvelope envelope, GraphQlRequestContext context) {
|
||||
|
||||
Duration remaining = context.deadline().remaining(clock);
|
||||
if (remaining.isZero() || remaining.isNegative()) {
|
||||
return timedOut();
|
||||
}
|
||||
|
||||
Future<GraphQlExecutionOutcome> pending =
|
||||
executorService.submit(() -> executor.execute(envelope, context));
|
||||
try {
|
||||
return pending.get(remaining.toMillis(), TimeUnit.MILLISECONDS);
|
||||
} catch (TimeoutException ex) {
|
||||
// Interrupt the in-flight work; the propagated deadline is what actually stops the
|
||||
// downstream database and HTTP calls.
|
||||
pending.cancel(true);
|
||||
return timedOut();
|
||||
} catch (CancellationException ex) {
|
||||
return timedOut();
|
||||
} catch (ExecutionException ex) {
|
||||
Throwable cause = ex.getCause();
|
||||
if (cause instanceof GraphQlHttpContractException contractFailure) {
|
||||
throw contractFailure;
|
||||
}
|
||||
throw new IllegalStateException("GraphQL execution failed", cause);
|
||||
} catch (InterruptedException ex) {
|
||||
Thread.currentThread().interrupt();
|
||||
pending.cancel(true);
|
||||
return timedOut();
|
||||
}
|
||||
}
|
||||
|
||||
private static GraphQlExecutionOutcome timedOut() {
|
||||
return GraphQlExecutionOutcome.partial(
|
||||
Map.of(),
|
||||
List.of(
|
||||
Map.of(
|
||||
"message",
|
||||
"요청을 처리할 수 없습니다.",
|
||||
"extensions",
|
||||
Map.of("code", "REQUEST_TIMEOUT", "category", "TIMEOUT", "retryable", true))));
|
||||
}
|
||||
}
|
||||
-55
@@ -1,55 +0,0 @@
|
||||
package dev.caskeleton.adapter.inbound.graphql.http.webflux;
|
||||
|
||||
import dev.caskeleton.adapter.inbound.graphql.execution.GraphQlExecutionProfileException;
|
||||
import dev.caskeleton.adapter.inbound.graphql.policy.ResolverExecutionType;
|
||||
|
||||
/**
|
||||
* Refuses blocking resolver work on a reactive event loop (design §10).
|
||||
*
|
||||
* <p>A blocking call on an event-loop thread does not fail — it holds one of a handful of threads
|
||||
* that serve every connection, so the symptom is latency across unrelated requests rather than an
|
||||
* error on the offending one. The guard turns that into an explicit failure at the point of
|
||||
* registration or dispatch, and the only way past it is an approved scheduler bridge that moves the
|
||||
* work off the loop.
|
||||
*/
|
||||
public final class GraphQlEventLoopGuard {
|
||||
|
||||
private GraphQlEventLoopGuard() {}
|
||||
|
||||
/**
|
||||
* Verifies one resolver dispatch.
|
||||
*
|
||||
* @param type how the resolver executes
|
||||
* @param eventLoopThread whether the current thread is a reactive event-loop thread
|
||||
* @param approvedBridge whether an approved executor or scheduler bridge is in place
|
||||
* @throws GraphQlExecutionProfileException when blocking work would run on the loop unbridged
|
||||
*/
|
||||
public static void verify(
|
||||
ResolverExecutionType type, boolean eventLoopThread, boolean approvedBridge) {
|
||||
if (eventLoopThread && type == ResolverExecutionType.BLOCKING && !approvedBridge) {
|
||||
throw new GraphQlExecutionProfileException("blocking resolver on event loop");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a thread name belongs to a known reactive event loop.
|
||||
*
|
||||
* <p>Name-based detection keeps the guard usable from a module that does not depend on a specific
|
||||
* server: Reactor Netty, Netty and Undertow all name their loop threads predictably.
|
||||
*/
|
||||
public static boolean isEventLoopThread(String threadName) {
|
||||
if (threadName == null) {
|
||||
return false;
|
||||
}
|
||||
return threadName.startsWith("reactor-http-nio")
|
||||
|| threadName.startsWith("reactor-tcp-nio")
|
||||
|| threadName.startsWith("nioEventLoopGroup")
|
||||
|| threadName.startsWith("XNIO")
|
||||
|| threadName.contains("-eventLoop-");
|
||||
}
|
||||
|
||||
/** Whether the calling thread is a reactive event-loop thread. */
|
||||
public static boolean onEventLoop() {
|
||||
return isEventLoopThread(Thread.currentThread().getName());
|
||||
}
|
||||
}
|
||||
-64
@@ -1,64 +0,0 @@
|
||||
package dev.caskeleton.adapter.inbound.graphql.http.webflux;
|
||||
|
||||
import dev.caskeleton.adapter.inbound.graphql.context.GraphQlRequestContext;
|
||||
import dev.caskeleton.adapter.inbound.graphql.http.GraphQlExecutionOutcome;
|
||||
import dev.caskeleton.adapter.inbound.graphql.http.GraphQlHttpExecutor;
|
||||
import dev.caskeleton.adapter.inbound.graphql.http.GraphQlHttpProfile;
|
||||
import dev.caskeleton.adapter.inbound.graphql.http.GraphQlHttpRequestEnvelope;
|
||||
import dev.caskeleton.adapter.inbound.graphql.http.GraphQlRequestEnvelopeValidator;
|
||||
import java.time.Clock;
|
||||
import java.util.function.BiFunction;
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnBean;
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnClass;
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean;
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnWebApplication;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.web.reactive.function.server.ServerResponse;
|
||||
import reactor.core.publisher.Mono;
|
||||
|
||||
/**
|
||||
* Wires the reactive transport when the application runs the WebFlux stack.
|
||||
*
|
||||
* <p>Conditional on a reactive web application, on WebFlux being present and on {@code
|
||||
* REACTIVE_WEBFLUX} being the selected execution profile — a blocking deployment must never acquire
|
||||
* a reactive transport implicitly, and vice versa.
|
||||
*/
|
||||
@Configuration(proxyBeanMethods = false)
|
||||
@ConditionalOnClass(ServerResponse.class)
|
||||
@ConditionalOnWebApplication(type = ConditionalOnWebApplication.Type.REACTIVE)
|
||||
@ConditionalOnProperty(
|
||||
prefix = "backend.graphql",
|
||||
name = "execution-profile",
|
||||
havingValue = "REACTIVE_WEBFLUX")
|
||||
public class GraphQlWebFluxAutoConfiguration {
|
||||
|
||||
/**
|
||||
* Adapts the blocking execution seam onto a reactive one.
|
||||
*
|
||||
* <p>Only registered when the application supplied a {@link GraphQlHttpExecutor} and no reactive
|
||||
* seam of its own. Execution is deferred rather than invoked eagerly, so subscription — and
|
||||
* therefore cancellation — controls when the work starts.
|
||||
*/
|
||||
@Bean
|
||||
@ConditionalOnMissingBean(name = "graphQlReactiveExecutor")
|
||||
@ConditionalOnBean(GraphQlHttpExecutor.class)
|
||||
public BiFunction<
|
||||
GraphQlHttpRequestEnvelope, GraphQlRequestContext, Mono<GraphQlExecutionOutcome>>
|
||||
graphQlReactiveExecutor(GraphQlHttpExecutor executor) {
|
||||
return (envelope, context) -> Mono.fromCallable(() -> executor.execute(envelope, context));
|
||||
}
|
||||
|
||||
/** The reactive transport adapter. */
|
||||
@Bean
|
||||
@ConditionalOnMissingBean
|
||||
@ConditionalOnBean(GraphQlRequestEnvelopeValidator.class)
|
||||
public GraphQlWebFluxTransportAdapter graphQlWebFluxTransportAdapter(
|
||||
GraphQlRequestEnvelopeValidator validator,
|
||||
BiFunction<GraphQlHttpRequestEnvelope, GraphQlRequestContext, Mono<GraphQlExecutionOutcome>>
|
||||
graphQlReactiveExecutor) {
|
||||
return new GraphQlWebFluxTransportAdapter(
|
||||
GraphQlHttpProfile.V1, validator, graphQlReactiveExecutor, Clock.systemUTC());
|
||||
}
|
||||
}
|
||||
-110
@@ -1,110 +0,0 @@
|
||||
package dev.caskeleton.adapter.inbound.graphql.http.webflux;
|
||||
|
||||
import dev.caskeleton.adapter.inbound.graphql.context.GraphQlRequestContext;
|
||||
import dev.caskeleton.adapter.inbound.graphql.http.GraphQlExecutionOutcome;
|
||||
import dev.caskeleton.adapter.inbound.graphql.http.GraphQlHttpContractException;
|
||||
import dev.caskeleton.adapter.inbound.graphql.http.GraphQlHttpProfile;
|
||||
import dev.caskeleton.adapter.inbound.graphql.http.GraphQlHttpRequestEnvelope;
|
||||
import dev.caskeleton.adapter.inbound.graphql.http.GraphQlHttpResponse;
|
||||
import dev.caskeleton.adapter.inbound.graphql.http.GraphQlHttpResponseFactory;
|
||||
import dev.caskeleton.adapter.inbound.graphql.http.GraphQlRequestEnvelopeValidator;
|
||||
import java.time.Clock;
|
||||
import java.time.Duration;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.function.BiFunction;
|
||||
import reactor.core.publisher.Mono;
|
||||
|
||||
/**
|
||||
* The reactive transport (design §10, Stable plan Task 19).
|
||||
*
|
||||
* <p>Reactive rather than blocking all the way through: the request deadline is applied with {@code
|
||||
* timeout}, which cancels the upstream chain, and cancellation is what actually reaches reactive
|
||||
* data fetchers and downstream publishers. A blocking {@code Future.get} would leave that work
|
||||
* running after the client had already been answered.
|
||||
*
|
||||
* <p>{@code block()} is never called here, and the request context is carried in the Reactor
|
||||
* context so it survives operator boundaries and thread hops.
|
||||
*/
|
||||
public final class GraphQlWebFluxTransportAdapter {
|
||||
|
||||
private final GraphQlHttpProfile profile;
|
||||
private final GraphQlRequestEnvelopeValidator validator;
|
||||
private final BiFunction<
|
||||
GraphQlHttpRequestEnvelope, GraphQlRequestContext, Mono<GraphQlExecutionOutcome>>
|
||||
executor;
|
||||
private final Clock clock;
|
||||
|
||||
/**
|
||||
* Creates the adapter.
|
||||
*
|
||||
* @param profile transport profile in force
|
||||
* @param validator pre-parse envelope limits
|
||||
* @param executor reactive GraphQL execution seam
|
||||
* @param clock clock used to compute the remaining request budget
|
||||
*/
|
||||
public GraphQlWebFluxTransportAdapter(
|
||||
GraphQlHttpProfile profile,
|
||||
GraphQlRequestEnvelopeValidator validator,
|
||||
BiFunction<GraphQlHttpRequestEnvelope, GraphQlRequestContext, Mono<GraphQlExecutionOutcome>>
|
||||
executor,
|
||||
Clock clock) {
|
||||
if (profile == null || validator == null || executor == null || clock == null) {
|
||||
throw new IllegalArgumentException("reactive transport adapter dependencies are required");
|
||||
}
|
||||
this.profile = profile;
|
||||
this.validator = validator;
|
||||
this.executor = executor;
|
||||
this.clock = clock;
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles one HTTP exchange reactively.
|
||||
*
|
||||
* <p>A transport violation becomes a response rather than an error signal, so the status contract
|
||||
* stays identical to the MVC transport.
|
||||
*/
|
||||
public Mono<GraphQlHttpResponse> handle(
|
||||
String method,
|
||||
String contentType,
|
||||
String accept,
|
||||
GraphQlHttpRequestEnvelope envelope,
|
||||
GraphQlRequestContext context) {
|
||||
|
||||
GraphQlHttpResponseFactory preferred = GraphQlHttpResponseFactory.preferredV1();
|
||||
GraphQlHttpResponseFactory responses;
|
||||
try {
|
||||
profile.validateMethod(method);
|
||||
profile.validateContentType(contentType);
|
||||
responses = GraphQlHttpResponseFactory.v1(profile.negotiateResponseContentType(accept));
|
||||
validator.validateEnvelope(envelope);
|
||||
} catch (GraphQlHttpContractException ex) {
|
||||
return Mono.just(preferred.requestError(ex));
|
||||
}
|
||||
|
||||
GraphQlHttpResponseFactory negotiated = responses;
|
||||
Duration remaining = context.deadline().remaining(clock);
|
||||
if (remaining.isZero() || remaining.isNegative()) {
|
||||
return Mono.just(negotiated.fieldError(Map.of(), timeoutErrors()));
|
||||
}
|
||||
|
||||
return Mono.defer(() -> executor.apply(envelope, context))
|
||||
.timeout(remaining, Mono.just(GraphQlExecutionOutcome.partial(Map.of(), timeoutErrors())))
|
||||
.map(
|
||||
outcome ->
|
||||
outcome.failed()
|
||||
? negotiated.fieldError(outcome.data(), outcome.errors())
|
||||
: negotiated.success(outcome.data()))
|
||||
.contextWrite(
|
||||
reactorContext -> reactorContext.put(GraphQlRequestContext.CONTEXT_KEY, context));
|
||||
}
|
||||
|
||||
private static List<Map<String, Object>> timeoutErrors() {
|
||||
return List.of(
|
||||
Map.of(
|
||||
"message",
|
||||
"요청을 처리할 수 없습니다.",
|
||||
"extensions",
|
||||
Map.of("code", "REQUEST_TIMEOUT", "category", "TIMEOUT", "retryable", true)));
|
||||
}
|
||||
}
|
||||
+156
@@ -0,0 +1,156 @@
|
||||
package dev.caskeleton.adapter.inbound.graphql.moduleboundary;
|
||||
|
||||
import java.util.Arrays;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.TreeMap;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
/**
|
||||
* The Advanced GraphQL capability modules and their allowed internal dependencies.
|
||||
*
|
||||
* <p>Advanced consumes Stable, never the other way round. If a Stable module depended on an
|
||||
* Advanced one, every Stable deployment would carry the Advanced capability's code and
|
||||
* configuration surface, and the feature flag would be the only thing standing between an ordinary
|
||||
* service and a subscription runtime. {@code GraphQlAdvancedDependencyRules} checks that direction
|
||||
* against this declaration; {@code GraphQlModuleBoundaryTest} checks it against the real imports.
|
||||
*
|
||||
* <p>Advanced modules are {@link GraphQlModulePurity#CORE} by default: the capabilities are state
|
||||
* machines and policies, and the transport binding for the ones that need it stays in the Stable
|
||||
* {@code http} seam. Exceptions are declared per module rather than assumed — {@code
|
||||
* advanced.codegen} is the one that has to compile a schema to do its job.
|
||||
*/
|
||||
public enum GraphQlAdvancedModule {
|
||||
|
||||
/** Persisted operation administration and removal gating. */
|
||||
ADMIN("advanced.admin", "advanced.admin", "advanced.persisted"),
|
||||
|
||||
/** Advanced capability grades, feature flags and the activation guard. */
|
||||
BOOTSTRAP("advanced.bootstrap", "advanced.bootstrap", "moduleboundary"),
|
||||
|
||||
/** DataLoader chaining and cycle detection. */
|
||||
CHAINING("advanced.chaining", "advanced.chaining", "advanced.bootstrap"),
|
||||
|
||||
/**
|
||||
* Client code generation planning, operation validation and generated-source boundary rules.
|
||||
*
|
||||
* <p>The one Advanced module that is not framework free. Validating a client operation means
|
||||
* compiling the schema and running GraphQL Java's validator — there is no way to answer "does
|
||||
* this field exist" without the type system, and a hand-rolled approximation would be the kind of
|
||||
* check that passes on exactly the documents that break.
|
||||
*/
|
||||
CODEGEN("advanced.codegen", "advanced.codegen", GraphQlModulePurity.FRAMEWORK_BOUND, "compat"),
|
||||
|
||||
/** Federated schema composition gating. */
|
||||
COMPOSITION("advanced.composition", "advanced.composition", "advanced.federation"),
|
||||
|
||||
/** Federation entity resolution. */
|
||||
FEDERATION("advanced.federation", "advanced.federation", "advanced.bootstrap"),
|
||||
|
||||
/** The draft HTTP GET operation profile. */
|
||||
GET("advanced.get", "advanced.get", "http"),
|
||||
|
||||
/** Incremental delivery (`@defer`/`@stream`) compatibility gating. */
|
||||
INCREMENTAL("advanced.incremental", "advanced.incremental", "cost", "execution"),
|
||||
|
||||
/** Persisted operation registry and the interceptor that enforces it. */
|
||||
PERSISTED(
|
||||
"advanced.persisted", "advanced.persisted", "advanced.bootstrap", "execution", "policy"),
|
||||
|
||||
/** The Advanced release gate. */
|
||||
RELEASE("advanced.release", "advanced.release", "advanced.bootstrap", "release"),
|
||||
|
||||
/** Subscription snapshot replay and live handoff. */
|
||||
REPLAY(
|
||||
"advanced.replay",
|
||||
"advanced.replay",
|
||||
"advanced.security",
|
||||
"advanced.subscription",
|
||||
"pagination"),
|
||||
|
||||
/** The experimental RSocket transport route policy. */
|
||||
RSOCKET("advanced.rsocket", "advanced.rsocket", "advanced.bootstrap", "error", "policy"),
|
||||
|
||||
/** Transport authentication for long-lived Advanced connections. */
|
||||
SECURITY("advanced.security", "advanced.security"),
|
||||
|
||||
/** Server-sent event connection policy. */
|
||||
SSE("advanced.sse", "advanced.sse", "advanced.bootstrap", "advanced.subscription", "http"),
|
||||
|
||||
/** Subscription execution policy, buffering and backpressure. */
|
||||
SUBSCRIPTION(
|
||||
"advanced.subscription",
|
||||
"advanced.subscription",
|
||||
"advanced.security",
|
||||
"api",
|
||||
"http",
|
||||
"security"),
|
||||
|
||||
/** The GraphQL over WebSocket protocol state machine. */
|
||||
WEBSOCKET("advanced.websocket", "advanced.websocket", "advanced.bootstrap");
|
||||
|
||||
private final String id;
|
||||
private final String packageSuffix;
|
||||
private final GraphQlModulePurity purity;
|
||||
|
||||
/**
|
||||
* Populated only from {@link Set#of}, which is genuinely immutable. Error Prone's {@code
|
||||
* ImmutableEnumChecker} recognises Guava's {@code ImmutableSet} but not the JDK's unmodifiable
|
||||
* factories, and this leaf has no Guava dependency to add for one field.
|
||||
*/
|
||||
@SuppressWarnings("ImmutableEnumChecker")
|
||||
private final Set<String> allowedDependencies;
|
||||
|
||||
GraphQlAdvancedModule(String id, String packageSuffix, String... allowedDependencies) {
|
||||
this(id, packageSuffix, GraphQlModulePurity.CORE, allowedDependencies);
|
||||
}
|
||||
|
||||
GraphQlAdvancedModule(
|
||||
String id, String packageSuffix, GraphQlModulePurity purity, String... allowedDependencies) {
|
||||
this.id = id;
|
||||
this.packageSuffix = packageSuffix;
|
||||
this.purity = purity;
|
||||
this.allowedDependencies = Set.of(allowedDependencies);
|
||||
}
|
||||
|
||||
/** The module identifier used on both sides of a declared dependency edge. */
|
||||
public String id() {
|
||||
return id;
|
||||
}
|
||||
|
||||
/** The fully qualified package that carries this module, including its sub-packages. */
|
||||
public String packageName() {
|
||||
return GraphQlModuleBoundary.PACKAGE_ROOT + "." + packageSuffix;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether this capability may reference framework types.
|
||||
*
|
||||
* <p>Advanced capabilities are policy and state machines, so they are framework free by default
|
||||
* and the exceptions are declared one by one rather than assumed.
|
||||
*/
|
||||
public GraphQlModulePurity purity() {
|
||||
return purity;
|
||||
}
|
||||
|
||||
/** The module identifiers this module is allowed to import. */
|
||||
public Set<String> allowedDependencies() {
|
||||
return allowedDependencies;
|
||||
}
|
||||
|
||||
/** Every Advanced module identifier. */
|
||||
public static Set<String> moduleIds() {
|
||||
return Arrays.stream(values())
|
||||
.map(GraphQlAdvancedModule::id)
|
||||
.collect(Collectors.toUnmodifiableSet());
|
||||
}
|
||||
|
||||
/** The declared Advanced dependency edges, keyed by module identifier in deterministic order. */
|
||||
public static Map<String, Set<String>> dependencyEdges() {
|
||||
Map<String, Set<String>> edges = new TreeMap<>();
|
||||
for (GraphQlAdvancedModule module : values()) {
|
||||
edges.put(module.id(), module.allowedDependencies());
|
||||
}
|
||||
return Map.copyOf(edges);
|
||||
}
|
||||
}
|
||||
+126
@@ -0,0 +1,126 @@
|
||||
package dev.caskeleton.adapter.inbound.graphql.moduleboundary;
|
||||
|
||||
import java.util.Arrays;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
import java.util.Set;
|
||||
import java.util.TreeMap;
|
||||
import java.util.stream.Collectors;
|
||||
import java.util.stream.Stream;
|
||||
|
||||
/**
|
||||
* The declared module map of the GraphQL platform: identities, purity grades and allowed edges.
|
||||
*
|
||||
* <p>This type is the read side of {@link GraphQlStableModule} and {@link GraphQlAdvancedModule}.
|
||||
* It answers the two questions every boundary check needs — "which module owns this package?" and
|
||||
* "is this edge declared?" — so the rules never re-implement package-prefix arithmetic.
|
||||
*
|
||||
* <p>Note what it deliberately does not do: it never reads the source tree. Scanning the checkout
|
||||
* is build-time work and lives in the test source set, because a running application cannot
|
||||
* meaningfully react to its own source layout and a runtime scan only fails in the environments
|
||||
* where the sources are absent.
|
||||
*/
|
||||
public final class GraphQlModuleBoundary {
|
||||
|
||||
/** The package that carries the whole platform. */
|
||||
public static final String PACKAGE_ROOT = deriveRootPackage();
|
||||
|
||||
private GraphQlModuleBoundary() {}
|
||||
|
||||
/** Every declared module identifier, Stable and Advanced. */
|
||||
public static Set<String> allModuleIds() {
|
||||
return Stream.concat(
|
||||
GraphQlStableModule.moduleIds().stream(), GraphQlAdvancedModule.moduleIds().stream())
|
||||
.collect(Collectors.toUnmodifiableSet());
|
||||
}
|
||||
|
||||
/** Every declared dependency edge, Stable and Advanced, in deterministic order. */
|
||||
public static Map<String, Set<String>> dependencyEdges() {
|
||||
Map<String, Set<String>> edges = new TreeMap<>();
|
||||
edges.putAll(GraphQlStableModule.dependencyEdges());
|
||||
edges.putAll(GraphQlAdvancedModule.dependencyEdges());
|
||||
return Map.copyOf(edges);
|
||||
}
|
||||
|
||||
/** Every module that must not reference a framework type. */
|
||||
public static Set<String> coreModuleIds() {
|
||||
return Stream.concat(
|
||||
GraphQlStableModule.coreModuleIds().stream(),
|
||||
Arrays.stream(GraphQlAdvancedModule.values())
|
||||
.filter(module -> module.purity() == GraphQlModulePurity.CORE)
|
||||
.map(GraphQlAdvancedModule::id))
|
||||
.collect(Collectors.toUnmodifiableSet());
|
||||
}
|
||||
|
||||
/** The declared package of every module, keyed by identifier. */
|
||||
public static Map<String, String> packagesById() {
|
||||
Map<String, String> packages = new LinkedHashMap<>(GraphQlStableModule.packagesById());
|
||||
for (GraphQlAdvancedModule module : GraphQlAdvancedModule.values()) {
|
||||
packages.put(module.id(), module.packageName());
|
||||
}
|
||||
return Map.copyOf(packages);
|
||||
}
|
||||
|
||||
/**
|
||||
* The module owning a package.
|
||||
*
|
||||
* <p>Ownership is by longest declared package prefix, so {@code ...graphql.http.webflux} belongs
|
||||
* to {@code http} and {@code ...graphql.advanced.sse} belongs to {@code advanced.sse} rather than
|
||||
* to a hypothetical {@code advanced} module.
|
||||
*
|
||||
* <p>The root module is the exception: it owns the platform root package itself and nothing below
|
||||
* it. Letting it absorb descendants would make every possible package "declared", and the
|
||||
* unregistered-package rule — the one that forces a new sub-package to be given an identity and
|
||||
* an edge set before it can ship — would silently never fire.
|
||||
*
|
||||
* @param packageName a fully qualified package name
|
||||
* @return the owning module identifier, or empty when the package is outside the platform or
|
||||
* belongs to no declared module
|
||||
*/
|
||||
public static Optional<String> moduleIdForPackage(String packageName) {
|
||||
if (packageName == null || !insidePlatform(packageName)) {
|
||||
return Optional.empty();
|
||||
}
|
||||
String best = null;
|
||||
String bestPackage = null;
|
||||
for (Map.Entry<String, String> candidate : packagesById().entrySet()) {
|
||||
String candidatePackage = candidate.getValue();
|
||||
boolean owns =
|
||||
candidatePackage.equals(PACKAGE_ROOT)
|
||||
? packageName.equals(PACKAGE_ROOT)
|
||||
: matches(packageName, candidatePackage);
|
||||
if (!owns) {
|
||||
continue;
|
||||
}
|
||||
if (bestPackage == null || candidatePackage.length() > bestPackage.length()) {
|
||||
best = candidate.getKey();
|
||||
bestPackage = candidatePackage;
|
||||
}
|
||||
}
|
||||
return Optional.ofNullable(best);
|
||||
}
|
||||
|
||||
/** Whether a package name belongs to the platform at all. */
|
||||
public static boolean insidePlatform(String packageName) {
|
||||
return packageName != null && matches(packageName, PACKAGE_ROOT);
|
||||
}
|
||||
|
||||
/** Whether an edge between two declared modules is allowed. */
|
||||
public static boolean edgeAllowed(String from, String to) {
|
||||
if (from == null || to == null || from.equals(to)) {
|
||||
return true;
|
||||
}
|
||||
Set<String> allowed = dependencyEdges().get(from);
|
||||
return allowed != null && allowed.contains(to);
|
||||
}
|
||||
|
||||
private static boolean matches(String packageName, String candidate) {
|
||||
return packageName.equals(candidate) || packageName.startsWith(candidate + ".");
|
||||
}
|
||||
|
||||
private static String deriveRootPackage() {
|
||||
String self = GraphQlModuleBoundary.class.getPackageName();
|
||||
return self.substring(0, self.lastIndexOf('.'));
|
||||
}
|
||||
}
|
||||
+19
@@ -0,0 +1,19 @@
|
||||
package dev.caskeleton.adapter.inbound.graphql.moduleboundary;
|
||||
|
||||
/**
|
||||
* Whether a platform module is allowed to reference transport, framework or GraphQL engine types.
|
||||
*
|
||||
* <p>The split is what keeps the policy model portable. A {@link #CORE} module holds the decision
|
||||
* ("this document is too deep", "this cursor is out of scope") as plain Java, so the same rule can
|
||||
* be exercised by a unit test, reused from a different transport, or promoted to its own leaf
|
||||
* without dragging a servlet container along. A {@link #FRAMEWORK_BOUND} module is the seam where
|
||||
* that decision meets Spring, GraphQL Java or Reactor.
|
||||
*/
|
||||
public enum GraphQlModulePurity {
|
||||
|
||||
/** Java standard library only: no Spring, GraphQL Java, Reactor, Micrometer or Jakarta types. */
|
||||
CORE,
|
||||
|
||||
/** May bind to the framework, because it is the adapter seam that has to. */
|
||||
FRAMEWORK_BOUND
|
||||
}
|
||||
+224
@@ -0,0 +1,224 @@
|
||||
package dev.caskeleton.adapter.inbound.graphql.moduleboundary;
|
||||
|
||||
import java.util.Arrays;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.TreeMap;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
/**
|
||||
* The Stable GraphQL platform modules, their purity grade and their allowed internal dependencies.
|
||||
*
|
||||
* <p>The platform splits into bounded sub-packages inside one registered leaf rather than into
|
||||
* Gradle leaves (see {@code CLAUDE.md}). That choice only holds if the boundaries are machine
|
||||
* checked, so this enum is the declared identity: each constant names a module, the package that
|
||||
* carries it, whether it may touch the framework, and exactly which other modules it may import.
|
||||
* {@code GraphQlModuleBoundaryTest} scans the real source tree and fails when the tree and this
|
||||
* declaration disagree in either direction — an undeclared edge, or an undeclared package.
|
||||
*
|
||||
* <p>Declaring the edge set is what makes the leaf split reversible: each constant is already
|
||||
* shaped like a leaf specification, so promoting a module to its own Gradle path is a registry edit
|
||||
* rather than an archaeology exercise.
|
||||
*/
|
||||
public enum GraphQlStableModule {
|
||||
|
||||
/** Skeleton transport surface: health schema controller and the Spring exception resolver. */
|
||||
TRANSPORT_ROOT("root", "", GraphQlModulePurity.FRAMEWORK_BOUND),
|
||||
|
||||
/** Shared identifiers and value types every other module is allowed to speak. */
|
||||
API("api", "api", GraphQlModulePurity.CORE),
|
||||
|
||||
/** Resolver and controller boundary rules enforced by reflection over the compiled package. */
|
||||
ARCHITECTURE("architecture", "architecture", GraphQlModulePurity.FRAMEWORK_BOUND),
|
||||
|
||||
/** Spring auto-configuration, configuration properties and startup validation. */
|
||||
AUTOCONFIGURE(
|
||||
"autoconfigure",
|
||||
"autoconfigure",
|
||||
GraphQlModulePurity.FRAMEWORK_BOUND,
|
||||
"api",
|
||||
"architecture",
|
||||
"context",
|
||||
"cost",
|
||||
"error",
|
||||
"execution",
|
||||
"http",
|
||||
"observation",
|
||||
"policy",
|
||||
"runtime",
|
||||
"scalar",
|
||||
"schema",
|
||||
"security"),
|
||||
|
||||
/** Schema compatibility comparison and deprecation gating. */
|
||||
COMPAT("compat", "compat", GraphQlModulePurity.FRAMEWORK_BOUND, "schema"),
|
||||
|
||||
/** Per-request execution context: actor, tenant and deadline. */
|
||||
CONTEXT("context", "context", GraphQlModulePurity.CORE, "api"),
|
||||
|
||||
/** Document shape analysis, complexity scoring and runtime budget tracking. */
|
||||
COST("cost", "cost", GraphQlModulePurity.FRAMEWORK_BOUND, "api", "execution", "policy"),
|
||||
|
||||
/** Batch loading, chunking and per-request DataLoader registry. */
|
||||
DATALOADER("dataloader", "dataloader", GraphQlModulePurity.CORE, "context"),
|
||||
|
||||
/** Wire error shape, error categories and null propagation contract. */
|
||||
ERROR("error", "error", GraphQlModulePurity.CORE, "api", "http"),
|
||||
|
||||
/** Execution pipeline stages, operation naming, timeouts and document caching. */
|
||||
EXECUTION("execution", "execution", GraphQlModulePurity.CORE, "api", "context", "policy"),
|
||||
|
||||
/** Fetch profile classification and registry. */
|
||||
FETCH("fetch", "fetch", GraphQlModulePurity.CORE, "api"),
|
||||
|
||||
/**
|
||||
* HTTP profile, request envelope validation, media negotiation and status mapping.
|
||||
*
|
||||
* <p>{@code CORE} since the custom MVC and WebFlux transport adapters were removed: what remains
|
||||
* is the platform's opinion about the HTTP contract, expressed as plain values. The route belongs
|
||||
* to Spring, and the seam that applies these decisions to it lives in {@code runtime}.
|
||||
*/
|
||||
HTTP("http", "http", GraphQlModulePurity.CORE, "api", "context", "execution", "policy"),
|
||||
|
||||
/** This module: the declared module identities and their allowed edges. */
|
||||
MODULE_BOUNDARY("moduleboundary", "moduleboundary", GraphQlModulePurity.CORE),
|
||||
|
||||
/** Mutation idempotency context and result mapping. */
|
||||
MUTATION("mutation", "mutation", GraphQlModulePurity.CORE, "api", "context", "http"),
|
||||
|
||||
/** Observation conventions, metric cardinality policy and sensitive attribute filtering. */
|
||||
OBSERVATION(
|
||||
"observation",
|
||||
"observation",
|
||||
GraphQlModulePurity.CORE,
|
||||
"api",
|
||||
"cost",
|
||||
"dataloader",
|
||||
"policy"),
|
||||
|
||||
/** Connection assembly and signed keyset cursors. */
|
||||
PAGINATION("pagination", "pagination", GraphQlModulePurity.CORE, "policy"),
|
||||
|
||||
/** Client and operation policy values shared by the enforcement modules. */
|
||||
POLICY("policy", "policy", GraphQlModulePurity.CORE, "api"),
|
||||
|
||||
/** Release gate, performance and fault scenario catalogues. */
|
||||
RELEASE("release", "release", GraphQlModulePurity.CORE),
|
||||
|
||||
/** The executable pipeline and the Spring GraphQL seams that run it on a real request. */
|
||||
RUNTIME(
|
||||
"runtime",
|
||||
"runtime",
|
||||
GraphQlModulePurity.FRAMEWORK_BOUND,
|
||||
"api",
|
||||
"context",
|
||||
"cost",
|
||||
"dataloader",
|
||||
"error",
|
||||
"execution",
|
||||
"http",
|
||||
"policy",
|
||||
"security"),
|
||||
|
||||
/** Custom scalar coercions and the runtime wiring configurer that registers them. */
|
||||
SCALAR("scalar", "scalar", GraphQlModulePurity.FRAMEWORK_BOUND, "schema"),
|
||||
|
||||
/** Schema assembly, mapping inspection and scalar manifest. */
|
||||
SCHEMA("schema", "schema", GraphQlModulePurity.FRAMEWORK_BOUND, "api"),
|
||||
|
||||
/** Authentication context, authorization policy and tenant isolation. */
|
||||
SECURITY("security", "security", GraphQlModulePurity.CORE, "api", "context", "error"),
|
||||
|
||||
/** Cross-module contract suites and integration fixtures. */
|
||||
TESTKIT(
|
||||
"testkit",
|
||||
"testkit",
|
||||
GraphQlModulePurity.CORE,
|
||||
"api",
|
||||
"compat",
|
||||
"context",
|
||||
"dataloader",
|
||||
"error",
|
||||
"execution",
|
||||
"http",
|
||||
"pagination",
|
||||
"policy",
|
||||
"schema",
|
||||
"security");
|
||||
|
||||
private final String id;
|
||||
private final String packageSuffix;
|
||||
private final GraphQlModulePurity purity;
|
||||
|
||||
/**
|
||||
* Populated only from {@link Set#of}, which is genuinely immutable. Error Prone's {@code
|
||||
* ImmutableEnumChecker} recognises Guava's {@code ImmutableSet} but not the JDK's unmodifiable
|
||||
* factories, and this leaf has no Guava dependency to add for one field.
|
||||
*/
|
||||
@SuppressWarnings("ImmutableEnumChecker")
|
||||
private final Set<String> allowedDependencies;
|
||||
|
||||
GraphQlStableModule(
|
||||
String id, String packageSuffix, GraphQlModulePurity purity, String... allowedDependencies) {
|
||||
this.id = id;
|
||||
this.packageSuffix = packageSuffix;
|
||||
this.purity = purity;
|
||||
this.allowedDependencies = Set.of(allowedDependencies);
|
||||
}
|
||||
|
||||
/** The module identifier used on both sides of a declared dependency edge. */
|
||||
public String id() {
|
||||
return id;
|
||||
}
|
||||
|
||||
/** The fully qualified package that carries this module, including its sub-packages. */
|
||||
public String packageName() {
|
||||
return packageSuffix.isEmpty()
|
||||
? GraphQlModuleBoundary.PACKAGE_ROOT
|
||||
: GraphQlModuleBoundary.PACKAGE_ROOT + "." + packageSuffix;
|
||||
}
|
||||
|
||||
/** Whether this module may reference framework types. */
|
||||
public GraphQlModulePurity purity() {
|
||||
return purity;
|
||||
}
|
||||
|
||||
/** The module identifiers this module is allowed to import. */
|
||||
public Set<String> allowedDependencies() {
|
||||
return allowedDependencies;
|
||||
}
|
||||
|
||||
/** Every Stable module identifier. */
|
||||
public static Set<String> moduleIds() {
|
||||
return Arrays.stream(values())
|
||||
.map(GraphQlStableModule::id)
|
||||
.collect(Collectors.toUnmodifiableSet());
|
||||
}
|
||||
|
||||
/** The declared Stable dependency edges, keyed by module identifier in deterministic order. */
|
||||
public static Map<String, Set<String>> dependencyEdges() {
|
||||
Map<String, Set<String>> edges = new TreeMap<>();
|
||||
for (GraphQlStableModule module : values()) {
|
||||
edges.put(module.id(), module.allowedDependencies());
|
||||
}
|
||||
return Map.copyOf(edges);
|
||||
}
|
||||
|
||||
/** Every Stable module that must not reference a framework type. */
|
||||
public static Set<String> coreModuleIds() {
|
||||
return Arrays.stream(values())
|
||||
.filter(module -> module.purity() == GraphQlModulePurity.CORE)
|
||||
.map(GraphQlStableModule::id)
|
||||
.collect(Collectors.toUnmodifiableSet());
|
||||
}
|
||||
|
||||
/** The declared package name of every Stable module, keyed by identifier. */
|
||||
public static Map<String, String> packagesById() {
|
||||
Map<String, String> packages = new LinkedHashMap<>();
|
||||
for (GraphQlStableModule module : values()) {
|
||||
packages.put(module.id(), module.packageName());
|
||||
}
|
||||
return Map.copyOf(packages);
|
||||
}
|
||||
}
|
||||
+87
@@ -0,0 +1,87 @@
|
||||
package dev.caskeleton.adapter.inbound.graphql.mutation;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.TreeMap;
|
||||
|
||||
/**
|
||||
* The one canonical serialization a mutation input is fingerprinted from.
|
||||
*
|
||||
* <p>The previous form sorted top-level keys and joined {@code key=value;}, which two different
|
||||
* inputs could produce identically: {@code {a: "b;c=d"}} and {@code {a: "b", c: "d"}} both
|
||||
* canonicalise to {@code a=b;c=d;}. Two different requests sharing a fingerprint is an idempotency
|
||||
* collision — the second one is answered with the first one's result.
|
||||
*
|
||||
* <p>Three properties remove the ambiguity. Every value carries a type tag, so the string {@code
|
||||
* "1"} and the number {@code 1} never collide. Every string is length-prefixed, so no character is
|
||||
* a separator and no value can imitate the framing. And nesting is sorted recursively, so a map
|
||||
* inside a list inside a map still canonicalises the same way whatever order it arrived in.
|
||||
*
|
||||
* <p>Numbers normalise through {@link BigDecimal}, because {@code 1}, {@code 1.0} and {@code 1e0}
|
||||
* are the same value and a client library is free to pick any of them for the same field.
|
||||
*/
|
||||
public final class GraphQlCanonicalInput {
|
||||
|
||||
/** Deepest input nesting this serializer will walk before refusing. */
|
||||
public static final int MAXIMUM_DEPTH = 64;
|
||||
|
||||
private GraphQlCanonicalInput() {}
|
||||
|
||||
/**
|
||||
* Canonicalises a decoded input value.
|
||||
*
|
||||
* @param value a decoded JSON value: map, list, string, number, boolean or {@code null}
|
||||
* @throws GraphQlMutationContractException when nesting exceeds {@link #MAXIMUM_DEPTH}
|
||||
*/
|
||||
public static String of(Object value) {
|
||||
StringBuilder canonical = new StringBuilder();
|
||||
write(canonical, value, 0);
|
||||
return canonical.toString();
|
||||
}
|
||||
|
||||
private static void write(StringBuilder out, Object value, int depth) {
|
||||
if (depth > MAXIMUM_DEPTH) {
|
||||
throw new GraphQlMutationContractException(
|
||||
"mutation input nesting exceeds the canonical limit");
|
||||
}
|
||||
if (value == null) {
|
||||
out.append("z;");
|
||||
return;
|
||||
}
|
||||
if (value instanceof Map<?, ?> map) {
|
||||
out.append("m").append(map.size()).append(';');
|
||||
// Recursive, not just top level: an unsorted nested map would fingerprint differently for
|
||||
// the same request depending on the client's serialization order.
|
||||
Map<String, Object> sorted = new TreeMap<>();
|
||||
map.forEach((key, entry) -> sorted.put(String.valueOf(key), entry));
|
||||
sorted.forEach(
|
||||
(key, entry) -> {
|
||||
writeString(out, key);
|
||||
write(out, entry, depth + 1);
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (value instanceof List<?> list) {
|
||||
// Order-sensitive on purpose: a list is a sequence, and two orders are two different inputs.
|
||||
out.append("l").append(list.size()).append(';');
|
||||
list.forEach(element -> write(out, element, depth + 1));
|
||||
return;
|
||||
}
|
||||
if (value instanceof Boolean flag) {
|
||||
out.append('b').append(flag ? '1' : '0').append(';');
|
||||
return;
|
||||
}
|
||||
if (value instanceof Number number) {
|
||||
out.append('n');
|
||||
writeString(out, new BigDecimal(number.toString()).stripTrailingZeros().toPlainString());
|
||||
return;
|
||||
}
|
||||
out.append('s');
|
||||
writeString(out, String.valueOf(value));
|
||||
}
|
||||
|
||||
private static void writeString(StringBuilder out, String value) {
|
||||
out.append(value.length()).append(':').append(value).append(';');
|
||||
}
|
||||
}
|
||||
+6
-2
@@ -23,12 +23,16 @@ public final class GraphQlMutationContractValidator {
|
||||
*/
|
||||
public static void requireSingleUseCase(
|
||||
GraphQlMutationCoordinate coordinate, int useCaseInvocations) {
|
||||
if (useCaseInvocations > 1) {
|
||||
// Exactly one, not "at most one". Zero invocations means the mutation resolved without going
|
||||
// through the Application at all, which is the shape where transaction and authorization
|
||||
// decisions end up in the resolver — the thing this contract exists to prevent. It read as a
|
||||
// passing check because the interesting number is on the other side of the boundary.
|
||||
if (useCaseInvocations != 1) {
|
||||
throw new GraphQlMutationContractException(
|
||||
coordinate.value()
|
||||
+ " calls "
|
||||
+ useCaseInvocations
|
||||
+ " use cases; model the atomic operation as one use case instead");
|
||||
+ " use cases; an atomic mutation is exactly one use case in the Application layer");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+9
-6
@@ -5,7 +5,6 @@ import java.security.MessageDigest;
|
||||
import java.security.NoSuchAlgorithmException;
|
||||
import java.util.HexFormat;
|
||||
import java.util.Map;
|
||||
import java.util.TreeMap;
|
||||
|
||||
/**
|
||||
* A fingerprint of a mutation's normalised input.
|
||||
@@ -25,12 +24,16 @@ public record GraphQlMutationFingerprint(String value) {
|
||||
}
|
||||
}
|
||||
|
||||
/** Fingerprints a normalised input map. */
|
||||
/**
|
||||
* Fingerprints a decoded input map.
|
||||
*
|
||||
* <p>Canonicalised by {@link GraphQlCanonicalInput}, which type-tags and length-frames every
|
||||
* value and sorts nesting recursively. The previous form joined {@code key=value;} over top-level
|
||||
* keys only, so {@code {a: "b;c=d"}} and {@code {a: "b", c: "d"}} produced the same fingerprint —
|
||||
* and an idempotent retry of one returned the other's result.
|
||||
*/
|
||||
public static GraphQlMutationFingerprint of(Map<String, ?> normalizedInput) {
|
||||
StringBuilder canonical = new StringBuilder();
|
||||
new TreeMap<>(normalizedInput)
|
||||
.forEach((key, value) -> canonical.append(key).append('=').append(value).append(';'));
|
||||
return sha256(canonical.toString());
|
||||
return sha256(GraphQlCanonicalInput.of(normalizedInput));
|
||||
}
|
||||
|
||||
/** Fingerprints already-canonical text. */
|
||||
|
||||
+38
-6
@@ -3,22 +3,32 @@ package dev.caskeleton.adapter.inbound.graphql.mutation;
|
||||
/**
|
||||
* The full scope one idempotency key applies to (design §15).
|
||||
*
|
||||
* <p>Scoped by actor, mutation and normalised input together — not by key alone. A key scoped only
|
||||
* to itself would let one client's retry return another client's result, and would let the same key
|
||||
* stand for two different requests.
|
||||
* <p>Scoped by actor, tenant, mutation, contract version and normalised input together — not by key
|
||||
* alone. A key scoped only to itself would let one client's retry return another client's result,
|
||||
* and would let the same key stand for two different requests.
|
||||
*
|
||||
* <p>Tenant is part of the scope because actor identity does not imply it: the same service account
|
||||
* acting for two tenants would otherwise share one idempotency namespace, and a retry issued for
|
||||
* one tenant could be answered with the other tenant's stored result. Contract version is part of
|
||||
* it because a mutation whose input or semantics changed is a different operation — replaying the
|
||||
* old result against the new contract is the silent wrong answer versioning exists to prevent.
|
||||
*
|
||||
* <p>This is context handed to the Application's idempotency capability. The platform does not
|
||||
* implement replay, record storage or locking: those need transactional guarantees the transport
|
||||
* layer cannot give.
|
||||
*
|
||||
* @param actorFingerprint non-reversible actor identity
|
||||
* @param tenantFingerprint non-reversible tenant identity
|
||||
* @param coordinate the mutation the key belongs to
|
||||
* @param contractVersion the mutation contract version the key was issued under
|
||||
* @param key the client-supplied key
|
||||
* @param fingerprint fingerprint of the normalised input
|
||||
*/
|
||||
public record GraphQlMutationIdempotencyContext(
|
||||
String actorFingerprint,
|
||||
String tenantFingerprint,
|
||||
GraphQlMutationCoordinate coordinate,
|
||||
String contractVersion,
|
||||
GraphQlIdempotencyKey key,
|
||||
GraphQlMutationFingerprint fingerprint) {
|
||||
|
||||
@@ -26,6 +36,12 @@ public record GraphQlMutationIdempotencyContext(
|
||||
if (actorFingerprint == null || actorFingerprint.isBlank()) {
|
||||
throw new IllegalArgumentException("actor fingerprint is required");
|
||||
}
|
||||
if (tenantFingerprint == null || tenantFingerprint.isBlank()) {
|
||||
throw new IllegalArgumentException("tenant fingerprint is required");
|
||||
}
|
||||
if (contractVersion == null || contractVersion.isBlank()) {
|
||||
throw new IllegalArgumentException("mutation contract version is required");
|
||||
}
|
||||
if (coordinate == null || key == null || fingerprint == null) {
|
||||
throw new IllegalArgumentException(
|
||||
"idempotency scope requires coordinate, key and fingerprint");
|
||||
@@ -35,10 +51,13 @@ public record GraphQlMutationIdempotencyContext(
|
||||
/** Creates the scope. */
|
||||
public static GraphQlMutationIdempotencyContext of(
|
||||
String actorFingerprint,
|
||||
String tenantFingerprint,
|
||||
GraphQlMutationCoordinate coordinate,
|
||||
String contractVersion,
|
||||
GraphQlIdempotencyKey key,
|
||||
GraphQlMutationFingerprint fingerprint) {
|
||||
return new GraphQlMutationIdempotencyContext(actorFingerprint, coordinate, key, fingerprint);
|
||||
return new GraphQlMutationIdempotencyContext(
|
||||
actorFingerprint, tenantFingerprint, coordinate, contractVersion, key, fingerprint);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -55,9 +74,22 @@ public record GraphQlMutationIdempotencyContext(
|
||||
/**
|
||||
* The storage scope for the Application's idempotency record.
|
||||
*
|
||||
* <p>Uses the actor fingerprint rather than the actor, so the scope can be persisted and logged.
|
||||
* <p>Uses fingerprints rather than the raw actor and tenant, so the scope can be persisted and
|
||||
* logged. Length-framed for the same reason the canonical input form is: joining five
|
||||
* caller-influenced values with a separator lets one of them contain the separator and collide
|
||||
* with a different scope.
|
||||
*/
|
||||
public String scope() {
|
||||
return actorFingerprint + "|" + coordinate.value() + "|" + key.value();
|
||||
StringBuilder scope = new StringBuilder();
|
||||
frame(scope, actorFingerprint);
|
||||
frame(scope, tenantFingerprint);
|
||||
frame(scope, coordinate.value());
|
||||
frame(scope, contractVersion);
|
||||
frame(scope, key.value());
|
||||
return scope.toString();
|
||||
}
|
||||
|
||||
private static void frame(StringBuilder out, String value) {
|
||||
out.append(value.length()).append(':').append(value).append('|');
|
||||
}
|
||||
}
|
||||
|
||||
+3
@@ -31,6 +31,7 @@ public final class GraphQlMutationIdempotencyInterceptor {
|
||||
public static Optional<GraphQlMutationIdempotencyContext> from(
|
||||
GraphQlRequestContext context,
|
||||
GraphQlMutationCoordinate coordinate,
|
||||
String contractVersion,
|
||||
Map<String, Object> extensions,
|
||||
Map<String, ?> normalizedInput) {
|
||||
|
||||
@@ -45,7 +46,9 @@ public final class GraphQlMutationIdempotencyInterceptor {
|
||||
return Optional.of(
|
||||
GraphQlMutationIdempotencyContext.of(
|
||||
context.actor().fingerprint(),
|
||||
context.tenant().fingerprint(),
|
||||
coordinate,
|
||||
contractVersion,
|
||||
new GraphQlIdempotencyKey(key),
|
||||
GraphQlMutationFingerprint.of(normalizedInput)));
|
||||
}
|
||||
|
||||
+74
@@ -0,0 +1,74 @@
|
||||
package dev.caskeleton.adapter.inbound.graphql.observation;
|
||||
|
||||
import dev.caskeleton.adapter.inbound.graphql.api.GraphQlOperationName;
|
||||
import java.util.Set;
|
||||
|
||||
/**
|
||||
* Decides which operation names are allowed to become metric labels.
|
||||
*
|
||||
* <p>{@link GraphQlOperationName} bounds an operation name's <em>syntax</em> and length, which is a
|
||||
* different property from bounding how many distinct ones exist. A client is free to send {@code
|
||||
* Query0000001}, {@code Query0000002} and so on indefinitely: every one is valid, and every one
|
||||
* used to become its own time series. That is a metrics backend brought down by a well-formed
|
||||
* client, and the tag that did it looked bounded because a regular expression was checking it.
|
||||
*
|
||||
* <p>So the label is drawn from a set the deployment declares, not from the request. Anything
|
||||
* outside it collapses to {@link #UNREGISTERED} — the request is still counted, still timed and
|
||||
* still attributed to its type, profile and outcome; only the one unbounded coordinate is dropped.
|
||||
*
|
||||
* <p>The default is an empty registry, which collapses every named operation. Defaulting the other
|
||||
* way would mean every deployment that never thought about this ships the unbounded behaviour, and
|
||||
* a cardinality bound that is opt-in is a cardinality bound nobody has.
|
||||
*/
|
||||
public final class GraphQlOperationNameCardinality {
|
||||
|
||||
/** The label used for any operation the deployment did not register. */
|
||||
public static final String UNREGISTERED = "other";
|
||||
|
||||
private final Set<String> registered;
|
||||
|
||||
/**
|
||||
* Creates the policy.
|
||||
*
|
||||
* @param registered operation names the deployment knows, typically the persisted registry's
|
||||
*/
|
||||
public GraphQlOperationNameCardinality(Set<String> registered) {
|
||||
if (registered == null) {
|
||||
throw new IllegalArgumentException("registered operation names are required");
|
||||
}
|
||||
this.registered = Set.copyOf(registered);
|
||||
}
|
||||
|
||||
/** A policy that collapses every named operation, for a deployment with no registry. */
|
||||
public static GraphQlOperationNameCardinality collapsingAll() {
|
||||
return new GraphQlOperationNameCardinality(Set.of());
|
||||
}
|
||||
|
||||
/**
|
||||
* The bounded label for one operation.
|
||||
*
|
||||
* @param operationName the validated name, or {@code null} for a permitted anonymous operation
|
||||
* @return the registered name, the anonymous value, or {@link #UNREGISTERED}
|
||||
*/
|
||||
public String labelFor(GraphQlOperationName operationName) {
|
||||
if (operationName == null) {
|
||||
return GraphQlOperationName.ANONYMOUS_OBSERVATION_VALUE;
|
||||
}
|
||||
return registered.contains(operationName.value()) ? operationName.value() : UNREGISTERED;
|
||||
}
|
||||
|
||||
/**
|
||||
* The number of distinct labels this policy can ever produce.
|
||||
*
|
||||
* <p>Stated as a number because "bounded" is a claim an operator should be able to check against
|
||||
* their backend's series budget before enabling the tag.
|
||||
*/
|
||||
public int distinctLabels() {
|
||||
return registered.size() + 2;
|
||||
}
|
||||
|
||||
/** The registered operation names. */
|
||||
public Set<String> registered() {
|
||||
return registered;
|
||||
}
|
||||
}
|
||||
+32
-11
@@ -10,32 +10,57 @@ import java.util.Map;
|
||||
/**
|
||||
* Tags for the {@code graphql.request} observation (design §22).
|
||||
*
|
||||
* <p>Everything here is bounded by construction: a validated operation name, an enum, a bounded
|
||||
* client profile and pre-computed buckets. Depth and complexity are bucketed rather than reported
|
||||
* exactly, because the exact numbers are effectively continuous and would create a new time series
|
||||
* per request.
|
||||
* <p>Everything here is bounded by construction: an enum, a bounded client profile, pre-computed
|
||||
* buckets, and an operation name drawn from the deployment's registry rather than from the request.
|
||||
* Depth and complexity are bucketed rather than reported exactly, because the exact numbers are
|
||||
* effectively continuous and would create a new time series per request.
|
||||
*
|
||||
* <p>The operation name was the exception, and it was the one that mattered: it was tagged raw, on
|
||||
* the strength of a regular expression that bounds its syntax and says nothing about how many
|
||||
* distinct names a client may invent. {@link GraphQlOperationNameCardinality} closes it.
|
||||
*/
|
||||
public final class GraphQlRequestObservationConvention {
|
||||
|
||||
private final GraphQlSensitiveAttributeFilter filter;
|
||||
private final GraphQlOperationNameCardinality operationNames;
|
||||
|
||||
/**
|
||||
* Creates the convention, collapsing every operation name.
|
||||
*
|
||||
* @param filter attribute allowlist and sensitivity filter
|
||||
*/
|
||||
public GraphQlRequestObservationConvention(GraphQlSensitiveAttributeFilter filter) {
|
||||
this(filter, GraphQlOperationNameCardinality.collapsingAll());
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates the convention.
|
||||
*
|
||||
* @param filter attribute allowlist and sensitivity filter
|
||||
* @param operationNames which operation names may become labels
|
||||
*/
|
||||
public GraphQlRequestObservationConvention(GraphQlSensitiveAttributeFilter filter) {
|
||||
public GraphQlRequestObservationConvention(
|
||||
GraphQlSensitiveAttributeFilter filter, GraphQlOperationNameCardinality operationNames) {
|
||||
if (filter == null) {
|
||||
throw new IllegalArgumentException("attribute filter is required");
|
||||
}
|
||||
if (operationNames == null) {
|
||||
throw new IllegalArgumentException("operation name cardinality policy is required");
|
||||
}
|
||||
this.filter = filter;
|
||||
this.operationNames = operationNames;
|
||||
}
|
||||
|
||||
/** A convention using the standard filter. */
|
||||
/** A convention using the standard filter and no registered operation names. */
|
||||
public static GraphQlRequestObservationConvention standard() {
|
||||
return new GraphQlRequestObservationConvention(GraphQlSensitiveAttributeFilter.standard());
|
||||
}
|
||||
|
||||
/** How many distinct operation-name labels this convention can produce. */
|
||||
public int distinctOperationNameLabels() {
|
||||
return operationNames.distinctLabels();
|
||||
}
|
||||
|
||||
/** The observation name. */
|
||||
public String name() {
|
||||
return GraphQlObservationNames.REQUEST;
|
||||
@@ -65,11 +90,7 @@ public final class GraphQlRequestObservationConvention {
|
||||
int depth) {
|
||||
|
||||
Map<String, String> tags = new LinkedHashMap<>();
|
||||
tags.put(
|
||||
"graphql.operation.name",
|
||||
operationName == null
|
||||
? GraphQlOperationName.ANONYMOUS_OBSERVATION_VALUE
|
||||
: operationName.value());
|
||||
tags.put("graphql.operation.name", operationNames.labelFor(operationName));
|
||||
tags.put("graphql.operation.type", operationType.name());
|
||||
tags.put("graphql.client.profile", clientProfile.value());
|
||||
tags.put("graphql.persisted", Boolean.toString(persisted));
|
||||
|
||||
+15
-15
@@ -17,6 +17,7 @@ public final class GraphQlConnectionAssembler {
|
||||
private final GraphQlCursorCodec codec;
|
||||
private final String queryProfile;
|
||||
private final String filterFingerprint;
|
||||
private final String tenantScope;
|
||||
|
||||
/**
|
||||
* Creates the assembler.
|
||||
@@ -24,23 +25,14 @@ public final class GraphQlConnectionAssembler {
|
||||
* @param codec signs the cursors it issues
|
||||
* @param queryProfile the query cursors will be bound to
|
||||
* @param filterFingerprint the filter cursors will be bound to
|
||||
* @param tenantScope opaque fingerprint of the caller scope cursors will be bound to
|
||||
*/
|
||||
public GraphQlConnectionAssembler(
|
||||
GraphQlCursorCodec codec, String queryProfile, String filterFingerprint) {
|
||||
GraphQlCursorCodec codec, String queryProfile, String filterFingerprint, String tenantScope) {
|
||||
this.codec = Objects.requireNonNull(codec);
|
||||
this.queryProfile = Objects.requireNonNull(queryProfile);
|
||||
this.filterFingerprint = Objects.requireNonNull(filterFingerprint);
|
||||
}
|
||||
|
||||
/** An assembler with a fixed test key, for contract tests. */
|
||||
public static GraphQlConnectionAssembler forTests() {
|
||||
return new GraphQlConnectionAssembler(
|
||||
HmacGraphQlCursorCodec.testCodec(
|
||||
GraphQlCursorPayload.DEFAULT_KEY_ID,
|
||||
"test-cursor-secret-test-cursor-secret"
|
||||
.getBytes(java.nio.charset.StandardCharsets.UTF_8)),
|
||||
"test-profile",
|
||||
"test-filter");
|
||||
this.tenantScope = Objects.requireNonNull(tenantScope, "cursor tenant scope is required");
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -93,16 +85,24 @@ public final class GraphQlConnectionAssembler {
|
||||
private <T> String cursorFor(
|
||||
T node, Function<T, Map<String, String>> keysetOf, String direction) {
|
||||
return codec.encode(
|
||||
GraphQlCursorPayload.of(queryProfile, direction, keysetOf.apply(node), filterFingerprint));
|
||||
GraphQlCursorPayload.issue(
|
||||
queryProfile, direction, keysetOf.apply(node), filterFingerprint, tenantScope));
|
||||
}
|
||||
|
||||
/**
|
||||
* Decodes the cursor a request supplied, checking it belongs to this query and filter.
|
||||
* Decodes the cursor a request supplied, checking it belongs to this query, filter, direction and
|
||||
* caller scope.
|
||||
*
|
||||
* <p>The direction comes from the request rather than from the cursor: a forward cursor replayed
|
||||
* on a backward request used to be accepted, and the page it resumed from was the wrong side of
|
||||
* the boundary.
|
||||
*
|
||||
* @throws GraphQlCursorException when it does not
|
||||
*/
|
||||
public java.util.Optional<GraphQlCursorPayload> decodeRequestCursor(
|
||||
GraphQlConnectionRequest request) {
|
||||
return request.cursor().map(cursor -> codec.decode(cursor, queryProfile, filterFingerprint));
|
||||
GraphQlCursorScope expected =
|
||||
new GraphQlCursorScope(queryProfile, filterFingerprint, request.direction(), tenantScope);
|
||||
return request.cursor().map(cursor -> codec.decode(cursor, expected));
|
||||
}
|
||||
}
|
||||
|
||||
+11
-9
@@ -3,23 +3,25 @@ package dev.caskeleton.adapter.inbound.graphql.pagination;
|
||||
/**
|
||||
* Encodes and decodes cursors.
|
||||
*
|
||||
* <p>Decoding takes the expected query profile and filter fingerprint, because verifying a
|
||||
* signature only proves the server issued the cursor — not that it issued it for <em>this</em>
|
||||
* query. Both checks together are what make a cursor safe to accept.
|
||||
* <p>Decoding takes the whole expected scope, because verifying a signature only proves the server
|
||||
* issued the cursor — not that it issued it for this query, this filter, this direction and this
|
||||
* tenant. The signature and the scope together are what make a cursor safe to accept.
|
||||
*/
|
||||
public interface GraphQlCursorCodec {
|
||||
|
||||
/** Encodes and signs a payload into an opaque cursor. */
|
||||
/**
|
||||
* Encodes and signs a payload into an opaque cursor.
|
||||
*
|
||||
* <p>The signing key is the codec's active one; a payload cannot choose it.
|
||||
*/
|
||||
String encode(GraphQlCursorPayload payload);
|
||||
|
||||
/**
|
||||
* Verifies and decodes a cursor.
|
||||
*
|
||||
* @param cursor the opaque cursor
|
||||
* @param expectedQueryProfile the query the cursor is being used for
|
||||
* @param expectedFilterFingerprint the filter the cursor is being used under
|
||||
* @throws GraphQlCursorException on any mismatch, bad signature or unknown version or key
|
||||
* @param expected what this request requires the cursor to have been issued for
|
||||
* @throws GraphQlCursorException on any mismatch, bad signature, unknown version or unknown key
|
||||
*/
|
||||
GraphQlCursorPayload decode(
|
||||
String cursor, String expectedQueryProfile, String expectedFilterFingerprint);
|
||||
GraphQlCursorPayload decode(String cursor, GraphQlCursorScope expected);
|
||||
}
|
||||
|
||||
+67
@@ -0,0 +1,67 @@
|
||||
package dev.caskeleton.adapter.inbound.graphql.pagination;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* Length-prefixed framing for the signed cursor envelope.
|
||||
*
|
||||
* <p>The v1 envelope joined fields with {@code |} and pairs with {@code ;} and {@code =}, escaping
|
||||
* those characters inside keyset values. It did not survive a round trip: the decoder split on the
|
||||
* delimiters <em>before</em> unescaping, so an escaped separator in a sort value tore the field
|
||||
* apart, and three fields — query profile, filter fingerprint, key id — were never escaped at all.
|
||||
* A sort value containing a pipe was enough to make a legitimately issued cursor unreadable.
|
||||
*
|
||||
* <p>Framing removes the problem rather than escaping around it. Each field is written as its
|
||||
* length, a colon, and the value, so the reader knows exactly how far to read and no character is
|
||||
* special. Lengths count {@code char} units, which is what {@code substring} consumes, so a
|
||||
* surrogate pair frames and reads back identically.
|
||||
*/
|
||||
public final class GraphQlCursorFraming {
|
||||
|
||||
/** Ceiling on fields in one envelope, so a hostile token cannot allocate without bound. */
|
||||
public static final int MAXIMUM_FIELDS = 256;
|
||||
|
||||
private GraphQlCursorFraming() {}
|
||||
|
||||
/** Appends one length-prefixed field. */
|
||||
public static void write(StringBuilder out, String value) {
|
||||
String safe = value == null ? "" : value;
|
||||
out.append(safe.length()).append(':').append(safe);
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads every length-prefixed field, requiring the body to be consumed exactly.
|
||||
*
|
||||
* @param framed the framed body, without the version prefix
|
||||
* @throws GraphQlCursorException when the framing is malformed, truncated or over-long
|
||||
*/
|
||||
public static List<String> readAll(String framed) {
|
||||
List<String> fields = new ArrayList<>();
|
||||
int cursor = 0;
|
||||
while (cursor < framed.length()) {
|
||||
if (fields.size() == MAXIMUM_FIELDS) {
|
||||
throw new GraphQlCursorException("cursor envelope has too many fields");
|
||||
}
|
||||
int separator = framed.indexOf(':', cursor);
|
||||
if (separator < 0) {
|
||||
throw new GraphQlCursorException("malformed cursor");
|
||||
}
|
||||
int length;
|
||||
try {
|
||||
length = Integer.parseInt(framed.substring(cursor, separator));
|
||||
} catch (NumberFormatException malformed) {
|
||||
throw new GraphQlCursorException("malformed cursor");
|
||||
}
|
||||
int valueStart = separator + 1;
|
||||
// A declared length longer than what remains is the truncation case; reading it would throw
|
||||
// StringIndexOutOfBounds instead of rejecting the token.
|
||||
if (length < 0 || valueStart + length > framed.length()) {
|
||||
throw new GraphQlCursorException("malformed cursor");
|
||||
}
|
||||
fields.add(framed.substring(valueStart, valueStart + length));
|
||||
cursor = valueStart + length;
|
||||
}
|
||||
return List.copyOf(fields);
|
||||
}
|
||||
}
|
||||
+7
-2
@@ -55,9 +55,14 @@ public final class GraphQlCursorKeyRing {
|
||||
return activeKeyId;
|
||||
}
|
||||
|
||||
/** Key identities that can still verify a cursor. */
|
||||
/**
|
||||
* Key identities that can still verify a cursor.
|
||||
*
|
||||
* <p>A copy. {@code keySet()} is a live view of the backing map, so handing it out let a caller
|
||||
* remove a key identity from the ring — retiring a signing key by accident, through a getter.
|
||||
*/
|
||||
public Set<String> keyIds() {
|
||||
return keys.keySet();
|
||||
return Set.copyOf(keys.keySet());
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
+55
-30
@@ -12,12 +12,19 @@ import java.util.Map;
|
||||
*
|
||||
* <p>The payload deliberately holds sort values and identifiers only: no credential, no raw tenant.
|
||||
*
|
||||
* <p>{@code tenantScope} binds the cursor to the caller it was issued for. Without it a cursor is a
|
||||
* position in a result set and nothing more, so one tenant's cursor replayed by another resumes a
|
||||
* scan the second tenant was never entitled to start. It is an opaque fingerprint supplied by the
|
||||
* caller, never a raw tenant identifier: this module must stay free of the context types, and a
|
||||
* cursor is a value a client holds and can read.
|
||||
*
|
||||
* @param version envelope version
|
||||
* @param queryProfile the query this cursor belongs to
|
||||
* @param direction {@code FORWARD} or {@code BACKWARD}
|
||||
* @param keyset sort position
|
||||
* @param filterFingerprint fingerprint of the filter the cursor was issued under
|
||||
* @param keyId signing key identity, so keys can rotate
|
||||
* @param tenantScope opaque fingerprint of the tenant and actor scope the cursor was issued for
|
||||
* @param keyId signing key identity, stamped by the codec so keys can rotate
|
||||
*/
|
||||
public record GraphQlCursorPayload(
|
||||
int version,
|
||||
@@ -25,6 +32,7 @@ public record GraphQlCursorPayload(
|
||||
String direction,
|
||||
Map<String, String> keyset,
|
||||
String filterFingerprint,
|
||||
String tenantScope,
|
||||
String keyId) {
|
||||
|
||||
/** Forward pagination. */
|
||||
@@ -47,52 +55,69 @@ public record GraphQlCursorPayload(
|
||||
if (filterFingerprint == null || filterFingerprint.isBlank()) {
|
||||
throw new GraphQlCursorException("cursor filter fingerprint is required");
|
||||
}
|
||||
if (tenantScope == null || tenantScope.isBlank()) {
|
||||
throw new GraphQlCursorException("cursor tenant scope is required");
|
||||
}
|
||||
if (keyId == null || keyId.isBlank()) {
|
||||
throw new GraphQlCursorException("cursor key id is required");
|
||||
}
|
||||
keyset = GraphQlCursorKeyset.of(keyset).values();
|
||||
}
|
||||
|
||||
/** Creates a current-version payload signed by the default key. */
|
||||
public static GraphQlCursorPayload of(
|
||||
String queryProfile, String direction, Map<String, String> keyset, String filterFingerprint) {
|
||||
return new GraphQlCursorPayload(
|
||||
GraphQlCursorVersion.CURRENT,
|
||||
queryProfile,
|
||||
direction,
|
||||
Map.copyOf(keyset),
|
||||
filterFingerprint,
|
||||
DEFAULT_KEY_ID);
|
||||
}
|
||||
|
||||
/** Creates a current-version payload signed by a named key. */
|
||||
public static GraphQlCursorPayload of(
|
||||
/**
|
||||
* Creates a payload to be issued.
|
||||
*
|
||||
* <p>No key identity: the codec stamps the active one. A caller that could name the signing key
|
||||
* could pin every cursor to a retired key and quietly opt out of rotation.
|
||||
*/
|
||||
public static GraphQlCursorPayload issue(
|
||||
String queryProfile,
|
||||
String direction,
|
||||
Map<String, String> keyset,
|
||||
String filterFingerprint,
|
||||
String keyId) {
|
||||
String tenantScope) {
|
||||
return new GraphQlCursorPayload(
|
||||
GraphQlCursorVersion.CURRENT,
|
||||
queryProfile,
|
||||
direction,
|
||||
Map.copyOf(keyset),
|
||||
filterFingerprint,
|
||||
keyId);
|
||||
tenantScope,
|
||||
PENDING_KEY_ID);
|
||||
}
|
||||
|
||||
/** Deterministic encoding of everything the signature covers. */
|
||||
public String canonicalForm() {
|
||||
return version
|
||||
+ "|"
|
||||
+ queryProfile
|
||||
+ "|"
|
||||
+ direction
|
||||
+ "|"
|
||||
+ new GraphQlCursorKeyset(keyset).canonicalForm()
|
||||
+ "|"
|
||||
+ filterFingerprint
|
||||
+ "|"
|
||||
+ keyId;
|
||||
/** Placeholder key identity on a payload the codec has not signed yet. */
|
||||
public static final String PENDING_KEY_ID = "pending";
|
||||
|
||||
/** Returns a copy stamped with the key that signed it. */
|
||||
public GraphQlCursorPayload signedWith(String activeKeyId) {
|
||||
return new GraphQlCursorPayload(
|
||||
version, queryProfile, direction, keyset, filterFingerprint, tenantScope, activeKeyId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Deterministic encoding of everything the signature covers.
|
||||
*
|
||||
* <p>Length-prefixed rather than delimiter-joined, so no field needs escaping and none can be
|
||||
* confused with the framing. Keyset entries are sorted by {@link GraphQlCursorKeyset}, so the
|
||||
* same position always signs to the same bytes.
|
||||
*/
|
||||
public String canonicalForm() {
|
||||
StringBuilder canonical = new StringBuilder();
|
||||
canonical.append(version).append('|');
|
||||
GraphQlCursorFraming.write(canonical, queryProfile);
|
||||
GraphQlCursorFraming.write(canonical, direction);
|
||||
GraphQlCursorFraming.write(canonical, filterFingerprint);
|
||||
GraphQlCursorFraming.write(canonical, tenantScope);
|
||||
GraphQlCursorFraming.write(canonical, keyId);
|
||||
keyset.forEach(
|
||||
(key, value) -> {
|
||||
GraphQlCursorFraming.write(canonical, key);
|
||||
GraphQlCursorFraming.write(canonical, value);
|
||||
});
|
||||
return canonical.toString();
|
||||
}
|
||||
|
||||
/** How many framed fields precede the keyset pairs. */
|
||||
static final int FIXED_FIELDS = 5;
|
||||
}
|
||||
|
||||
+57
@@ -0,0 +1,57 @@
|
||||
package dev.caskeleton.adapter.inbound.graphql.pagination;
|
||||
|
||||
import java.util.Objects;
|
||||
|
||||
/**
|
||||
* Everything a presented cursor has to match before it may be used.
|
||||
*
|
||||
* <p>Verifying the signature proves the server issued the cursor. It does not prove the server
|
||||
* issued it for <em>this</em> request, and each field here is a way that gap was exploitable: a
|
||||
* forward cursor replayed as a backward one walks the page boundary in the wrong direction, and a
|
||||
* cursor from one tenant replayed by another resumes a scan the second tenant could never have
|
||||
* started. Passing the whole expectation as one value is what stops a new caller from checking
|
||||
* three of the four and looking correct.
|
||||
*
|
||||
* @param queryProfile the query the cursor is being used for
|
||||
* @param filterFingerprint the filter the cursor is being used under
|
||||
* @param direction the direction this request is paginating in
|
||||
* @param tenantScope opaque fingerprint of the caller's tenant and actor scope
|
||||
*/
|
||||
public record GraphQlCursorScope(
|
||||
String queryProfile, String filterFingerprint, String direction, String tenantScope) {
|
||||
|
||||
/**
|
||||
* Tenant scope carried by a v1 cursor, which bound none.
|
||||
*
|
||||
* <p>A distinct value rather than {@code null}, so a v1 cursor presented against a real scope
|
||||
* fails the comparison like any other mismatch instead of skipping the check.
|
||||
*/
|
||||
public static final String LEGACY_UNSCOPED = "legacy-unscoped";
|
||||
|
||||
public GraphQlCursorScope {
|
||||
Objects.requireNonNull(queryProfile, "query profile is required");
|
||||
Objects.requireNonNull(filterFingerprint, "filter fingerprint is required");
|
||||
Objects.requireNonNull(direction, "direction is required");
|
||||
Objects.requireNonNull(tenantScope, "tenant scope is required");
|
||||
}
|
||||
|
||||
/**
|
||||
* Verifies a decoded payload against this scope.
|
||||
*
|
||||
* @throws GraphQlCursorException naming the first field that does not match
|
||||
*/
|
||||
public void verify(GraphQlCursorPayload payload) {
|
||||
if (!payload.queryProfile().equals(queryProfile)) {
|
||||
throw new GraphQlCursorException("cursor was issued for a different query profile");
|
||||
}
|
||||
if (!payload.filterFingerprint().equals(filterFingerprint)) {
|
||||
throw new GraphQlCursorException("cursor was issued for a different filter");
|
||||
}
|
||||
if (!payload.direction().equals(direction)) {
|
||||
throw new GraphQlCursorException("cursor was issued for a different pagination direction");
|
||||
}
|
||||
if (!payload.tenantScope().equals(tenantScope)) {
|
||||
throw new GraphQlCursorException("cursor was issued for a different tenant scope");
|
||||
}
|
||||
}
|
||||
}
|
||||
+16
-3
@@ -11,11 +11,24 @@ import java.util.Set;
|
||||
*/
|
||||
public final class GraphQlCursorVersion {
|
||||
|
||||
/** Current envelope version. */
|
||||
public static final int CURRENT = 1;
|
||||
/**
|
||||
* Current envelope version: length-prefixed framing, tenant scope, codec-stamped key.
|
||||
*
|
||||
* <p>Only this version is issued.
|
||||
*/
|
||||
public static final int CURRENT = 2;
|
||||
|
||||
/**
|
||||
* The delimiter-escaped envelope, still decodable during migration.
|
||||
*
|
||||
* <p>Kept readable because cursors already in clients' hands outlive a deploy. It is never
|
||||
* issued: its framing could not round-trip a sort value containing a delimiter, and it binds no
|
||||
* tenant scope.
|
||||
*/
|
||||
public static final int LEGACY_DELIMITED = 1;
|
||||
|
||||
/** Versions this deployment will still decode. */
|
||||
public static final Set<Integer> SUPPORTED = Set.of(CURRENT);
|
||||
public static final Set<Integer> SUPPORTED = Set.of(LEGACY_DELIMITED, CURRENT);
|
||||
|
||||
private GraphQlCursorVersion() {}
|
||||
|
||||
|
||||
+71
-26
@@ -4,6 +4,7 @@ import java.nio.charset.StandardCharsets;
|
||||
import java.security.MessageDigest;
|
||||
import java.util.Base64;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import javax.crypto.Mac;
|
||||
@@ -18,9 +19,15 @@ import javax.crypto.spec.SecretKeySpec;
|
||||
*
|
||||
* <p>Signatures are compared with {@link MessageDigest#isEqual}, whose timing does not depend on
|
||||
* where the first differing byte is — a normal string comparison would leak that position.
|
||||
*
|
||||
* <p>The codec chooses the signing key. Letting the payload name it meant a caller could pin every
|
||||
* new cursor to a key that was being retired, which is rotation that never completes.
|
||||
*/
|
||||
public final class HmacGraphQlCursorCodec implements GraphQlCursorCodec {
|
||||
|
||||
/** Largest cursor token accepted, before any decoding work is done. */
|
||||
public static final int MAXIMUM_CURSOR_CHARS = 4096;
|
||||
|
||||
private static final String ALGORITHM = "HmacSHA256";
|
||||
private static final Base64.Encoder ENCODER = Base64.getUrlEncoder().withoutPadding();
|
||||
private static final Base64.Decoder DECODER = Base64.getUrlDecoder();
|
||||
@@ -36,27 +43,29 @@ public final class HmacGraphQlCursorCodec implements GraphQlCursorCodec {
|
||||
this.keyRing = Objects.requireNonNull(keyRing);
|
||||
}
|
||||
|
||||
/** A single-key codec for tests and single-key deployments. */
|
||||
public static HmacGraphQlCursorCodec testCodec(String keyId, byte[] secret) {
|
||||
return new HmacGraphQlCursorCodec(GraphQlCursorKeyRing.single(keyId, secret));
|
||||
}
|
||||
|
||||
@Override
|
||||
public String encode(GraphQlCursorPayload payload) {
|
||||
String canonical = payload.canonicalForm();
|
||||
byte[] signature = sign(canonical, keyRing.secret(payload.keyId()));
|
||||
if (payload.version() != GraphQlCursorVersion.CURRENT) {
|
||||
throw new GraphQlCursorException("only the current cursor version is issued");
|
||||
}
|
||||
GraphQlCursorPayload signed = payload.signedWith(keyRing.activeKeyId());
|
||||
String canonical = signed.canonicalForm();
|
||||
byte[] signature = sign(canonical, keyRing.secret(signed.keyId()));
|
||||
return ENCODER.encodeToString(canonical.getBytes(StandardCharsets.UTF_8))
|
||||
+ "."
|
||||
+ ENCODER.encodeToString(signature);
|
||||
}
|
||||
|
||||
@Override
|
||||
public GraphQlCursorPayload decode(
|
||||
String cursor, String expectedQueryProfile, String expectedFilterFingerprint) {
|
||||
|
||||
public GraphQlCursorPayload decode(String cursor, GraphQlCursorScope expected) {
|
||||
Objects.requireNonNull(expected, "expected cursor scope is required");
|
||||
if (cursor == null || cursor.isBlank()) {
|
||||
throw new GraphQlCursorException("cursor is required");
|
||||
}
|
||||
// Bounded before any decode: an oversized token is refused without allocating a copy of it.
|
||||
if (cursor.length() > MAXIMUM_CURSOR_CHARS) {
|
||||
throw new GraphQlCursorException("cursor is too large");
|
||||
}
|
||||
int separator = cursor.indexOf('.');
|
||||
if (separator < 0) {
|
||||
throw new GraphQlCursorException("malformed cursor");
|
||||
@@ -68,7 +77,7 @@ public final class HmacGraphQlCursorCodec implements GraphQlCursorCodec {
|
||||
canonical =
|
||||
new String(DECODER.decode(cursor.substring(0, separator)), StandardCharsets.UTF_8);
|
||||
presentedSignature = DECODER.decode(cursor.substring(separator + 1));
|
||||
} catch (IllegalArgumentException ex) {
|
||||
} catch (IllegalArgumentException malformed) {
|
||||
throw new GraphQlCursorException("malformed cursor");
|
||||
}
|
||||
|
||||
@@ -77,34 +86,63 @@ public final class HmacGraphQlCursorCodec implements GraphQlCursorCodec {
|
||||
if (!MessageDigest.isEqual(expectedSignature, presentedSignature)) {
|
||||
throw new GraphQlCursorException("cursor signature mismatch");
|
||||
}
|
||||
if (!payload.queryProfile().equals(expectedQueryProfile)) {
|
||||
throw new GraphQlCursorException("cursor was issued for a different query profile");
|
||||
}
|
||||
if (!payload.filterFingerprint().equals(expectedFilterFingerprint)) {
|
||||
throw new GraphQlCursorException("cursor was issued for a different filter");
|
||||
}
|
||||
expected.verify(payload);
|
||||
return payload;
|
||||
}
|
||||
|
||||
private static GraphQlCursorPayload parse(String canonical) {
|
||||
String[] parts = canonical.split("\\|", -1);
|
||||
if (parts.length != 6) {
|
||||
int versionEnd = canonical.indexOf('|');
|
||||
if (versionEnd < 0) {
|
||||
throw new GraphQlCursorException("malformed cursor");
|
||||
}
|
||||
int version;
|
||||
try {
|
||||
version = Integer.parseInt(parts[0]);
|
||||
} catch (NumberFormatException ex) {
|
||||
version = Integer.parseInt(canonical.substring(0, versionEnd));
|
||||
} catch (NumberFormatException malformed) {
|
||||
throw new GraphQlCursorException("malformed cursor");
|
||||
}
|
||||
GraphQlCursorVersion.require(version);
|
||||
return new GraphQlCursorPayload(
|
||||
version, parts[1], parts[2], parseKeyset(parts[3]), parts[4], parts[5]);
|
||||
String body = canonical.substring(versionEnd + 1);
|
||||
return version == GraphQlCursorVersion.CURRENT ? parseFramed(body) : parseLegacy(body);
|
||||
}
|
||||
|
||||
private static Map<String, String> parseKeyset(String encoded) {
|
||||
private static GraphQlCursorPayload parseFramed(String body) {
|
||||
List<String> fields = GraphQlCursorFraming.readAll(body);
|
||||
int pairFields = fields.size() - GraphQlCursorPayload.FIXED_FIELDS;
|
||||
if (pairFields < 2 || pairFields % 2 != 0) {
|
||||
throw new GraphQlCursorException("malformed cursor");
|
||||
}
|
||||
Map<String, String> keyset = new LinkedHashMap<>();
|
||||
for (String entry : encoded.split(";", -1)) {
|
||||
for (int index = GraphQlCursorPayload.FIXED_FIELDS; index < fields.size(); index += 2) {
|
||||
keyset.put(fields.get(index), fields.get(index + 1));
|
||||
}
|
||||
return new GraphQlCursorPayload(
|
||||
GraphQlCursorVersion.CURRENT,
|
||||
fields.get(0),
|
||||
fields.get(1),
|
||||
keyset,
|
||||
fields.get(2),
|
||||
fields.get(3),
|
||||
fields.get(4));
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads the delimiter-escaped v1 envelope.
|
||||
*
|
||||
* <p>Unescaping happens per field after the split, which is the ordering v1's own decoder got
|
||||
* wrong. A v1 cursor whose sort value contained a delimiter was never readable, so there is no
|
||||
* correct behaviour to preserve for that case — it is rejected here rather than mis-parsed.
|
||||
*
|
||||
* <p>v1 bound no tenant scope, so the decoded payload carries the sentinel below and any caller
|
||||
* expecting a real scope rejects it.
|
||||
*/
|
||||
private static GraphQlCursorPayload parseLegacy(String body) {
|
||||
String[] parts = body.split("\\|", -1);
|
||||
if (parts.length != 5) {
|
||||
throw new GraphQlCursorException("malformed cursor");
|
||||
}
|
||||
Map<String, String> keyset = new LinkedHashMap<>();
|
||||
for (String entry : parts[2].split(";", -1)) {
|
||||
if (entry.isBlank()) {
|
||||
continue;
|
||||
}
|
||||
@@ -117,7 +155,14 @@ public final class HmacGraphQlCursorCodec implements GraphQlCursorCodec {
|
||||
if (keyset.isEmpty()) {
|
||||
throw new GraphQlCursorException("malformed cursor");
|
||||
}
|
||||
return keyset;
|
||||
return new GraphQlCursorPayload(
|
||||
GraphQlCursorVersion.LEGACY_DELIMITED,
|
||||
parts[0],
|
||||
parts[1],
|
||||
keyset,
|
||||
parts[3],
|
||||
GraphQlCursorScope.LEGACY_UNSCOPED,
|
||||
parts[4]);
|
||||
}
|
||||
|
||||
private static String unescape(String value) {
|
||||
|
||||
+34
@@ -69,6 +69,40 @@ public record GraphQlClientPolicy(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The policy an adopter gets before tuning anything, driven by the platform's own settings.
|
||||
*
|
||||
* <p>Page size, complexity budget and introspection come from configuration because those are the
|
||||
* three an operator actually sets; the remaining ceilings are starting points meant to be
|
||||
* calibrated against measured latency and statement counts, as the design says. They are
|
||||
* deliberately generous enough that a correct client is never refused and tight enough that a
|
||||
* hostile document is, which is the only property a default can honestly claim.
|
||||
*
|
||||
* @param maximumPageSize largest page size a client may request
|
||||
* @param maximumComplexity largest accepted pre-execution complexity score
|
||||
* @param introspectionAllowed whether this deployment answers introspection
|
||||
*/
|
||||
public static GraphQlClientPolicy defaults(
|
||||
int maximumPageSize, long maximumComplexity, boolean introspectionAllowed) {
|
||||
return new GraphQlClientPolicy(
|
||||
16 * 1024,
|
||||
64 * 1024,
|
||||
12,
|
||||
500,
|
||||
50,
|
||||
50,
|
||||
1_000,
|
||||
Math.min(20, Math.max(1, maximumPageSize)),
|
||||
Math.max(1, maximumPageSize),
|
||||
Math.max(1, maximumComplexity),
|
||||
10_000,
|
||||
5L * 1024 * 1024,
|
||||
Duration.ofSeconds(10),
|
||||
introspectionAllowed,
|
||||
false,
|
||||
false);
|
||||
}
|
||||
|
||||
/**
|
||||
* The effective page size for a connection request.
|
||||
*
|
||||
|
||||
+6
-2
@@ -38,8 +38,7 @@ public final class GraphQlStableCapabilityManifest {
|
||||
"SSE_SUBSCRIPTION",
|
||||
"FEDERATION_SUBGRAPH",
|
||||
"DATALOADER_CHAINING",
|
||||
"CODE_GENERATION",
|
||||
"SPRING_DATA_COMPAT");
|
||||
"CODE_GENERATION");
|
||||
|
||||
/** Capabilities that remain experimental until a promotion decision is recorded. */
|
||||
public static final Set<String> EXPERIMENTAL =
|
||||
@@ -52,6 +51,11 @@ public final class GraphQlStableCapabilityManifest {
|
||||
"HTTP_ARRAY_BATCH",
|
||||
"ARBITRARY_JSON_INPUT_GATEWAY",
|
||||
"PERSISTENCE_ENTITY_AUTO_EXPOSURE",
|
||||
// Was an allowlisted Advanced capability. An allowlist that lets a repository back a
|
||||
// GraphQL field is still a controller reaching a repository — the second canonical
|
||||
// hard-stop in AGENTS.md — and a capability flag cannot make an architectural rule
|
||||
// conditional. Resolvers reach storage through an application use case or not at all.
|
||||
"SPRING_DATA_REPOSITORY_AUTO_EXPOSURE",
|
||||
"REQUEST_WIDE_DB_TRANSACTION",
|
||||
"DURABLE_SUBSCRIPTION_GUARANTEE",
|
||||
"EXACTLY_ONCE_SUBSCRIPTION_DELIVERY",
|
||||
|
||||
+105
@@ -0,0 +1,105 @@
|
||||
package dev.caskeleton.adapter.inbound.graphql.runtime;
|
||||
|
||||
import dev.caskeleton.adapter.inbound.graphql.context.GraphQlRequestContext;
|
||||
import dev.caskeleton.adapter.inbound.graphql.dataloader.GraphQlBatchContext;
|
||||
import dev.caskeleton.adapter.inbound.graphql.dataloader.GraphQlBatchExecutor;
|
||||
import dev.caskeleton.adapter.inbound.graphql.dataloader.GraphQlBatchTimeoutException;
|
||||
import dev.caskeleton.adapter.inbound.graphql.dataloader.GraphQlDataLoaderFactory;
|
||||
import dev.caskeleton.adapter.inbound.graphql.dataloader.GraphQlDataLoaderName;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.function.BiFunction;
|
||||
import org.springframework.graphql.execution.BatchLoaderRegistry;
|
||||
|
||||
/**
|
||||
* Registers the platform's batch loaders with Spring, so they are the ones a field actually uses.
|
||||
*
|
||||
* <p>The platform had a request registry keyed by name holding {@code Object}, and nothing
|
||||
* connected it to Spring's {@link BatchLoaderRegistry} or to java-dataloader. A field resolving
|
||||
* through {@code @BatchMapping} or {@code DataLoader} therefore never met the chunking, the budget
|
||||
* or the scope the platform had defined — the N+1 protection existed as a set of well-tested
|
||||
* objects that no request could reach.
|
||||
*
|
||||
* <p>The chunking, budget and scope arrive as a decorator around the adopter's loader rather than
|
||||
* as something the adopter has to remember. What the adopter supplies is the downstream call; what
|
||||
* this adds is everything that makes it safe to run on a shared request budget.
|
||||
*/
|
||||
public final class GraphQlBatchLoaderRegistrar {
|
||||
|
||||
private final GraphQlDataLoaderFactory factory;
|
||||
private final GraphQlBlockingBridge blockingBridge;
|
||||
|
||||
/**
|
||||
* Creates a registrar that runs loaders on the calling thread.
|
||||
*
|
||||
* <p>No scheduler of its own. Spring has already put the request on a thread; handing the work to
|
||||
* a second pool adds a queue, a wait and a context hop, and buys nothing on a servlet stack.
|
||||
*
|
||||
* @param factory supplies the per-loader batch policy and executor
|
||||
*/
|
||||
public GraphQlBatchLoaderRegistrar(GraphQlDataLoaderFactory factory) {
|
||||
this(factory, null);
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a registrar that hands blocking loads to a bounded bridge.
|
||||
*
|
||||
* @param factory supplies the per-loader batch policy and executor
|
||||
* @param blockingBridge the bounded hand-off, for a runtime where blocking in place is unsafe
|
||||
*/
|
||||
public GraphQlBatchLoaderRegistrar(
|
||||
GraphQlDataLoaderFactory factory, GraphQlBlockingBridge blockingBridge) {
|
||||
this.factory = Objects.requireNonNull(factory, "data loader factory is required");
|
||||
this.blockingBridge = blockingBridge;
|
||||
}
|
||||
|
||||
/**
|
||||
* Registers one loader under its platform name.
|
||||
*
|
||||
* <p>The registration is per request by construction: Spring builds a fresh {@code
|
||||
* DataLoaderRegistry} for every execution, so the loader's cache never outlives the request and
|
||||
* one caller's cached row can never be served to the next.
|
||||
*
|
||||
* @param <K> key type
|
||||
* @param <V> value type
|
||||
* @param registry Spring's batch loader registry
|
||||
* @param loaderName the platform loader name, which must have a registered policy
|
||||
* @param loadChunk the adopter's downstream call for one chunk
|
||||
*/
|
||||
public <K, V> void register(
|
||||
BatchLoaderRegistry registry,
|
||||
GraphQlDataLoaderName loaderName,
|
||||
BiFunction<List<K>, GraphQlBatchContext, Map<K, V>> loadChunk) {
|
||||
|
||||
GraphQlBatchExecutor executor = factory.executorFor(loaderName);
|
||||
|
||||
registry
|
||||
.<K, V>forName(loaderName.value())
|
||||
.registerMappedBatchLoader(
|
||||
(keys, environment) -> {
|
||||
GraphQlRequestContext context =
|
||||
environment.getContext() instanceof graphql.GraphQLContext graphQlContext
|
||||
? graphQlContext.get(GraphQlRequestContext.CONTEXT_KEY)
|
||||
: null;
|
||||
if (context == null) {
|
||||
// Fail closed. A loader running without the platform context has no deadline, no
|
||||
// tenant and no actor, and would happily batch across whatever the caller was.
|
||||
return reactor.core.publisher.Mono.error(
|
||||
new GraphQlBatchTimeoutException(loaderName.value()));
|
||||
}
|
||||
GraphQlBatchContext batchContext = factory.batchContext(context);
|
||||
reactor.core.publisher.Mono<Map<K, V>> load =
|
||||
reactor.core.publisher.Mono.fromCallable(
|
||||
() -> executor.load(List.copyOf(keys), batchContext, loadChunk));
|
||||
// Inline unless an adopter asked for the bridge. `supplyAsync` would have used the
|
||||
// common ForkJoinPool: a shared, unbounded-admission scheduler with no relationship
|
||||
// to the request budget, which is the second queue this platform exists to avoid.
|
||||
return blockingBridge == null
|
||||
? load
|
||||
: load.subscribeOn(
|
||||
reactor.core.scheduler.Schedulers.fromExecutor(
|
||||
blockingBridge.executorFor(context)));
|
||||
});
|
||||
}
|
||||
}
|
||||
+93
@@ -0,0 +1,93 @@
|
||||
package dev.caskeleton.adapter.inbound.graphql.runtime;
|
||||
|
||||
import dev.caskeleton.adapter.inbound.graphql.context.GraphQlRequestContext;
|
||||
import dev.caskeleton.adapter.inbound.graphql.security.GraphQlContextPropagator;
|
||||
import java.util.Objects;
|
||||
import java.util.concurrent.ArrayBlockingQueue;
|
||||
import java.util.concurrent.Executor;
|
||||
import java.util.concurrent.RejectedExecutionException;
|
||||
import java.util.concurrent.ThreadPoolExecutor;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
|
||||
/**
|
||||
* An opt-in, bounded hand-off for blocking work, carrying the request context with it.
|
||||
*
|
||||
* <p>Bounded on both axes, because a pool is only half of it. A virtual-thread-per-task executor
|
||||
* limits nothing: it accepts every task and the bound becomes whatever the downstream system will
|
||||
* tolerate. A fixed pool bounds threads and then queues without limit, which converts an overload
|
||||
* into unbounded memory and latency rather than into a refusal. Here both the pool and the queue
|
||||
* are finite and a full bridge refuses immediately, which is the answer a caller can act on.
|
||||
*
|
||||
* <p>Opt-in on purpose. The platform schedules nothing by default: work runs on the thread Spring
|
||||
* already gave it, so there is one scheduler and one queue rather than two of each with a wait
|
||||
* between them. A bridge exists for the case an adopter genuinely has — blocking work that must
|
||||
* leave an event loop — and then it is explicit, sized, and visible.
|
||||
*
|
||||
* <p>The context travels through {@link GraphQlContextPropagator}, so the thread local is bound on
|
||||
* the bridge thread and unbound afterwards. Reading the thread local is what a blocking library
|
||||
* does; the GraphQL context remains the source of truth, and this only mirrors it for the duration
|
||||
* of the hand-off.
|
||||
*/
|
||||
public final class GraphQlBlockingBridge implements AutoCloseable {
|
||||
|
||||
private final ThreadPoolExecutor executor;
|
||||
|
||||
private GraphQlBlockingBridge(ThreadPoolExecutor executor) {
|
||||
this.executor = executor;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a bridge with a finite pool and a finite queue.
|
||||
*
|
||||
* @param threads how many blocking calls may run at once
|
||||
* @param queueDepth how many may wait; beyond this the bridge refuses
|
||||
*/
|
||||
public static GraphQlBlockingBridge bounded(int threads, int queueDepth) {
|
||||
if (threads < 1 || queueDepth < 1) {
|
||||
throw new IllegalArgumentException("a blocking bridge needs a positive pool and queue");
|
||||
}
|
||||
ThreadPoolExecutor executor =
|
||||
new ThreadPoolExecutor(
|
||||
threads,
|
||||
threads,
|
||||
0,
|
||||
TimeUnit.MILLISECONDS,
|
||||
new ArrayBlockingQueue<>(queueDepth),
|
||||
runnable -> Thread.ofVirtual().name("graphql-blocking-bridge-", 0).unstarted(runnable),
|
||||
new ThreadPoolExecutor.AbortPolicy());
|
||||
return new GraphQlBlockingBridge(executor);
|
||||
}
|
||||
|
||||
/** How many blocking calls may run at once. */
|
||||
public int threads() {
|
||||
return executor.getMaximumPoolSize();
|
||||
}
|
||||
|
||||
/** How many may wait before the bridge refuses. */
|
||||
public int queueDepth() {
|
||||
return executor.getQueue().remainingCapacity() + executor.getQueue().size();
|
||||
}
|
||||
|
||||
/**
|
||||
* The executor, with the context bound around every task.
|
||||
*
|
||||
* @param context the request context to bind on the bridge thread
|
||||
*/
|
||||
public Executor executorFor(GraphQlRequestContext context) {
|
||||
Objects.requireNonNull(context, "request context is required");
|
||||
return task -> {
|
||||
try {
|
||||
executor.execute(() -> GraphQlContextPropagator.wrap(context, task).run());
|
||||
} catch (RejectedExecutionException full) {
|
||||
// Refusing is the point. Growing the queue here would turn an overload into latency that
|
||||
// the request deadline has to discover later, by which time the work is already queued.
|
||||
throw new GraphQlBlockingBridgeFullException(threads(), queueDepth());
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
@Override
|
||||
public void close() {
|
||||
executor.shutdownNow();
|
||||
}
|
||||
}
|
||||
+28
@@ -0,0 +1,28 @@
|
||||
package dev.caskeleton.adapter.inbound.graphql.runtime;
|
||||
|
||||
/**
|
||||
* Raised when the blocking bridge has no capacity left.
|
||||
*
|
||||
* <p>A refusal rather than a wait. An unbounded queue turns an overload into latency that only the
|
||||
* request deadline discovers, by which time the work is already queued behind everything else and
|
||||
* the client has been waiting for all of it.
|
||||
*
|
||||
* <p>Carries the configured bounds, never a key, an actor or a tenant.
|
||||
*/
|
||||
public class GraphQlBlockingBridgeFullException extends RuntimeException {
|
||||
|
||||
private static final long serialVersionUID = 1L;
|
||||
|
||||
/** Stable error code clients and operators see. */
|
||||
public static final String CODE = "GRAPHQL_BLOCKING_BRIDGE_FULL";
|
||||
|
||||
/**
|
||||
* Creates the failure.
|
||||
*
|
||||
* @param threads configured concurrency
|
||||
* @param queueDepth configured queue depth
|
||||
*/
|
||||
public GraphQlBlockingBridgeFullException(int threads, int queueDepth) {
|
||||
super(CODE + ": threads=" + threads + ", queue=" + queueDepth);
|
||||
}
|
||||
}
|
||||
+83
@@ -0,0 +1,83 @@
|
||||
package dev.caskeleton.adapter.inbound.graphql.runtime;
|
||||
|
||||
import dev.caskeleton.adapter.inbound.graphql.cost.GraphQlComplexityCalculator;
|
||||
import dev.caskeleton.adapter.inbound.graphql.cost.GraphQlComplexityResult;
|
||||
import dev.caskeleton.adapter.inbound.graphql.cost.GraphQlDocumentComplexityScorer;
|
||||
import dev.caskeleton.adapter.inbound.graphql.cost.GraphQlDocumentShape;
|
||||
import dev.caskeleton.adapter.inbound.graphql.cost.GraphQlDocumentShapeAnalyzer;
|
||||
import dev.caskeleton.adapter.inbound.graphql.cost.GraphQlStructuralLimitPolicy;
|
||||
import dev.caskeleton.adapter.inbound.graphql.execution.GraphQlExecutionStage;
|
||||
import dev.caskeleton.adapter.inbound.graphql.execution.GraphQlRequestCancelledException;
|
||||
import dev.caskeleton.adapter.inbound.graphql.policy.GraphQlClientPolicy;
|
||||
import java.time.Clock;
|
||||
import java.util.Objects;
|
||||
|
||||
/**
|
||||
* Enforces the structural and cost budgets, before any resolver runs.
|
||||
*
|
||||
* <p>A budget checked after execution has already been spent, so this is the last stage before
|
||||
* graphql-java starts fetching. It also re-checks the deadline: the request may have queued behind
|
||||
* other work since the transport set it, and starting an operation whose budget is already gone
|
||||
* spends downstream capacity on a response nobody will read.
|
||||
*/
|
||||
public final class GraphQlCostBudgetHandler implements GraphQlExecutionHandler {
|
||||
|
||||
private final GraphQlDocumentShapeAnalyzer analyzer;
|
||||
private final GraphQlStructuralLimitPolicy structuralLimits;
|
||||
private final GraphQlComplexityCalculator calculator;
|
||||
private final GraphQlDocumentComplexityScorer scorer;
|
||||
private final GraphQlClientPolicy policy;
|
||||
private final Clock clock;
|
||||
|
||||
/**
|
||||
* Creates the handler.
|
||||
*
|
||||
* @param analyzer document structure measurement
|
||||
* @param structuralLimits per-profile structural ceilings
|
||||
* @param calculator per-field pricing
|
||||
* @param policy the client policy carrying the complexity budget
|
||||
* @param clock clock used for the deadline re-check
|
||||
*/
|
||||
public GraphQlCostBudgetHandler(
|
||||
GraphQlDocumentShapeAnalyzer analyzer,
|
||||
GraphQlStructuralLimitPolicy structuralLimits,
|
||||
GraphQlComplexityCalculator calculator,
|
||||
GraphQlClientPolicy policy,
|
||||
Clock clock) {
|
||||
this.analyzer = Objects.requireNonNull(analyzer, "document analyzer is required");
|
||||
this.structuralLimits = Objects.requireNonNull(structuralLimits, "structural limits required");
|
||||
this.calculator = Objects.requireNonNull(calculator, "complexity calculator is required");
|
||||
this.scorer = new GraphQlDocumentComplexityScorer(this.calculator);
|
||||
this.policy = Objects.requireNonNull(policy, "client policy is required");
|
||||
this.clock = Objects.requireNonNull(clock, "clock is required");
|
||||
}
|
||||
|
||||
@Override
|
||||
public GraphQlExecutionStage stage() {
|
||||
return GraphQlExecutionStage.COST;
|
||||
}
|
||||
|
||||
@Override
|
||||
public GraphQlExecutionContext handle(GraphQlExecutionContext context) {
|
||||
if (context.requestContext().deadline().expired(clock)) {
|
||||
throw new GraphQlRequestCancelledException();
|
||||
}
|
||||
|
||||
// Scoped to the operation that will actually run. Measuring every operation in the document
|
||||
// charges a client for selections this request never executes, which turns a document holding
|
||||
// three cheap queries into one expensive one.
|
||||
GraphQlDocumentShape shape =
|
||||
analyzer.analyze(context.requireDocument(), context.requireOperation());
|
||||
structuralLimits.verify(shape);
|
||||
|
||||
GraphQlComplexityResult complexity =
|
||||
scorer.score(
|
||||
context.schema(),
|
||||
context.requireDocument(),
|
||||
context.requireOperation(),
|
||||
context.request().variables());
|
||||
calculator.verify(complexity, policy.maxComplexity());
|
||||
|
||||
return context.withCost(shape, complexity);
|
||||
}
|
||||
}
|
||||
+70
@@ -0,0 +1,70 @@
|
||||
package dev.caskeleton.adapter.inbound.graphql.runtime;
|
||||
|
||||
import dev.caskeleton.adapter.inbound.graphql.error.GraphQlErrorContext;
|
||||
import dev.caskeleton.adapter.inbound.graphql.error.GraphQlWireError;
|
||||
import graphql.GraphQLError;
|
||||
import graphql.schema.DataFetchingEnvironment;
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
import org.springframework.graphql.execution.DataFetcherExceptionResolverAdapter;
|
||||
|
||||
/**
|
||||
* Adapts the canonical wire-error mapper onto Spring's data-fetcher exception contract.
|
||||
*
|
||||
* <p>An adapter and nothing more. What a client is told — code, category, retryability, masking —
|
||||
* is decided by {@link GraphQlWireErrorMapper}, so a failure raised inside a resolver and the same
|
||||
* failure raised before execution produce the same answer. The previous arrangement had that
|
||||
* decision in two classes whose names differed by one letter's case, only one of which Spring
|
||||
* actually called.
|
||||
*/
|
||||
public final class GraphQlDataFetcherExceptionResolver extends DataFetcherExceptionResolverAdapter {
|
||||
|
||||
private final GraphQlWireErrorMapper mapper;
|
||||
|
||||
/**
|
||||
* Creates the resolver.
|
||||
*
|
||||
* @param mapper the canonical mapper
|
||||
*/
|
||||
public GraphQlDataFetcherExceptionResolver(GraphQlWireErrorMapper mapper) {
|
||||
this.mapper = Objects.requireNonNull(mapper, "wire error mapper is required");
|
||||
// Spring resolves a failing field, not the whole request, so the response keeps sibling data.
|
||||
setThreadLocalContextAware(false);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected GraphQLError resolveToSingleError(Throwable failure, DataFetchingEnvironment env) {
|
||||
GraphQlWireError wireError = mapper.mapFieldFailure(failure, errorContext(env));
|
||||
// A carrier still knows its operational category, which is finer than the six wire categories.
|
||||
// Classifying from it keeps `NOT_FOUND` distinguishable from any other business outcome.
|
||||
if (failure instanceof dev.caskeleton.shared.error.ApiErrorCarrier carrier) {
|
||||
return GraphQlWireErrors.toGraphQlError(
|
||||
wireError, GraphQlWireErrors.classificationOf(carrier.errorCode().category()));
|
||||
}
|
||||
return GraphQlWireErrors.toGraphQlError(wireError);
|
||||
}
|
||||
|
||||
/**
|
||||
* Correlation context for a failing field.
|
||||
*
|
||||
* <p>A real execution always supplies the environment; a unit test may not, and a null-hostile
|
||||
* mapper would then be untestable in isolation.
|
||||
*/
|
||||
private static GraphQlErrorContext errorContext(DataFetchingEnvironment env) {
|
||||
if (env == null) {
|
||||
return GraphQlErrorContext.of("unknown-execution");
|
||||
}
|
||||
String executionId = String.valueOf(env.getExecutionId());
|
||||
List<Object> path =
|
||||
env.getExecutionStepInfo() == null
|
||||
? List.of()
|
||||
: List.copyOf(env.getExecutionStepInfo().getPath().toList());
|
||||
String coordinate =
|
||||
env.getExecutionStepInfo() == null
|
||||
? null
|
||||
: env.getExecutionStepInfo().getObjectType().getName()
|
||||
+ "."
|
||||
+ env.getExecutionStepInfo().getFieldDefinition().getName();
|
||||
return GraphQlErrorContext.field(executionId, path, coordinate);
|
||||
}
|
||||
}
|
||||
+139
@@ -0,0 +1,139 @@
|
||||
package dev.caskeleton.adapter.inbound.graphql.runtime;
|
||||
|
||||
import dev.caskeleton.adapter.inbound.graphql.api.GraphQlSchemaCoordinate;
|
||||
import dev.caskeleton.adapter.inbound.graphql.cost.GraphQlDocumentShapeAnalyzer;
|
||||
import dev.caskeleton.adapter.inbound.graphql.execution.GraphQlExecutionStage;
|
||||
import dev.caskeleton.adapter.inbound.graphql.policy.GraphQlClientPolicy;
|
||||
import dev.caskeleton.adapter.inbound.graphql.security.GraphQlAuthorizationInterceptor;
|
||||
import graphql.language.Field;
|
||||
import graphql.language.FragmentDefinition;
|
||||
import graphql.language.FragmentSpread;
|
||||
import graphql.language.InlineFragment;
|
||||
import graphql.language.OperationDefinition;
|
||||
import graphql.language.Selection;
|
||||
import graphql.language.SelectionSet;
|
||||
import java.util.ArrayDeque;
|
||||
import java.util.Deque;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.Set;
|
||||
|
||||
/**
|
||||
* Authorizes the operation and its root coordinates, before any resolver runs.
|
||||
*
|
||||
* <p>Running before execution is the whole point: a mutation denied halfway through has already
|
||||
* performed the side effect it was denied for. So the decision is made on the document, and a
|
||||
* denial means zero data fetchers were invoked.
|
||||
*
|
||||
* <p>The introspection gate lives here rather than in the cost stage because it is an authorization
|
||||
* question — whether this client profile may see the schema — and not a budget one.
|
||||
*/
|
||||
public final class GraphQlDocumentAuthorizationHandler implements GraphQlExecutionHandler {
|
||||
|
||||
private final GraphQlAuthorizationInterceptor authorization;
|
||||
private final GraphQlDocumentShapeAnalyzer analyzer;
|
||||
private final GraphQlClientPolicy policy;
|
||||
|
||||
/**
|
||||
* Creates the handler.
|
||||
*
|
||||
* @param authorization coordinate authorization
|
||||
* @param analyzer document analyzer used for the introspection gate
|
||||
* @param policy the client policy that decides whether introspection is permitted
|
||||
*/
|
||||
public GraphQlDocumentAuthorizationHandler(
|
||||
GraphQlAuthorizationInterceptor authorization,
|
||||
GraphQlDocumentShapeAnalyzer analyzer,
|
||||
GraphQlClientPolicy policy) {
|
||||
this.authorization = Objects.requireNonNull(authorization, "authorization is required");
|
||||
this.analyzer = Objects.requireNonNull(analyzer, "document analyzer is required");
|
||||
this.policy = Objects.requireNonNull(policy, "client policy is required");
|
||||
}
|
||||
|
||||
@Override
|
||||
public GraphQlExecutionStage stage() {
|
||||
return GraphQlExecutionStage.AUTHORIZATION;
|
||||
}
|
||||
|
||||
@Override
|
||||
public GraphQlExecutionContext handle(GraphQlExecutionContext context) {
|
||||
OperationDefinition operation = context.requireOperation();
|
||||
analyzer.verifyIntrospection(
|
||||
context.requireDocument(), operation, policy.introspectionAllowed());
|
||||
|
||||
String rootTypeName = rootTypeName(operation);
|
||||
for (String fieldName : rootFieldNames(context, operation)) {
|
||||
authorization.authorize(
|
||||
context.requestContext(), new GraphQlSchemaCoordinate(rootTypeName, fieldName));
|
||||
}
|
||||
return context;
|
||||
}
|
||||
|
||||
private static String rootTypeName(OperationDefinition operation) {
|
||||
OperationDefinition.Operation kind =
|
||||
operation.getOperation() == null
|
||||
? OperationDefinition.Operation.QUERY
|
||||
: operation.getOperation();
|
||||
return switch (kind) {
|
||||
case QUERY -> "Query";
|
||||
case MUTATION -> "Mutation";
|
||||
case SUBSCRIPTION -> "Subscription";
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Root field names the operation actually reaches, fragments included.
|
||||
*
|
||||
* <p>Fragments are expanded rather than skipped: a root field reached only through a named
|
||||
* fragment is executed exactly like one written inline, so authorizing only the inline ones would
|
||||
* make {@code query { ...Hidden }} a bypass.
|
||||
*/
|
||||
private static Set<String> rootFieldNames(
|
||||
GraphQlExecutionContext context, OperationDefinition operation) {
|
||||
|
||||
Map<String, FragmentDefinition> fragments = new LinkedHashMap<>();
|
||||
context
|
||||
.requireDocument()
|
||||
.getDefinitions()
|
||||
.forEach(
|
||||
definition -> {
|
||||
if (definition instanceof FragmentDefinition fragment) {
|
||||
fragments.put(fragment.getName(), fragment);
|
||||
}
|
||||
});
|
||||
|
||||
Set<String> names = new LinkedHashSet<>();
|
||||
collect(operation.getSelectionSet(), fragments, names, new ArrayDeque<>());
|
||||
return names;
|
||||
}
|
||||
|
||||
private static void collect(
|
||||
SelectionSet selectionSet,
|
||||
Map<String, FragmentDefinition> fragments,
|
||||
Set<String> names,
|
||||
Deque<String> expansionPath) {
|
||||
|
||||
if (selectionSet == null) {
|
||||
return;
|
||||
}
|
||||
for (Selection<?> selection : selectionSet.getSelections()) {
|
||||
if (selection instanceof Field field) {
|
||||
if (!field.getName().startsWith(GraphQlDocumentShapeAnalyzer.INTROSPECTION_FIELD_PREFIX)) {
|
||||
names.add(field.getName());
|
||||
}
|
||||
} else if (selection instanceof InlineFragment inlineFragment) {
|
||||
collect(inlineFragment.getSelectionSet(), fragments, names, expansionPath);
|
||||
} else if (selection instanceof FragmentSpread spread) {
|
||||
FragmentDefinition fragment = fragments.get(spread.getName());
|
||||
if (fragment == null || expansionPath.contains(spread.getName())) {
|
||||
continue;
|
||||
}
|
||||
expansionPath.push(spread.getName());
|
||||
collect(fragment.getSelectionSet(), fragments, names, expansionPath);
|
||||
expansionPath.pop();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+82
@@ -0,0 +1,82 @@
|
||||
package dev.caskeleton.adapter.inbound.graphql.runtime;
|
||||
|
||||
import dev.caskeleton.adapter.inbound.graphql.execution.GraphQlExecutionPipeline;
|
||||
import dev.caskeleton.adapter.inbound.graphql.execution.GraphQlExecutionPipelineValidator;
|
||||
import dev.caskeleton.adapter.inbound.graphql.execution.GraphQlExecutionStage;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* The handlers the platform actually runs, in the order it runs them.
|
||||
*
|
||||
* <p>This is the executable pipeline; {@link GraphQlExecutionPipeline} is a view derived from it.
|
||||
* The direction matters: a stage catalogue written by hand can drift from the code without anything
|
||||
* failing, whereas a catalogue derived from the registered handlers cannot describe a stage that is
|
||||
* not there.
|
||||
*
|
||||
* <p>Two stages are owned by the framework seams rather than by handlers, and the derived pipeline
|
||||
* says so explicitly. {@code CONTEXT} happens in the transport interceptor, which is the only layer
|
||||
* that can see the authenticated principal, and {@code EXECUTE} is graphql-java running the
|
||||
* operation. Including them in the derived view is what lets the ordering validator judge the whole
|
||||
* request path instead of the middle of it.
|
||||
*/
|
||||
public final class GraphQlExecutionChain {
|
||||
|
||||
private final List<GraphQlExecutionHandler> handlers;
|
||||
|
||||
/**
|
||||
* Creates the chain and validates the order it produces.
|
||||
*
|
||||
* @param handlers handlers in execution order
|
||||
* @throws dev.caskeleton.adapter.inbound.graphql.execution.GraphQlExecutionPipelineException when
|
||||
* the resulting pipeline drops or reorders a required stage
|
||||
*/
|
||||
public GraphQlExecutionChain(List<GraphQlExecutionHandler> handlers) {
|
||||
if (handlers == null || handlers.isEmpty()) {
|
||||
throw new IllegalArgumentException("an execution chain needs at least one handler");
|
||||
}
|
||||
this.handlers = List.copyOf(handlers);
|
||||
GraphQlExecutionPipelineValidator.validate(pipeline());
|
||||
}
|
||||
|
||||
/** The Stable chain: select the operation, authorize it, then judge its cost. */
|
||||
public static GraphQlExecutionChain stable(
|
||||
GraphQlOperationSelectionHandler selection,
|
||||
GraphQlDocumentAuthorizationHandler authorization,
|
||||
GraphQlCostBudgetHandler cost) {
|
||||
return new GraphQlExecutionChain(List.of(selection, authorization, cost));
|
||||
}
|
||||
|
||||
/** The pipeline this chain realises, including the two framework-owned stages. */
|
||||
public GraphQlExecutionPipeline pipeline() {
|
||||
List<GraphQlExecutionStage> stages = new ArrayList<>();
|
||||
stages.add(GraphQlExecutionStage.CONTEXT);
|
||||
handlers.forEach(handler -> stages.add(handler.stage()));
|
||||
stages.add(GraphQlExecutionStage.EXECUTE);
|
||||
return new GraphQlExecutionPipeline(stages);
|
||||
}
|
||||
|
||||
/** The registered handlers, in execution order. */
|
||||
public List<GraphQlExecutionHandler> handlers() {
|
||||
return handlers;
|
||||
}
|
||||
|
||||
/**
|
||||
* Runs every handler in order.
|
||||
*
|
||||
* @param start state produced by the transport
|
||||
* @return the state after the last handler
|
||||
* @throws RuntimeException the first rejection, unwrapped, for the caller to map
|
||||
*/
|
||||
public GraphQlExecutionContext run(GraphQlExecutionContext start) {
|
||||
GraphQlExecutionContext current = start;
|
||||
for (GraphQlExecutionHandler handler : handlers) {
|
||||
current = handler.handle(current);
|
||||
if (current == null) {
|
||||
throw new IllegalStateException(
|
||||
"handler for stage " + handler.stage() + " returned no state");
|
||||
}
|
||||
}
|
||||
return current;
|
||||
}
|
||||
}
|
||||
+94
@@ -0,0 +1,94 @@
|
||||
package dev.caskeleton.adapter.inbound.graphql.runtime;
|
||||
|
||||
import dev.caskeleton.adapter.inbound.graphql.context.GraphQlRequestContext;
|
||||
import dev.caskeleton.adapter.inbound.graphql.cost.GraphQlComplexityResult;
|
||||
import dev.caskeleton.adapter.inbound.graphql.cost.GraphQlDocumentShape;
|
||||
import dev.caskeleton.adapter.inbound.graphql.execution.GraphQlOperationSelection;
|
||||
import graphql.language.Document;
|
||||
import graphql.language.OperationDefinition;
|
||||
import graphql.schema.GraphQLSchema;
|
||||
import java.util.Objects;
|
||||
|
||||
/**
|
||||
* The state threaded through the executable pipeline.
|
||||
*
|
||||
* <p>Each stage adds a field and never removes one, so "has this stage run?" is answerable by
|
||||
* looking at the value rather than by trusting the order someone registered handlers in. A stage
|
||||
* that needs an earlier result asks for it and fails loudly when it is absent — the alternative is
|
||||
* a chain that silently authorizes {@code null}.
|
||||
*
|
||||
* @param request the request as received
|
||||
* @param requestContext identity, tenant, client policy and deadline, produced by the transport
|
||||
* @param schema the schema the document was validated against
|
||||
* @param document the parsed document
|
||||
* @param operation the selected operation, produced by the parse/select stage
|
||||
* @param selection what was selected and out of how many
|
||||
* @param shape measured document structure, produced by the cost stage
|
||||
* @param complexity the scored complexity, produced by the cost stage
|
||||
*/
|
||||
public record GraphQlExecutionContext(
|
||||
GraphQlExecutionRequest request,
|
||||
GraphQlRequestContext requestContext,
|
||||
GraphQLSchema schema,
|
||||
Document document,
|
||||
OperationDefinition operation,
|
||||
GraphQlOperationSelection selection,
|
||||
GraphQlDocumentShape shape,
|
||||
GraphQlComplexityResult complexity) {
|
||||
|
||||
public GraphQlExecutionContext {
|
||||
Objects.requireNonNull(request, "request is required");
|
||||
Objects.requireNonNull(requestContext, "request context is required");
|
||||
}
|
||||
|
||||
/** The state a chain starts from, once the transport has established identity and parsed. */
|
||||
public static GraphQlExecutionContext starting(
|
||||
GraphQlExecutionRequest request,
|
||||
GraphQlRequestContext requestContext,
|
||||
GraphQLSchema schema,
|
||||
Document document) {
|
||||
return new GraphQlExecutionContext(
|
||||
request, requestContext, schema, document, null, null, null, null);
|
||||
}
|
||||
|
||||
/** Returns a copy carrying the selected operation. */
|
||||
public GraphQlExecutionContext withSelection(
|
||||
OperationDefinition selected,
|
||||
GraphQlOperationSelection operationSelection,
|
||||
GraphQlRequestContext refinedContext) {
|
||||
return new GraphQlExecutionContext(
|
||||
request, refinedContext, schema, document, selected, operationSelection, shape, complexity);
|
||||
}
|
||||
|
||||
/** Returns a copy carrying the measured shape and score. */
|
||||
public GraphQlExecutionContext withCost(
|
||||
GraphQlDocumentShape measuredShape, GraphQlComplexityResult scored) {
|
||||
return new GraphQlExecutionContext(
|
||||
request, requestContext, schema, document, operation, selection, measuredShape, scored);
|
||||
}
|
||||
|
||||
/**
|
||||
* The selected operation.
|
||||
*
|
||||
* @throws IllegalStateException when the parse/select stage has not run
|
||||
*/
|
||||
public OperationDefinition requireOperation() {
|
||||
if (operation == null) {
|
||||
throw new IllegalStateException(
|
||||
"no operation has been selected; PARSE_VALIDATE must run before this stage");
|
||||
}
|
||||
return operation;
|
||||
}
|
||||
|
||||
/**
|
||||
* The parsed document.
|
||||
*
|
||||
* @throws IllegalStateException when the document is absent
|
||||
*/
|
||||
public Document requireDocument() {
|
||||
if (document == null) {
|
||||
throw new IllegalStateException("no parsed document is available for this stage");
|
||||
}
|
||||
return document;
|
||||
}
|
||||
}
|
||||
+27
@@ -0,0 +1,27 @@
|
||||
package dev.caskeleton.adapter.inbound.graphql.runtime;
|
||||
|
||||
import dev.caskeleton.adapter.inbound.graphql.execution.GraphQlExecutionStage;
|
||||
|
||||
/**
|
||||
* One executable stage of the platform pipeline.
|
||||
*
|
||||
* <p>A handler takes the state produced so far and returns the state it produced, so a stage that
|
||||
* needs a previous stage's output cannot run without it — the missing value is a missing field, not
|
||||
* a convention. This is what separates the pipeline from a list of stage names: the names could be
|
||||
* in any order and nothing would notice, while a chain that authorizes before selecting an
|
||||
* operation has nothing to authorize.
|
||||
*/
|
||||
public interface GraphQlExecutionHandler {
|
||||
|
||||
/** The stage this handler implements, used to derive and validate the pipeline order. */
|
||||
GraphQlExecutionStage stage();
|
||||
|
||||
/**
|
||||
* Runs the stage.
|
||||
*
|
||||
* @param context state produced by the preceding stages
|
||||
* @return state including whatever this stage produced
|
||||
* @throws RuntimeException when the request is rejected; the caller maps it onto the wire
|
||||
*/
|
||||
GraphQlExecutionContext handle(GraphQlExecutionContext context);
|
||||
}
|
||||
+41
@@ -0,0 +1,41 @@
|
||||
package dev.caskeleton.adapter.inbound.graphql.runtime;
|
||||
|
||||
import dev.caskeleton.adapter.inbound.graphql.http.GraphQlJsonValues;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* The request as it entered the platform, before anything was decided about it.
|
||||
*
|
||||
* <p>Sizes are carried rather than recomputed. The transport is the only layer that sees the raw
|
||||
* bytes, and a limit re-derived downstream from a re-serialised map measures the platform's own
|
||||
* encoder rather than what the client actually sent.
|
||||
*
|
||||
* @param document the GraphQL document text
|
||||
* @param operationName the requested operation name, or {@code null}
|
||||
* @param variables the request variables, never {@code null}
|
||||
* @param documentBytes size of the document as received
|
||||
* @param variablesBytes size of the serialised variables as received
|
||||
*/
|
||||
public record GraphQlExecutionRequest(
|
||||
String document,
|
||||
String operationName,
|
||||
Map<String, Object> variables,
|
||||
long documentBytes,
|
||||
long variablesBytes) {
|
||||
|
||||
public GraphQlExecutionRequest {
|
||||
if (document == null) {
|
||||
throw new IllegalArgumentException("document is required");
|
||||
}
|
||||
// Null-valued variables are legal GraphQL input, so this cannot be Map.copyOf.
|
||||
variables = GraphQlJsonValues.immutableObject(variables);
|
||||
if (documentBytes < 0 || variablesBytes < 0) {
|
||||
throw new IllegalArgumentException("request sizes cannot be negative");
|
||||
}
|
||||
}
|
||||
|
||||
/** Whether the request named the operation to run. */
|
||||
public boolean named() {
|
||||
return operationName != null && !operationName.isBlank();
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user