merge: integrate messaging R2 polling producer
# Conflicts: # docs/superpowers/specs/2026-07-28-messaging-production-capability-design.md # src/app-bootstrap/gradle.lockfile # src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/architecture/ArchitectureViolationFixtureTest.java # src/build.gradle
This commit is contained in:
@@ -1,7 +1,7 @@
|
||||
# Messaging Production Capability Deep Design
|
||||
|
||||
- 작성일: 2026-07-28
|
||||
- 상태: 상세 설계 승인, 실행 계획 작성·독립 검토 완료, 구현 미착수
|
||||
- 상태: 상세 설계 승인, P0 characterization 및 P1 implementation candidate 완료, P2 이후 미착수
|
||||
- 기준: Java 21, Spring Boot 4.0.0, Gradle 멀티모듈 Clean Architecture
|
||||
- 현재 outbound leaf: `adapter-outbound-messaging`
|
||||
- 미래 inbound leaf: `adapter-inbound-messaging-kafka`
|
||||
@@ -124,8 +124,8 @@ cdc = disabled
|
||||
|
||||
| Phase | 산출물 | 2026-07-28 상태 | 허용 표현 |
|
||||
| --- | --- | --- | --- |
|
||||
| P0 | current truth, design, characterization | `IMPLEMENTATION_PLAN_READY` | 설계·실행 계획 완료, 구현 미착수 |
|
||||
| P1 | event contract/catalog/envelope/schema | `NOT_STARTED` | 미구현 |
|
||||
| P0 | current truth, design, characterization | `CHARACTERIZED` | legacy R0 동작 고정, production 동작 변경 없음 |
|
||||
| P1 | event contract/catalog/envelope/schema | `IMPLEMENTED_CANDIDATE` | deterministic local wire contract 후보, production 미연결 |
|
||||
| P2 | immutable event + polling delivery v2 | `NOT_STARTED` | legacy polling만 존재 |
|
||||
| P3 | Spring Kafka ACK-aware producer | `NOT_STARTED` | Kafka seam R0 |
|
||||
| P4 | security/observability/fault/real-service R2 evidence | `NOT_STARTED` | R2 주장 금지 |
|
||||
@@ -136,6 +136,20 @@ cdc = disabled
|
||||
Phase 진행도와 capability readiness는 별도 축이다. 예를 들어 P3 코드가 존재해도 exact selected
|
||||
profile이 real Kafka, 보안, fault, shutdown evidence를 통과하지 않으면 R2가 아니다.
|
||||
|
||||
P1 후보는 closed application contract SPI, generic/sample schema, exact destination/card binding,
|
||||
single-snapshot deterministic envelope, pinned local Draft 2020-12 validation과 payload-free build
|
||||
evidence까지 구현했다. `verifyMessagingJsonSchemaV1` 28개와 `verifyMessagingContracts` 82개
|
||||
scenario가 실패/skip 없이 통과했으며 생성 manifest도 공통 Draft 2020-12 schema로 실제
|
||||
검증한다. `json-schema-envelope.v1`만 `implemented-candidate`이고 evidence fingerprint는
|
||||
release attestation이 아니므로 비워 둔다. Kafka ACK producer, durable outbox v2, production
|
||||
runtime wiring, full validator compatibility와 regex execution timeout은 여전히 미구현/미증명이다.
|
||||
|
||||
P0 characterization은 blank broker의 disabled sentinel, 선택 broker의 sender 누락 및 broker ID
|
||||
불일치 startup 실패, legacy void sender 정상 반환 뒤 `PUBLISHED`, sender 예외 뒤 `FAILED/DEAD`,
|
||||
상태 mark 실패 뒤 `IN_FLIGHT`와 duplicate 가능 구간, 같은 transaction의 append rollback,
|
||||
`occurred_at` timestamp만 사용하는 FIFO의 동률 한계를 현재 truth로 고정한다. 여기서 legacy
|
||||
void 반환은 broker acknowledgement가 아니다.
|
||||
|
||||
## 1. 설계 판정
|
||||
|
||||
이번 설계는 provider-neutral semantic contract와 Kafka reference implementation을 분리한다.
|
||||
@@ -1056,10 +1070,9 @@ transaction/inbox provider를 조립한다.
|
||||
- provider setting;
|
||||
- feature-specific topic.
|
||||
|
||||
현재 `shared-contract/CLAUDE.md`에는 messaging schema resource가 아직 책임으로 등록되어 있지
|
||||
않다. P1에서 공통 envelope schema를 추가하는 변경은 같은 commit scope에서 해당
|
||||
`CLAUDE.md`의 Responsibility를 갱신하고 Java-stdlib-only 규칙과 business-free 검증을
|
||||
추가해야 한다. 이 정책 갱신 없이 resource만 넣지 않는다.
|
||||
P1 구현에서 공통 envelope schema와 함께 `shared-contract/CLAUDE.md`의 Responsibility,
|
||||
Java-stdlib-only 규칙, business-free 검증을 갱신했다. 이후 schema resource를 확장할 때도
|
||||
이 세 계약을 같은 변경 범위에서 유지한다.
|
||||
|
||||
### 9.7 `app-bootstrap`
|
||||
|
||||
@@ -5429,7 +5442,7 @@ Kafka에 갈 수 있는 시점부터 old/new relay를 동시에 켜는 rollback
|
||||
- LLM Wiki capture.
|
||||
|
||||
현재 문서 상태는 사용자 승인에 따라
|
||||
`상세 설계 승인, 실행 계획 작성·독립 검토 완료, 구현 미착수`다.
|
||||
`상세 설계 승인, P0 characterization 및 P1 implementation candidate 완료, P2 이후 미착수`다.
|
||||
|
||||
### 31.2 First R2 implementation complete
|
||||
|
||||
|
||||
@@ -15,6 +15,11 @@ Package root: `dev.caskeleton.adapter.outbound.messaging`.
|
||||
- Implement outbound message publication and broker integration behind application/domain ports.
|
||||
- Own broker settings, serialization envelope, disabled/fail-safe technical modes, and outbox
|
||||
publication adaptation.
|
||||
- Own the closed local contract/destination/capability binding compiler, stable catalog/settings/
|
||||
schema digests, and deterministic partition-key algorithm. Physical topics and bootstrap servers
|
||||
stay in deployment settings and compiled outbound bindings.
|
||||
- Own the deterministic UTF-8 envelope writer and exact-resource Draft 2020-12 registry. Schema
|
||||
validation is local and precompiled; YAML and runtime network/filesystem resolution are forbidden.
|
||||
- Own structured rendering of `OutboxRelayFailureReport` through the single unconditional
|
||||
`Slf4jOutboxRelayFailureReportAdapter` bean.
|
||||
- Reuse `adapter:outbound:support` for shared technical concerns.
|
||||
@@ -24,6 +29,42 @@ Package root: `dev.caskeleton.adapter.outbound.messaging`.
|
||||
- Allowed dependency edges come only from the module's
|
||||
`src/config/architecture/modules.json` entry.
|
||||
- No inbound DTO/controller, persistence repository/entity, bootstrap, or sample dependency.
|
||||
- Contract compilation accepts only explicitly supplied application SPI contributions; do not scan,
|
||||
import sample payload classes, use `Class.forName`, or discover contracts from raw JSON/tree data.
|
||||
- Snapshot each contribution accessor exactly once. Compiled contracts/publication bindings have
|
||||
no public constructor and ACTIVE descriptors are created only through the canonical compile path.
|
||||
- Keep adapter-local compiler/card/binding types off public instance return surfaces. Use
|
||||
package-private accessors and narrowly scoped public static composition bridges; expose only
|
||||
primitive/String/core values from public instance methods.
|
||||
- `DISABLED` with an empty catalog/binding is a zero-resource state. The R1 compiler is intentionally
|
||||
not wired to legacy `MessagingConfig`; it must not claim Kafka ACK, wire compatibility,
|
||||
`ACTIVE_READY`, or R2 qualification.
|
||||
- The local encoder accepts only exact registered final payload records and a closed value
|
||||
vocabulary. No `Map`, raw JSON/tree, polymorphic type or custom serializer input is allowed.
|
||||
- Compile and freeze the complete declared payload generic graph before encoding. Allow only the
|
||||
closed scalar/enum vocabulary, `Optional<T>`, `List<T>`, and exact final nested records; reject
|
||||
open/raw/wildcard/interface/tree types and runtime record discovery.
|
||||
- Snapshot every payload record accessor exactly once, encode the payload once, and reuse those
|
||||
exact trusted bytes for payload-schema validation and envelope embedding. Do not add a raw JSON
|
||||
parser/generator injection path.
|
||||
- Apply decimal/list/output admission before unbounded allocation: bound plain decimal digits
|
||||
before `toPlainString`, check list size before bounded iteration without `toArray`, fail closed on
|
||||
mutation/concurrency, and enforce output bytes while the generator writes.
|
||||
- Keep the nine exact checked-in Draft 2020-12 schema/meta bytes and digests synchronized. Startup
|
||||
compares those bytes, IDs, and NetworkNT runtime schema trees without assuming the validator's
|
||||
`CodeSource` is a regular JAR. Strict dependency locks and `verifyJsonSchemaRuntimeGraph` own the
|
||||
NetworkNT 3.0.2 artifact provenance.
|
||||
- Permit only lowercase exact `urn` root `$id` and absolute `$ref` schemes. Reject every
|
||||
root-external nested `$id` key regardless of value type, and reject `$dynamicRef`,
|
||||
`$dynamicAnchor`, deprecated `$recursiveRef`/`$recursiveAnchor`, and `$anchor` anywhere in the
|
||||
Task 6 closed subset.
|
||||
- Keep common evidence validation in test/build qualification code: validate exact schema and
|
||||
generated manifest bytes with the pinned NetworkNT/`LocalJsonSchemaRegistry`, retain schema
|
||||
meta-validation, and order JSON-only evidence before combined contract evidence so the combined
|
||||
manifest deterministically owns the shared output.
|
||||
- The adopted JDK regex engine has no proven execution timeout. Keep regex input and document
|
||||
budgets finite, retain the adversarial corpus, and preserve the explicit unsupported evidence
|
||||
claim until a separately reviewed bounded engine or isolation policy exists.
|
||||
- Do not hide use-case sequencing or business routing policy in broker adapters.
|
||||
- `OutboxMessagePublishAdapter` is mapping/send-only and emits no dependency log. The confirmed
|
||||
FAILED/DEAD transition owns the one canonical ERROR; only the general fail-open publisher keeps
|
||||
|
||||
@@ -45,6 +45,22 @@ application-core 포트(`MessagePublisher` / `OutboxMessagePublishPort`) 뒤에
|
||||
그대로 전파하고 checked 예외는 cause를 보존해 감싼다. 성공 DEBUG나 실패 WARN을 남기지 않는다.
|
||||
반환 타입을 void 로 둬 broker SDK 타입이 어댑터 밖으로 새지 않는다(B7).
|
||||
|
||||
## Legacy R0 characterization
|
||||
|
||||
현재 `KafkaSender.send(...)`와 `MessageBroker.send(...)`의 정상 void 반환은 호출이 예외 없이
|
||||
끝났다는 뜻일 뿐 broker acknowledgement 증거가 아니다. relay는 이 정상 반환 뒤 legacy
|
||||
`PUBLISHED`를 기록한다. sender 예외는 fail-closed outbox 경로에서 전파되어 relay의
|
||||
`FAILED/DEAD` 전이를 유발한다.
|
||||
|
||||
broker 설정이 blank면 두 포트는 각각 `DisabledMessagePublisher`와
|
||||
`DisabledOutboxMessagePublisher`에 바인딩된다. broker를 선택했지만 project-supplied sender가
|
||||
없거나 선택 ID와 활성 `MessageBroker.brokerId()`가 다르면 startup이 실패한다.
|
||||
|
||||
publisher 정상 반환 뒤 DB mark가 실패하면 row는 `IN_FLIGHT`에 남아 timeout reclaim 후 같은
|
||||
event가 다시 publish될 수 있다. 현재 consumer/inbox가 없으므로 이 duplicate 가능 구간을
|
||||
중복 안전 전달로 표현하지 않는다. 또한 legacy FIFO gate는 `occurred_at`의 strict less-than 비교만
|
||||
사용하므로 동일 aggregate의 timestamp 동률 row는 서로를 gate하지 않는다.
|
||||
|
||||
## OutboxRelayFailureReport 구조화 ERROR
|
||||
|
||||
`MessagingConfig`는 broker 활성 여부와 무관하게 정확히 하나의
|
||||
@@ -58,3 +74,86 @@ ERROR 하나로 렌더링한다.
|
||||
payload/idempotency key/envelope/exception-derived field는 받거나 렌더링하지 않고 cause만 throwable로
|
||||
붙인다. logging 내부 `RuntimeException`은 adapter와 use case 양쪽에서 방어하므로 persisted
|
||||
FAILED/DEAD outcome을 바꾸지 않는다.
|
||||
|
||||
## Closed local binding compiler R1
|
||||
|
||||
`contract`, `destination`, `config` package는 명시적으로 주입된
|
||||
`IntegrationEventContractContribution` 목록만 사용해 contract catalog와 deployment binding을
|
||||
로컬에서 컴파일한다. payload record의 exact `Class` token, 선언 순서, schema resource/hash를
|
||||
검증하며 scan, `Class.forName`, assignable discovery, raw JSON/tree discovery를 하지 않는다.
|
||||
catalog/settings/schema digest는 정렬된 semantic identity와 length-prefixed UTF-8/raw SHA-256
|
||||
bytes로 계산한다.
|
||||
|
||||
physical topic과 bootstrap server는 `DestinationBindingSettings`와
|
||||
`CompiledPublicationBinding`에만 존재한다. application contract에는 broker, topic, credential
|
||||
설정이 유출되지 않는다. logical destination과 physical topic은 각각 deployment 안에서 unique다.
|
||||
compiled publication binding은 package-local compiler만 만들 수 있고 active descriptor는 public
|
||||
static compile 경로로만 생성한다. compiled integration-event contract도 catalog compiler만
|
||||
package-local constructor로 만들 수 있다. partition key v1은 canonical non-null tenant scope,
|
||||
logical destination, aggregate type/id를 strict UTF-8 REPORT + domain-separated
|
||||
length-prefixed SHA-256으로 계산하고 lowercase hex US-ASCII bytes를 반환한다. malformed
|
||||
surrogate는 replacement 문자로 바꾸지 않고 거부한다.
|
||||
|
||||
adapter 내부 compiler/card/binding 타입은 direct 또는 generic argument 형태의 public instance
|
||||
response surface로 노출하지 않는다.
|
||||
cross-package adapter composition이 정확한 compiled identity를 확인해야 할 때만 public static
|
||||
bridge를 사용하며, instance accessor는 package-private이고 외부에 필요한 상태는 boolean/String/
|
||||
core value로만 제공한다. 이는 broker SDK뿐 아니라 adapter 자체의 조립 타입도 B7 반환 경계를
|
||||
통해 application consumer로 새지 않게 한다.
|
||||
|
||||
`MessagingCapabilityCardRegistry`는 first R2 tuple의 11개 ID와 각 role을 닫힌 Java vocabulary로
|
||||
제공할 뿐이다. readiness registry의 `not-implemented` 상태를 승격하거나 R2/`ACTIVE_READY`를
|
||||
주장하지 않는다. `DISABLED + empty catalog/bindings`는 client, thread, scheduler, network,
|
||||
filesystem resource가 정확히 0인 descriptor다. `ACTIVE`는 empty catalog, missing/duplicate/extra
|
||||
binding, unknown/mismatched card와 ordering/schema/security 완화를 resource 생성 전에 거부한다.
|
||||
|
||||
## Deterministic local JSON Schema encoder candidate
|
||||
|
||||
`envelope` package는 exact final `IntegrationPayload` record만 받아 frozen v1 field order로 UTF-8
|
||||
JSON을 쓴다. `Map`, raw JSON, `JsonNode`, polymorphic typing, custom serializer, assignable search는
|
||||
입력 경로에 없다. contract compiler가 선언된 generic type graph를 scalar/enum,
|
||||
`Optional<T>`/`List<T>`/exact final nested record로만 재귀 고정하며 `Object`, interface, raw/wildcard,
|
||||
generic record와 runtime record discovery를 거부한다. writer는 이 compiled graph를 따라서 각
|
||||
record accessor를 정확히 한 번 읽은 immutable snapshot을 만들고 payload를 정확히 한 번 encode한다.
|
||||
schema validation에 건넨 바로 그 payload bytes를 raw JSON API 없이 envelope suffix에 삽입한다.
|
||||
depth, string character/UTF-8 byte, array, object, number, payload/envelope byte limit는 writer와
|
||||
parser 양쪽에서 적용한다. output byte limit는 generator가 쓰는 bounded stream에서 allocation 전에
|
||||
강제하고, list는 size를 먼저 검사한 뒤 bounded iterator로 한 번만 snapshot한다. mutation/concurrent
|
||||
access와 극단적 `BigDecimal` scale도 큰 배열/String 생성 전에 fail-closed 한다. duplicate key,
|
||||
malformed UTF-8, trailing data, unpaired surrogate와 non-finite number는 replacement나 coercion
|
||||
없이 거부한다.
|
||||
|
||||
`LocalJsonSchemaRegistry`는 호출자가 명시적으로 제공한 exact bytes와 SHA-256만 startup에서
|
||||
Draft 2020-12 meta-schema로 검사하고 precompile한다. root `$id`와 absolute `$ref`는 lowercase
|
||||
exact `urn` scheme만 허용하고 `$ref`는 동일 문서 fragment 또는 제공된 exact URN map으로 닫힌다.
|
||||
HTTP/HTTPS/file/classpath/resource/jar 및 unknown absolute scheme fetch와 YAML loader는 제공하지
|
||||
않는다. public validation boundary도 UTF-8 bytes만 받는다. format assertion은 활성이다. 이 Task 6
|
||||
closed subset은 scope 추적의 모호성을 제거하기 위해 root 외 nested `$id`를 value type과 무관하게
|
||||
거부하고, `$dynamicRef`, `$dynamicAnchor`, deprecated `$recursiveRef`/`$recursiveAnchor`, `$anchor`
|
||||
키워드를 어느 위치에서도 지원하지 않는다.
|
||||
|
||||
Draft 2020-12 authority는 `draft/2020-12/schema` 1개와 `meta/*` 8개의 exact checked-in bytes 및
|
||||
digest로 pin한다. registry startup은 9개 digest, 각 `$id`, NetworkNT runtime schema tree를
|
||||
대조하고 하나라도 다르면 fail-closed 한다. Spring Boot executable/fat/nested JAR 배치를 깨뜨리는
|
||||
`CodeSource` regular-file/JAR 가정은 하지 않는다. NetworkNT 3.0.2 artifact provenance는 strict
|
||||
Gradle dependency lock과 `verifyJsonSchemaRuntimeGraph`가 담당한다. 이 검증은 business schema
|
||||
registry나 runtime remote resolution 경로를 넓히지 않는다.
|
||||
|
||||
공통 build evidence manifest는 수동 구조 검사만으로 PASS하지 않는다. test/build 전용
|
||||
`MessagingEvidenceManifestSchemaValidator`가 exact common schema와 생성된 manifest bytes를 같은
|
||||
pinned NetworkNT/`LocalJsonSchemaRegistry`로 Draft 2020-12 검증하며, common schema 자체도
|
||||
meta-schema 검증을 통과해야 한다. `verifyMessagingContracts`는 JSON-only qualification과 그
|
||||
manifest schema validation을 명시적으로 선행해 CLI task 나열 순서와 무관하게 combined manifest가
|
||||
`build/messaging-evidence/contracts-schema/manifest.json`의 최종 소유자가 된다.
|
||||
|
||||
NetworkNT 3.0.2의 adopted regex 구현은 JDK regex 실행시간을 강제 중단시키는 별도 engine/timeout을
|
||||
제공하지 않는다. 따라서 현재 후보는 regex 입력 길이와 전체 document 구조를 먼저 제한하고,
|
||||
pathological pattern의 작은 repository corpus를 회귀 테스트한다. 이는 JSON Schema Test
|
||||
Suite/Bowtie 전체 호환이나 hostile regex 시간 상한 증명이 아니며 evidence의
|
||||
`regex-engine-timeout`, `consumer-compatibility-full-suite` unsupported claim으로 남는다. 테스트
|
||||
runtime은 remote corpus를 내려받지 않는다.
|
||||
|
||||
이 Task의 acceptance는 **deterministic local wire contract candidate**다. encoder/catalog는 기존
|
||||
`MessagingConfig`, runtime append, `KafkaSender` 또는 legacy broker selection에 연결하지 않았다.
|
||||
Kafka ACK, durable outbox R2, external topic attestation은 아직 구현되지 않았고 R0 runtime authority와
|
||||
동작은 그대로다.
|
||||
|
||||
@@ -4,7 +4,63 @@ dependencies {
|
||||
implementation project(':adapter:outbound:support')
|
||||
|
||||
implementation 'org.springframework.boot:spring-boot-autoconfigure'
|
||||
implementation 'org.springframework.boot:spring-boot-starter-json'
|
||||
implementation('com.networknt:json-schema-validator:3.0.2') {
|
||||
exclude group: 'com.fasterxml.jackson.dataformat', module: 'jackson-dataformat-yaml'
|
||||
}
|
||||
implementation 'org.slf4j:slf4j-api'
|
||||
annotationProcessor 'org.springframework.boot:spring-boot-configuration-processor'
|
||||
}
|
||||
tasks.withType(JavaCompile).configureEach { options.encoding = 'UTF-8' }
|
||||
tasks.withType(Test).configureEach {
|
||||
systemProperty 'messaging.commonEvidenceSchema',
|
||||
rootProject.file('config/messaging/evidence/build-evidence-manifest-v1.schema.json')
|
||||
.absolutePath
|
||||
}
|
||||
|
||||
configurations.configureEach {
|
||||
exclude group: 'tools.jackson.dataformat', module: 'jackson-dataformat-yaml'
|
||||
exclude group: 'org.yaml', module: 'snakeyaml'
|
||||
exclude group: 'org.snakeyaml', module: 'snakeyaml-engine'
|
||||
}
|
||||
|
||||
tasks.register('verifyJsonSchemaRuntimeGraph') {
|
||||
group = 'verification'
|
||||
description = 'Verifies the closed Jackson 3 / NetworkNT graph contains no YAML or Jackson 2 runtime.'
|
||||
doLast {
|
||||
Set<String> modules = configurations.runtimeClasspath.incoming.resolutionResult
|
||||
.allComponents
|
||||
.findAll { it.moduleVersion != null }
|
||||
.collect {
|
||||
"${it.moduleVersion.group}:${it.moduleVersion.name}:${it.moduleVersion.version}"
|
||||
.toString()
|
||||
} as Set
|
||||
List<String> forbidden = modules.findAll { String coordinate ->
|
||||
String lowered = coordinate.toLowerCase(Locale.ROOT)
|
||||
lowered.contains('yaml') ||
|
||||
lowered.startsWith('org.yaml:') ||
|
||||
lowered.startsWith('org.snakeyaml:') ||
|
||||
lowered ==~ /com\.fasterxml\.jackson\.core:jackson-(core|databind):.*/
|
||||
}.sort()
|
||||
if (!forbidden.isEmpty()) {
|
||||
throw new GradleException(
|
||||
"Messaging JSON runtime contains forbidden Jackson 2/YAML modules: ${forbidden}")
|
||||
}
|
||||
[
|
||||
'com.networknt:json-schema-validator:3.0.2',
|
||||
'tools.jackson.core:jackson-core:3.0.2',
|
||||
'tools.jackson.core:jackson-databind:3.0.2'
|
||||
].each { String required ->
|
||||
if (!modules.contains(required)) {
|
||||
throw new GradleException(
|
||||
"Messaging JSON runtime is missing required locked module ${required}")
|
||||
}
|
||||
}
|
||||
// Jackson 3 intentionally retains the 2.x-namespace annotations artifact. It is not a
|
||||
// Jackson 2 databind/runtime engine and is part of the official Jackson 3 BOM graph.
|
||||
}
|
||||
}
|
||||
|
||||
tasks.named('check') {
|
||||
dependsOn tasks.named('verifyJsonSchemaRuntimeGraph')
|
||||
}
|
||||
|
||||
@@ -1,23 +1,24 @@
|
||||
# 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=testCompileClasspath
|
||||
ch.qos.logback:logback-classic:1.5.21=testCompileClasspath,testRuntimeClasspath
|
||||
ch.qos.logback:logback-core:1.5.21=testCompileClasspath,testRuntimeClasspath
|
||||
com.fasterxml.jackson.core:jackson-annotations:2.20=testCompileClasspath,testRuntimeClasspath
|
||||
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.ethlo.time:itu:1.14.0=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
|
||||
com.github.spotbugs:spotbugs-annotations:4.10.2=spotbugs
|
||||
com.github.spotbugs:spotbugs-annotations:4.8.6=testCompileClasspath
|
||||
com.github.spotbugs:spotbugs-annotations:4.8.6=compileClasspath,testCompileClasspath
|
||||
com.github.spotbugs:spotbugs:4.10.2=spotbugs
|
||||
com.github.stephenc.jcip:jcip-annotations:1.0-1=spotbugs
|
||||
com.google.auto.service:auto-service-annotations:1.0.1=annotationProcessor,testAnnotationProcessor
|
||||
com.google.auto.value:auto-value-annotations:1.9=annotationProcessor,testAnnotationProcessor
|
||||
com.google.auto:auto-common:1.2.2=annotationProcessor,testAnnotationProcessor
|
||||
com.google.code.findbugs:jsr305:3.0.2=checkstyle,spotbugs,testCompileClasspath
|
||||
com.google.code.findbugs:jsr305:3.0.2=checkstyle,compileClasspath,spotbugs,testCompileClasspath
|
||||
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=testCompileClasspath
|
||||
com.google.errorprone:error_prone_annotations:2.38.0=compileClasspath,testCompileClasspath
|
||||
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
|
||||
@@ -32,6 +33,7 @@ com.google.j2objc:j2objc-annotations:3.1=annotationProcessor,checkstyle,testAnno
|
||||
com.google.protobuf:protobuf-java:4.33.2=annotationProcessor,testAnnotationProcessor
|
||||
com.h3xstream.findsecbugs:findsecbugs-plugin:1.14.0=spotbugsPlugins
|
||||
com.jayway.jsonpath:json-path:2.9.0=testCompileClasspath,testRuntimeClasspath
|
||||
com.networknt:json-schema-validator:3.0.2=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
com.puppycrawl.tools:checkstyle:13.5.0=checkstyle
|
||||
com.vaadin.external.google:android-json:0.0.20131108.vaadin1=testCompileClasspath,testRuntimeClasspath
|
||||
commons-beanutils:commons-beanutils:1.11.0=checkstyle
|
||||
@@ -44,7 +46,7 @@ io.github.java-diff-utils:java-diff-utils:4.12=annotationProcessor,testAnnotatio
|
||||
io.micrometer:micrometer-commons:1.16.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
io.micrometer:micrometer-observation:1.16.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
jakarta.activation:jakarta.activation-api:2.1.4=testCompileClasspath,testRuntimeClasspath
|
||||
jakarta.annotation:jakarta.annotation-api:3.0.0=testCompileClasspath,testRuntimeClasspath
|
||||
jakarta.annotation:jakarta.annotation-api:3.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
jakarta.xml.bind:jakarta.xml.bind-api:4.0.4=testCompileClasspath,testRuntimeClasspath
|
||||
javax.inject:javax.inject:1=annotationProcessor,testAnnotationProcessor
|
||||
jaxen:jaxen:2.0.0=spotbugs
|
||||
@@ -60,9 +62,9 @@ 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=spotbugs,testCompileClasspath,testRuntimeClasspath
|
||||
org.apache.logging.log4j:log4j-api:2.25.2=compileClasspath,runtimeClasspath,spotbugs,testCompileClasspath,testRuntimeClasspath
|
||||
org.apache.logging.log4j:log4j-core:2.25.2=spotbugs
|
||||
org.apache.logging.log4j:log4j-to-slf4j:2.25.2=testCompileClasspath,testRuntimeClasspath
|
||||
org.apache.logging.log4j:log4j-to-slf4j:2.25.2=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.apache.maven.doxia:doxia-core:1.12.0=checkstyle
|
||||
org.apache.maven.doxia:doxia-logging-api:1.12.0=checkstyle
|
||||
org.apache.maven.doxia:doxia-module-xdoc:1.12.0=checkstyle
|
||||
@@ -95,10 +97,10 @@ org.mockito:mockito-core:5.20.0=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=testCompileClasspath
|
||||
org.osgi:org.osgi.annotation.versioning:1.1.2=testCompileClasspath
|
||||
org.osgi:org.osgi.resource:1.0.0=testCompileClasspath
|
||||
org.osgi:org.osgi.service.serviceloader:1.0.0=testCompileClasspath
|
||||
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.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
|
||||
@@ -108,26 +110,27 @@ org.ow2.asm:asm:9.7.1=testCompileClasspath,testRuntimeClasspath
|
||||
org.pcollections:pcollections:4.0.1=annotationProcessor,testAnnotationProcessor
|
||||
org.reflections:reflections:0.10.2=checkstyle
|
||||
org.skyscreamer:jsonassert:1.5.3=testCompileClasspath,testRuntimeClasspath
|
||||
org.slf4j:jul-to-slf4j:2.0.17=testCompileClasspath,testRuntimeClasspath
|
||||
org.slf4j:jul-to-slf4j:2.0.17=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.slf4j:slf4j-api:2.0.17=compileClasspath,runtimeClasspath,spotbugs,spotbugsSlf4j,testCompileClasspath,testRuntimeClasspath
|
||||
org.slf4j:slf4j-simple:2.0.17=checkstyle,spotbugsSlf4j
|
||||
org.springframework.boot:spring-boot-autoconfigure:4.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-configuration-processor:4.0.0=annotationProcessor
|
||||
org.springframework.boot:spring-boot-http-client:4.0.0=testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-http-converter:4.0.0=testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-jackson:4.0.0=testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-jackson:4.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-restclient:4.0.0=testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-resttestclient:4.0.0=testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-servlet:4.0.0=testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-starter-jackson-test:4.0.0=testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-starter-jackson:4.0.0=testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-starter-logging:4.0.0=testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-starter-json: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-test:4.0.0=testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-starter-tomcat-runtime:4.0.0=testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-starter-tomcat:4.0.0=testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-starter-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=testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-starter:4.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-test-autoconfigure:4.0.0=testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-test:4.0.0=testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-tomcat:4.0.0=testCompileClasspath,testRuntimeClasspath
|
||||
@@ -145,8 +148,7 @@ org.springframework:spring-web: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=testCompileClasspath,testRuntimeClasspath
|
||||
tools.jackson.core:jackson-core:3.0.2=testCompileClasspath,testRuntimeClasspath
|
||||
tools.jackson.core:jackson-databind:3.0.2=testCompileClasspath,testRuntimeClasspath
|
||||
tools.jackson:jackson-bom:3.0.2=testCompileClasspath,testRuntimeClasspath
|
||||
tools.jackson.core:jackson-core:3.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
|
||||
empty=
|
||||
|
||||
+204
@@ -0,0 +1,204 @@
|
||||
package dev.caskeleton.adapter.outbound.messaging.config;
|
||||
|
||||
import dev.caskeleton.adapter.outbound.messaging.contract.CompiledIntegrationEventContract;
|
||||
import dev.caskeleton.adapter.outbound.messaging.contract.ContractCatalogDigest;
|
||||
import dev.caskeleton.adapter.outbound.messaging.destination.CompiledPublicationBinding;
|
||||
import dev.caskeleton.adapter.outbound.messaging.destination.DestinationBindingCompiler;
|
||||
import dev.caskeleton.adapter.outbound.messaging.destination.DestinationBindingSettings;
|
||||
import dev.caskeleton.application.messaging.contract.Sha256;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/** Immutable R1 local compilation result; it owns no broker client, thread or external resource. */
|
||||
public final class CompiledMessagingDescriptor {
|
||||
|
||||
private final ActivationMode activationMode;
|
||||
private final List<CompiledIntegrationEventContract> contracts;
|
||||
private final List<CompiledPublicationBinding> bindings;
|
||||
private final Sha256 catalogDigest;
|
||||
private final Sha256 settingsDigest;
|
||||
private final Sha256 schemaSetDigest;
|
||||
|
||||
private CompiledMessagingDescriptor(
|
||||
ActivationMode activationMode,
|
||||
List<CompiledIntegrationEventContract> contracts,
|
||||
List<CompiledPublicationBinding> bindings,
|
||||
Sha256 catalogDigest,
|
||||
Sha256 settingsDigest,
|
||||
Sha256 schemaSetDigest) {
|
||||
if (activationMode == null
|
||||
|| contracts == null
|
||||
|| bindings == null
|
||||
|| catalogDigest == null
|
||||
|| settingsDigest == null
|
||||
|| schemaSetDigest == null) {
|
||||
throw new IllegalArgumentException("compiled messaging descriptor fields must not be null");
|
||||
}
|
||||
this.activationMode = activationMode;
|
||||
this.contracts = List.copyOf(contracts);
|
||||
this.bindings = List.copyOf(bindings);
|
||||
this.catalogDigest = catalogDigest;
|
||||
this.settingsDigest = settingsDigest;
|
||||
this.schemaSetDigest = schemaSetDigest;
|
||||
|
||||
if (!catalogDigest.equals(ContractCatalogDigest.compute(this.contracts))) {
|
||||
throw new IllegalArgumentException("catalog digest must match the exact compiled catalog");
|
||||
}
|
||||
Sha256 expectedSchemaSetDigest =
|
||||
new DestinationBindingCompiler().schemaSetDigest(this.contracts);
|
||||
if (!schemaSetDigest.equals(expectedSchemaSetDigest)) {
|
||||
throw new IllegalArgumentException("schema digest must match the exact compiled catalog");
|
||||
}
|
||||
if (activationMode == ActivationMode.DISABLED) {
|
||||
if (!this.contracts.isEmpty() || !this.bindings.isEmpty()) {
|
||||
throw new IllegalArgumentException(
|
||||
"DISABLED descriptor requires an empty catalog and empty bindings");
|
||||
}
|
||||
} else {
|
||||
validateActive(this.contracts, this.bindings, settingsDigest, schemaSetDigest);
|
||||
}
|
||||
}
|
||||
|
||||
public static CompiledMessagingDescriptor compile(
|
||||
ActivationMode mode,
|
||||
List<CompiledIntegrationEventContract> contracts,
|
||||
DestinationBindingSettings settings,
|
||||
MessagingCapabilityCardRegistry cardRegistry) {
|
||||
if (mode == null || contracts == null || settings == null || cardRegistry == null) {
|
||||
throw new IllegalArgumentException("descriptor compiler inputs must not be null");
|
||||
}
|
||||
DestinationBindingCompiler bindingCompiler = new DestinationBindingCompiler();
|
||||
if (mode == ActivationMode.DISABLED) {
|
||||
if (!contracts.isEmpty()
|
||||
|| !DestinationBindingSettings.destinationsOf(settings).isEmpty()
|
||||
|| DestinationBindingSettings.legacyAliasesOf(settings).isPresent()) {
|
||||
throw new IllegalArgumentException(
|
||||
"DISABLED descriptor requires an empty catalog and empty bindings");
|
||||
}
|
||||
return new CompiledMessagingDescriptor(
|
||||
mode,
|
||||
List.of(),
|
||||
List.of(),
|
||||
ContractCatalogDigest.compute(List.of()),
|
||||
bindingCompiler.settingsDigest(settings),
|
||||
bindingCompiler.schemaSetDigest(List.of()));
|
||||
}
|
||||
if (contracts.isEmpty()) {
|
||||
throw new IllegalArgumentException("ACTIVE descriptor rejects an empty catalog");
|
||||
}
|
||||
List<CompiledPublicationBinding> bindings =
|
||||
DestinationBindingCompiler.compileExact(contracts, settings, cardRegistry);
|
||||
return new CompiledMessagingDescriptor(
|
||||
mode,
|
||||
contracts,
|
||||
bindings,
|
||||
ContractCatalogDigest.compute(contracts),
|
||||
bindings.getFirst().settingsDigest(),
|
||||
bindings.getFirst().schemaSetDigest());
|
||||
}
|
||||
|
||||
ActivationMode activationMode() {
|
||||
return activationMode;
|
||||
}
|
||||
|
||||
public boolean active() {
|
||||
return activationMode == ActivationMode.ACTIVE;
|
||||
}
|
||||
|
||||
public boolean disabled() {
|
||||
return activationMode == ActivationMode.DISABLED;
|
||||
}
|
||||
|
||||
List<CompiledIntegrationEventContract> contracts() {
|
||||
return contracts;
|
||||
}
|
||||
|
||||
List<CompiledPublicationBinding> bindings() {
|
||||
return bindings;
|
||||
}
|
||||
|
||||
/** Narrow immutable composition bridge for the exact compiled catalog. */
|
||||
public static List<CompiledIntegrationEventContract> contractsOf(
|
||||
CompiledMessagingDescriptor descriptor) {
|
||||
return requireDescriptor(descriptor).contracts;
|
||||
}
|
||||
|
||||
/** Narrow immutable composition bridge for the exact compiled bindings. */
|
||||
public static List<CompiledPublicationBinding> bindingsOf(
|
||||
CompiledMessagingDescriptor descriptor) {
|
||||
return requireDescriptor(descriptor).bindings;
|
||||
}
|
||||
|
||||
public Sha256 catalogDigest() {
|
||||
return catalogDigest;
|
||||
}
|
||||
|
||||
public Sha256 settingsDigest() {
|
||||
return settingsDigest;
|
||||
}
|
||||
|
||||
public Sha256 schemaSetDigest() {
|
||||
return schemaSetDigest;
|
||||
}
|
||||
|
||||
/** Task 5 is compilation-only and therefore always allocates zero runtime resources. */
|
||||
public int resourceCount() {
|
||||
return 0;
|
||||
}
|
||||
|
||||
private static CompiledMessagingDescriptor requireDescriptor(
|
||||
CompiledMessagingDescriptor descriptor) {
|
||||
if (descriptor == null) {
|
||||
throw new IllegalArgumentException("compiled messaging descriptor must not be null");
|
||||
}
|
||||
return descriptor;
|
||||
}
|
||||
|
||||
private static void validateActive(
|
||||
List<CompiledIntegrationEventContract> contracts,
|
||||
List<CompiledPublicationBinding> bindings,
|
||||
Sha256 settingsDigest,
|
||||
Sha256 schemaSetDigest) {
|
||||
if (contracts.isEmpty()) {
|
||||
throw new IllegalArgumentException("ACTIVE descriptor rejects an empty catalog");
|
||||
}
|
||||
if (contracts.size() != bindings.size()) {
|
||||
throw new IllegalArgumentException(
|
||||
"ACTIVE descriptor requires exactly one binding per contract version");
|
||||
}
|
||||
Map<String, CompiledIntegrationEventContract> contractsByKey = new HashMap<>();
|
||||
for (CompiledIntegrationEventContract contract : contracts) {
|
||||
if (contractsByKey.putIfAbsent(contract.stableKey(), contract) != null) {
|
||||
throw new IllegalArgumentException(
|
||||
"ACTIVE descriptor rejects duplicate contract " + contract.stableKey());
|
||||
}
|
||||
}
|
||||
Map<String, CompiledPublicationBinding> bindingsByKey = new HashMap<>();
|
||||
for (CompiledPublicationBinding binding : bindings) {
|
||||
CompiledIntegrationEventContract bindingContract =
|
||||
CompiledPublicationBinding.contractOf(binding);
|
||||
String stableKey = bindingContract.stableKey();
|
||||
CompiledIntegrationEventContract exactContract = contractsByKey.get(stableKey);
|
||||
if (exactContract == null
|
||||
|| !exactContract.equals(bindingContract)
|
||||
|| bindingsByKey.putIfAbsent(stableKey, binding) != null) {
|
||||
throw new IllegalArgumentException(
|
||||
"ACTIVE descriptor contains a forged, duplicate or extra binding for " + stableKey);
|
||||
}
|
||||
if (!settingsDigest.equals(binding.settingsDigest())
|
||||
|| !schemaSetDigest.equals(binding.schemaSetDigest())) {
|
||||
throw new IllegalArgumentException(
|
||||
"ACTIVE descriptor digest must match every compiled binding");
|
||||
}
|
||||
}
|
||||
if (!bindingsByKey.keySet().equals(contractsByKey.keySet())) {
|
||||
throw new IllegalArgumentException("ACTIVE descriptor is missing an exact contract binding");
|
||||
}
|
||||
}
|
||||
|
||||
public enum ActivationMode {
|
||||
DISABLED,
|
||||
ACTIVE
|
||||
}
|
||||
}
|
||||
+235
@@ -0,0 +1,235 @@
|
||||
package dev.caskeleton.adapter.outbound.messaging.config;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.EnumMap;
|
||||
import java.util.HashMap;
|
||||
import java.util.HashSet;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
|
||||
/** Closed code vocabulary for the first producer-only R2 capability tuple. */
|
||||
public final class MessagingCapabilityCardRegistry {
|
||||
|
||||
private static final List<CapabilityCard> FIRST_R2 =
|
||||
List.of(
|
||||
new CapabilityCard("messaging-outbox-publish.v1", CardRole.SEMANTIC),
|
||||
new CapabilityCard("kafka-spring-acknowledged-idempotent.v1", CardRole.PRODUCER),
|
||||
new CapabilityCard("postgresql-polling-outbox.v2", CardRole.DISPATCH),
|
||||
new CapabilityCard("postgresql-per-record-jit-claim.v1", CardRole.CLAIM),
|
||||
new CapabilityCard("json-schema-envelope.v1", CardRole.SERIALIZATION),
|
||||
new CapabilityCard("external-topic-validated.v1", CardRole.TOPIC),
|
||||
new CapabilityCard("kafka-sasl-ssl-scram-sha-512.v1", CardRole.SECURITY),
|
||||
new CapabilityCard("kafka-compression-none.v1", CardRole.COMPRESSION),
|
||||
new CapabilityCard("per-key-normal-path-sequence-detectable.v1", CardRole.ORDERING),
|
||||
new CapabilityCard("same-postgresql-transaction-resource.v1", CardRole.TRANSACTION),
|
||||
new CapabilityCard(
|
||||
"authenticated-internal-web-disposition.v1", CardRole.OPERATOR_CONTROL));
|
||||
|
||||
private static final Map<String, CardRole> SUPPORTED_ROLES = supportedRoles();
|
||||
|
||||
private final List<CapabilityCard> cards;
|
||||
private final Map<String, CapabilityCard> cardsById;
|
||||
private final ExactSelection selection;
|
||||
|
||||
private MessagingCapabilityCardRegistry(List<CapabilityCard> cards) {
|
||||
this.cards = List.copyOf(cards);
|
||||
this.cardsById = index(cards);
|
||||
this.selection = selection(cards);
|
||||
}
|
||||
|
||||
public static MessagingCapabilityCardRegistry exactFirstR2() {
|
||||
return new MessagingCapabilityCardRegistry(FIRST_R2);
|
||||
}
|
||||
|
||||
/** Static bridge keeps the adapter-local selection type off public instance response surfaces. */
|
||||
public static ExactSelection exactFirstR2Selection() {
|
||||
return selection(FIRST_R2);
|
||||
}
|
||||
|
||||
public static MessagingCapabilityCardRegistry compile(List<CapabilityCard> cards) {
|
||||
if (cards == null) {
|
||||
throw new IllegalArgumentException("capability cards must not be null");
|
||||
}
|
||||
return new MessagingCapabilityCardRegistry(new ArrayList<>(cards));
|
||||
}
|
||||
|
||||
List<CapabilityCard> cards() {
|
||||
return cards;
|
||||
}
|
||||
|
||||
ExactSelection selection() {
|
||||
return selection;
|
||||
}
|
||||
|
||||
/** Selection vocabulary is not readiness evidence and never claims release eligibility. */
|
||||
public boolean claimsReleaseEligibility() {
|
||||
return false;
|
||||
}
|
||||
|
||||
public void validate(ExactSelection candidate) {
|
||||
if (candidate == null) {
|
||||
throw new IllegalArgumentException("capability selection must not be null");
|
||||
}
|
||||
require(candidate.semanticCardId(), CardRole.SEMANTIC);
|
||||
require(candidate.producerCardId(), CardRole.PRODUCER);
|
||||
require(candidate.dispatchCardId(), CardRole.DISPATCH);
|
||||
require(candidate.claimCardId(), CardRole.CLAIM);
|
||||
require(candidate.serializationCardId(), CardRole.SERIALIZATION);
|
||||
require(candidate.topicCardId(), CardRole.TOPIC);
|
||||
require(candidate.securityCardId(), CardRole.SECURITY);
|
||||
require(candidate.compressionCardId(), CardRole.COMPRESSION);
|
||||
require(candidate.orderingCardId(), CardRole.ORDERING);
|
||||
require(candidate.transactionCardId(), CardRole.TRANSACTION);
|
||||
require(candidate.operatorControlCardId(), CardRole.OPERATOR_CONTROL);
|
||||
}
|
||||
|
||||
private void require(String cardId, CardRole expectedRole) {
|
||||
CapabilityCard card = cardsById.get(cardId);
|
||||
if (card == null) {
|
||||
throw new IllegalArgumentException("unknown capability card: " + cardId);
|
||||
}
|
||||
if (card.role() != expectedRole) {
|
||||
throw new IllegalArgumentException(
|
||||
"capability card role mismatch: "
|
||||
+ cardId
|
||||
+ " is "
|
||||
+ card.role()
|
||||
+ ", expected "
|
||||
+ expectedRole);
|
||||
}
|
||||
}
|
||||
|
||||
private static Map<String, CapabilityCard> index(List<CapabilityCard> cards) {
|
||||
Set<String> observedIds = new HashSet<>();
|
||||
for (CapabilityCard card : cards) {
|
||||
if (card != null && !observedIds.add(card.cardId())) {
|
||||
throw new IllegalArgumentException("duplicate capability card: " + card.cardId());
|
||||
}
|
||||
}
|
||||
if (cards.size() != FIRST_R2.size()) {
|
||||
throw new IllegalArgumentException("registry must contain exactly the first R2 card tuple");
|
||||
}
|
||||
Map<String, CapabilityCard> result = new HashMap<>();
|
||||
Set<CardRole> roles = new HashSet<>();
|
||||
for (CapabilityCard card : cards) {
|
||||
if (card == null) {
|
||||
throw new IllegalArgumentException("capability card must not be null");
|
||||
}
|
||||
CardRole supportedRole = SUPPORTED_ROLES.get(card.cardId());
|
||||
if (supportedRole == null) {
|
||||
throw new IllegalArgumentException("unknown capability card: " + card.cardId());
|
||||
}
|
||||
if (supportedRole != card.role()) {
|
||||
throw new IllegalArgumentException("capability card role mismatch: " + card.cardId());
|
||||
}
|
||||
if (result.putIfAbsent(card.cardId(), card) != null) {
|
||||
throw new IllegalArgumentException("duplicate capability card: " + card.cardId());
|
||||
}
|
||||
if (!roles.add(card.role())) {
|
||||
throw new IllegalArgumentException("duplicate capability card role: " + card.role());
|
||||
}
|
||||
}
|
||||
if (!result.keySet().equals(SUPPORTED_ROLES.keySet())) {
|
||||
throw new IllegalArgumentException("registry must contain exactly the first R2 card tuple");
|
||||
}
|
||||
return Map.copyOf(result);
|
||||
}
|
||||
|
||||
private static ExactSelection selection(List<CapabilityCard> cards) {
|
||||
EnumMap<CardRole, String> byRole = new EnumMap<>(CardRole.class);
|
||||
cards.forEach(card -> byRole.put(card.role(), card.cardId()));
|
||||
return new ExactSelection(
|
||||
byRole.get(CardRole.SEMANTIC),
|
||||
byRole.get(CardRole.PRODUCER),
|
||||
byRole.get(CardRole.DISPATCH),
|
||||
byRole.get(CardRole.CLAIM),
|
||||
byRole.get(CardRole.SERIALIZATION),
|
||||
byRole.get(CardRole.TOPIC),
|
||||
byRole.get(CardRole.SECURITY),
|
||||
byRole.get(CardRole.COMPRESSION),
|
||||
byRole.get(CardRole.ORDERING),
|
||||
byRole.get(CardRole.TRANSACTION),
|
||||
byRole.get(CardRole.OPERATOR_CONTROL));
|
||||
}
|
||||
|
||||
private static Map<String, CardRole> supportedRoles() {
|
||||
Map<String, CardRole> result = new HashMap<>();
|
||||
FIRST_R2.forEach(card -> result.put(card.cardId(), card.role()));
|
||||
return Map.copyOf(result);
|
||||
}
|
||||
|
||||
public enum CardRole {
|
||||
SEMANTIC,
|
||||
PRODUCER,
|
||||
DISPATCH,
|
||||
CLAIM,
|
||||
SERIALIZATION,
|
||||
TOPIC,
|
||||
SECURITY,
|
||||
COMPRESSION,
|
||||
ORDERING,
|
||||
TRANSACTION,
|
||||
OPERATOR_CONTROL
|
||||
}
|
||||
|
||||
public static final class CapabilityCard {
|
||||
|
||||
private final String cardId;
|
||||
private final CardRole role;
|
||||
|
||||
public CapabilityCard(String cardId, CardRole role) {
|
||||
if (cardId == null || cardId.isBlank() || role == null) {
|
||||
throw new IllegalArgumentException("capability card id and role must not be blank");
|
||||
}
|
||||
this.cardId = cardId;
|
||||
this.role = role;
|
||||
}
|
||||
|
||||
public String cardId() {
|
||||
return cardId;
|
||||
}
|
||||
|
||||
CardRole role() {
|
||||
return role;
|
||||
}
|
||||
|
||||
public String roleName() {
|
||||
return role.name();
|
||||
}
|
||||
}
|
||||
|
||||
public record ExactSelection(
|
||||
String semanticCardId,
|
||||
String producerCardId,
|
||||
String dispatchCardId,
|
||||
String claimCardId,
|
||||
String serializationCardId,
|
||||
String topicCardId,
|
||||
String securityCardId,
|
||||
String compressionCardId,
|
||||
String orderingCardId,
|
||||
String transactionCardId,
|
||||
String operatorControlCardId) {
|
||||
|
||||
public ExactSelection {
|
||||
String[] selectedIds = {
|
||||
semanticCardId,
|
||||
producerCardId,
|
||||
dispatchCardId,
|
||||
claimCardId,
|
||||
serializationCardId,
|
||||
topicCardId,
|
||||
securityCardId,
|
||||
compressionCardId,
|
||||
orderingCardId,
|
||||
transactionCardId,
|
||||
operatorControlCardId
|
||||
};
|
||||
if (java.util.Arrays.stream(selectedIds)
|
||||
.anyMatch(value -> value == null || value.isBlank())) {
|
||||
throw new IllegalArgumentException("selected capability card ids must not be blank");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+263
@@ -0,0 +1,263 @@
|
||||
package dev.caskeleton.adapter.outbound.messaging.contract;
|
||||
|
||||
import dev.caskeleton.application.messaging.contract.ContractDescriptor;
|
||||
import dev.caskeleton.application.messaging.contract.ContractId;
|
||||
import dev.caskeleton.application.messaging.contract.IntegrationPayload;
|
||||
import dev.caskeleton.application.messaging.contract.SchemaResourceId;
|
||||
import dev.caskeleton.application.messaging.contract.Sha256;
|
||||
import java.lang.reflect.InvocationTargetException;
|
||||
import java.lang.reflect.Method;
|
||||
import java.lang.reflect.Modifier;
|
||||
import java.lang.reflect.RecordComponent;
|
||||
import java.util.HashSet;
|
||||
import java.util.List;
|
||||
|
||||
/** Immutable, provider-neutral result of compiling one exact integration-event contract version. */
|
||||
public final class CompiledIntegrationEventContract {
|
||||
|
||||
private final ContractId contractId;
|
||||
private final int payloadVersion;
|
||||
private final Class<? extends IntegrationPayload> exactPayloadRecordType;
|
||||
private final List<String> canonicalRecordComponentOrder;
|
||||
private final SchemaResourceId payloadSchemaResource;
|
||||
private final Sha256 payloadSchemaHash;
|
||||
private final ContractDescriptor descriptor;
|
||||
private final PayloadShape payloadShape;
|
||||
|
||||
CompiledIntegrationEventContract(
|
||||
ContractId contractId,
|
||||
int payloadVersion,
|
||||
Class<? extends IntegrationPayload> exactPayloadRecordType,
|
||||
List<String> canonicalRecordComponentOrder,
|
||||
SchemaResourceId payloadSchemaResource,
|
||||
Sha256 payloadSchemaHash,
|
||||
ContractDescriptor descriptor) {
|
||||
this(
|
||||
contractId,
|
||||
payloadVersion,
|
||||
exactPayloadRecordType,
|
||||
canonicalRecordComponentOrder,
|
||||
payloadSchemaResource,
|
||||
payloadSchemaHash,
|
||||
descriptor,
|
||||
ContractCatalogCompiler.compilePayloadShape(exactPayloadRecordType));
|
||||
}
|
||||
|
||||
CompiledIntegrationEventContract(
|
||||
ContractId contractId,
|
||||
int payloadVersion,
|
||||
Class<? extends IntegrationPayload> exactPayloadRecordType,
|
||||
List<String> canonicalRecordComponentOrder,
|
||||
SchemaResourceId payloadSchemaResource,
|
||||
Sha256 payloadSchemaHash,
|
||||
ContractDescriptor descriptor,
|
||||
PayloadShape payloadShape) {
|
||||
if (contractId == null
|
||||
|| exactPayloadRecordType == null
|
||||
|| canonicalRecordComponentOrder == null
|
||||
|| payloadSchemaResource == null
|
||||
|| payloadSchemaHash == null
|
||||
|| descriptor == null
|
||||
|| payloadShape == null) {
|
||||
throw new IllegalArgumentException("compiled contract fields must not be null");
|
||||
}
|
||||
if (payloadVersion <= 0) {
|
||||
throw new IllegalArgumentException("payloadVersion must be positive");
|
||||
}
|
||||
List<String> copiedComponentOrder = List.copyOf(canonicalRecordComponentOrder);
|
||||
if (!exactPayloadRecordType.isRecord()
|
||||
|| !Modifier.isFinal(exactPayloadRecordType.getModifiers())
|
||||
|| !IntegrationPayload.class.isAssignableFrom(exactPayloadRecordType)) {
|
||||
throw new IllegalArgumentException(
|
||||
"compiled exact payload type must be a final IntegrationPayload record");
|
||||
}
|
||||
if (copiedComponentOrder.stream()
|
||||
.anyMatch(component -> component == null || component.isBlank())
|
||||
|| new HashSet<>(copiedComponentOrder).size() != copiedComponentOrder.size()) {
|
||||
throw new IllegalArgumentException(
|
||||
"compiled canonical component order must contain unique non-blank names");
|
||||
}
|
||||
List<String> reflectedComponentOrder =
|
||||
java.util.Arrays.stream(exactPayloadRecordType.getRecordComponents())
|
||||
.map(RecordComponent::getName)
|
||||
.toList();
|
||||
if (!copiedComponentOrder.equals(reflectedComponentOrder)) {
|
||||
throw new IllegalArgumentException(
|
||||
"compiled canonical component order must match the exact record type");
|
||||
}
|
||||
if (payloadShape.kind != PayloadKind.RECORD
|
||||
|| payloadShape.exactJavaType != exactPayloadRecordType
|
||||
|| !payloadShape.components.stream()
|
||||
.map(component -> component.name)
|
||||
.toList()
|
||||
.equals(copiedComponentOrder)) {
|
||||
throw new IllegalArgumentException(
|
||||
"compiled payload shape must match the exact root record declaration");
|
||||
}
|
||||
|
||||
this.contractId = contractId;
|
||||
this.payloadVersion = payloadVersion;
|
||||
this.exactPayloadRecordType = exactPayloadRecordType;
|
||||
this.canonicalRecordComponentOrder = copiedComponentOrder;
|
||||
this.payloadSchemaResource = payloadSchemaResource;
|
||||
this.payloadSchemaHash = payloadSchemaHash;
|
||||
this.descriptor = descriptor;
|
||||
this.payloadShape = payloadShape;
|
||||
}
|
||||
|
||||
public ContractId contractId() {
|
||||
return contractId;
|
||||
}
|
||||
|
||||
public int payloadVersion() {
|
||||
return payloadVersion;
|
||||
}
|
||||
|
||||
public Class<? extends IntegrationPayload> exactPayloadRecordType() {
|
||||
return exactPayloadRecordType;
|
||||
}
|
||||
|
||||
public List<String> canonicalRecordComponentOrder() {
|
||||
return canonicalRecordComponentOrder;
|
||||
}
|
||||
|
||||
public SchemaResourceId payloadSchemaResource() {
|
||||
return payloadSchemaResource;
|
||||
}
|
||||
|
||||
public Sha256 payloadSchemaHash() {
|
||||
return payloadSchemaHash;
|
||||
}
|
||||
|
||||
public ContractDescriptor descriptor() {
|
||||
return descriptor;
|
||||
}
|
||||
|
||||
/** Stable semantic identity independent of Java class names and physical destinations. */
|
||||
public String stableKey() {
|
||||
return contractId.value() + ":v" + payloadVersion;
|
||||
}
|
||||
|
||||
/** Adapter-internal bridge used by the deterministic writer without runtime type discovery. */
|
||||
public static PayloadShape payloadShapeOf(CompiledIntegrationEventContract contract) {
|
||||
if (contract == null) {
|
||||
throw new IllegalArgumentException("compiled contract must not be null");
|
||||
}
|
||||
return contract.payloadShape;
|
||||
}
|
||||
|
||||
/**
|
||||
* Invokes the exact accessor frozen by the catalog compiler without exposing reflection types.
|
||||
*/
|
||||
public static Object readPayloadComponentOnce(PayloadComponent component, Object record) {
|
||||
PayloadComponent required = PayloadComponent.requireComponent(component);
|
||||
if (record == null || record.getClass() != required.accessor.getDeclaringClass()) {
|
||||
throw new IllegalArgumentException(
|
||||
"payload component record must match the exact compiled declaring class");
|
||||
}
|
||||
try {
|
||||
return required.accessor.invoke(record);
|
||||
} catch (IllegalAccessException | InvocationTargetException exception) {
|
||||
throw new IllegalArgumentException(
|
||||
"record component accessor could not be invoked: " + required.name, exception);
|
||||
}
|
||||
}
|
||||
|
||||
/** Closed kinds accepted by the contract compiler. */
|
||||
public enum PayloadKind {
|
||||
RECORD,
|
||||
OPTIONAL,
|
||||
LIST,
|
||||
STRING,
|
||||
BOOLEAN,
|
||||
INTEGRAL,
|
||||
DECIMAL,
|
||||
ENUM
|
||||
}
|
||||
|
||||
/** Immutable declared type node. Public only as an adapter-package bridge; it has no mutators. */
|
||||
public static final class PayloadShape {
|
||||
|
||||
private final PayloadKind kind;
|
||||
private final Class<?> exactJavaType;
|
||||
private final List<PayloadComponent> components;
|
||||
private final PayloadShape elementShape;
|
||||
|
||||
PayloadShape(
|
||||
PayloadKind kind,
|
||||
Class<?> exactJavaType,
|
||||
List<PayloadComponent> components,
|
||||
PayloadShape elementShape) {
|
||||
if (kind == null || exactJavaType == null || components == null) {
|
||||
throw new IllegalArgumentException("compiled payload shape fields must not be null");
|
||||
}
|
||||
this.kind = kind;
|
||||
this.exactJavaType = exactJavaType;
|
||||
this.components = List.copyOf(components);
|
||||
this.elementShape = elementShape;
|
||||
}
|
||||
|
||||
public static PayloadKind kindOf(PayloadShape shape) {
|
||||
return requireShape(shape).kind;
|
||||
}
|
||||
|
||||
public static Class<?> exactJavaTypeOf(PayloadShape shape) {
|
||||
return requireShape(shape).exactJavaType;
|
||||
}
|
||||
|
||||
public static List<PayloadComponent> componentsOf(PayloadShape shape) {
|
||||
return requireShape(shape).components;
|
||||
}
|
||||
|
||||
public static PayloadShape elementShapeOf(PayloadShape shape) {
|
||||
PayloadShape required = requireShape(shape);
|
||||
if (required.elementShape == null) {
|
||||
throw new IllegalArgumentException("compiled payload shape has no element shape");
|
||||
}
|
||||
return required.elementShape;
|
||||
}
|
||||
|
||||
private static PayloadShape requireShape(PayloadShape shape) {
|
||||
if (shape == null) {
|
||||
throw new IllegalArgumentException("compiled payload shape must not be null");
|
||||
}
|
||||
return shape;
|
||||
}
|
||||
}
|
||||
|
||||
/** Immutable record component node with its exact compiled accessor and declared child shape. */
|
||||
public static final class PayloadComponent {
|
||||
|
||||
private final String name;
|
||||
private final Method accessor;
|
||||
private final PayloadShape shape;
|
||||
|
||||
PayloadComponent(String name, Method accessor, PayloadShape shape) {
|
||||
if (name == null || name.isBlank() || accessor == null || shape == null) {
|
||||
throw new IllegalArgumentException("compiled payload component fields must not be null");
|
||||
}
|
||||
if (!accessor.trySetAccessible()) {
|
||||
throw new IllegalArgumentException(
|
||||
"compiled payload component accessor must be locally invocable");
|
||||
}
|
||||
this.name = name;
|
||||
this.accessor = accessor;
|
||||
this.shape = shape;
|
||||
}
|
||||
|
||||
public static String nameOf(PayloadComponent component) {
|
||||
return requireComponent(component).name;
|
||||
}
|
||||
|
||||
public static PayloadShape shapeOf(PayloadComponent component) {
|
||||
return requireComponent(component).shape;
|
||||
}
|
||||
|
||||
private static PayloadComponent requireComponent(PayloadComponent component) {
|
||||
if (component == null) {
|
||||
throw new IllegalArgumentException("compiled payload component must not be null");
|
||||
}
|
||||
return component;
|
||||
}
|
||||
}
|
||||
}
|
||||
+274
@@ -0,0 +1,274 @@
|
||||
package dev.caskeleton.adapter.outbound.messaging.contract;
|
||||
|
||||
import dev.caskeleton.application.messaging.contract.ContractDescriptor;
|
||||
import dev.caskeleton.application.messaging.contract.ContractId;
|
||||
import dev.caskeleton.application.messaging.contract.IntegrationEventContractContribution;
|
||||
import dev.caskeleton.application.messaging.contract.IntegrationPayload;
|
||||
import dev.caskeleton.application.messaging.contract.LogicalDestinationId;
|
||||
import dev.caskeleton.application.messaging.contract.SchemaResourceId;
|
||||
import dev.caskeleton.application.messaging.contract.Sha256;
|
||||
import java.lang.reflect.GenericArrayType;
|
||||
import java.lang.reflect.Modifier;
|
||||
import java.lang.reflect.ParameterizedType;
|
||||
import java.lang.reflect.RecordComponent;
|
||||
import java.lang.reflect.Type;
|
||||
import java.lang.reflect.TypeVariable;
|
||||
import java.lang.reflect.WildcardType;
|
||||
import java.math.BigDecimal;
|
||||
import java.math.BigInteger;
|
||||
import java.util.ArrayDeque;
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashMap;
|
||||
import java.util.HashSet;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
import java.util.Set;
|
||||
|
||||
/** Fail-fast compiler for an explicitly supplied, closed integration-event contribution list. */
|
||||
public final class ContractCatalogCompiler {
|
||||
|
||||
List<CompiledIntegrationEventContract> compile(
|
||||
List<? extends IntegrationEventContractContribution<?>> contributions) {
|
||||
if (contributions == null) {
|
||||
throw new IllegalArgumentException("contract contributions must not be null");
|
||||
}
|
||||
|
||||
Set<String> stableKeys = new HashSet<>();
|
||||
Set<SchemaResourceId> schemaResources = new HashSet<>();
|
||||
Set<Class<?>> exactPayloadTypes = new HashSet<>();
|
||||
Map<ContractId, LogicalDestinationId> destinationsByContract = new HashMap<>();
|
||||
List<CompiledIntegrationEventContract> compiled = new ArrayList<>(contributions.size());
|
||||
|
||||
for (IntegrationEventContractContribution<?> contribution : contributions) {
|
||||
if (contribution == null) {
|
||||
throw new IllegalArgumentException("contract contribution must not be null");
|
||||
}
|
||||
compiled.add(
|
||||
compileOne(
|
||||
contribution,
|
||||
stableKeys,
|
||||
schemaResources,
|
||||
exactPayloadTypes,
|
||||
destinationsByContract));
|
||||
}
|
||||
|
||||
compiled.sort(
|
||||
java.util.Comparator.comparing(
|
||||
(CompiledIntegrationEventContract contract) -> contract.contractId().value())
|
||||
.thenComparingInt(CompiledIntegrationEventContract::payloadVersion));
|
||||
return List.copyOf(compiled);
|
||||
}
|
||||
|
||||
/** Narrow immutable composition bridge for callers in sibling messaging adapter packages. */
|
||||
public static List<CompiledIntegrationEventContract> compileExact(
|
||||
List<? extends IntegrationEventContractContribution<?>> contributions) {
|
||||
return new ContractCatalogCompiler().compile(contributions);
|
||||
}
|
||||
|
||||
private static CompiledIntegrationEventContract compileOne(
|
||||
IntegrationEventContractContribution<?> contribution,
|
||||
Set<String> stableKeys,
|
||||
Set<SchemaResourceId> schemaResources,
|
||||
Set<Class<?>> exactPayloadTypes,
|
||||
Map<ContractId, LogicalDestinationId> destinationsByContract) {
|
||||
ContractId contractId = contribution.contractId();
|
||||
int payloadVersion = contribution.payloadVersion();
|
||||
Class<? extends IntegrationPayload> payloadType = contribution.exactPayloadRecordType();
|
||||
List<String> declaredComponentOrder = contribution.canonicalRecordComponentOrder();
|
||||
SchemaResourceId schemaResource = contribution.payloadSchemaResource();
|
||||
Sha256 schemaHash = contribution.payloadSchemaHash();
|
||||
ContractDescriptor descriptor = contribution.descriptor();
|
||||
|
||||
if (contractId == null) {
|
||||
throw new IllegalArgumentException("contractId must not be null");
|
||||
}
|
||||
if (payloadVersion <= 0) {
|
||||
throw new IllegalArgumentException("payload version must be positive");
|
||||
}
|
||||
String stableKey = contractId.value() + ":v" + payloadVersion;
|
||||
if (!stableKeys.add(stableKey)) {
|
||||
throw new IllegalArgumentException("duplicate contract identity: " + stableKey);
|
||||
}
|
||||
|
||||
if (schemaResource == null) {
|
||||
throw new IllegalArgumentException("payload schema resource must not be null");
|
||||
}
|
||||
if (!schemaResources.add(schemaResource)) {
|
||||
throw new IllegalArgumentException("duplicate schema resource: " + schemaResource.value());
|
||||
}
|
||||
if (schemaHash == null) {
|
||||
throw new IllegalArgumentException("payload schema hash must not be null");
|
||||
}
|
||||
|
||||
validateExactPayloadType(payloadType);
|
||||
if (!exactPayloadTypes.add(payloadType)) {
|
||||
throw new IllegalArgumentException(
|
||||
"duplicate exact payload record type: " + payloadType.getName());
|
||||
}
|
||||
|
||||
List<String> componentOrder = validateComponentOrder(payloadType, declaredComponentOrder);
|
||||
CompiledIntegrationEventContract.PayloadShape payloadShape = compilePayloadShape(payloadType);
|
||||
if (descriptor == null) {
|
||||
throw new IllegalArgumentException("contract descriptor must not be null");
|
||||
}
|
||||
LogicalDestinationId logicalDestination = descriptor.logicalDestination();
|
||||
LogicalDestinationId existing =
|
||||
destinationsByContract.putIfAbsent(contractId, logicalDestination);
|
||||
if (existing != null && !existing.equals(logicalDestination)) {
|
||||
throw new IllegalArgumentException(
|
||||
"logical destination drift across versions of contract " + contractId.value());
|
||||
}
|
||||
|
||||
return new CompiledIntegrationEventContract(
|
||||
contractId,
|
||||
payloadVersion,
|
||||
payloadType,
|
||||
componentOrder,
|
||||
schemaResource,
|
||||
schemaHash,
|
||||
descriptor,
|
||||
payloadShape);
|
||||
}
|
||||
|
||||
private static void validateExactPayloadType(Class<? extends IntegrationPayload> payloadType) {
|
||||
if (payloadType == null) {
|
||||
throw new IllegalArgumentException("exact payload record type must not be null");
|
||||
}
|
||||
if (!payloadType.isRecord() || !Modifier.isFinal(payloadType.getModifiers())) {
|
||||
throw new IllegalArgumentException("exact payload type must be a final Java record");
|
||||
}
|
||||
if (!IntegrationPayload.class.isAssignableFrom(payloadType)) {
|
||||
throw new IllegalArgumentException("exact payload record must implement IntegrationPayload");
|
||||
}
|
||||
}
|
||||
|
||||
private static List<String> validateComponentOrder(
|
||||
Class<? extends IntegrationPayload> payloadType, List<String> declared) {
|
||||
if (declared == null) {
|
||||
throw new IllegalArgumentException("canonical record component order must not be null");
|
||||
}
|
||||
List<String> copied = List.copyOf(declared);
|
||||
if (copied.stream().anyMatch(value -> value == null || value.isBlank())
|
||||
|| new HashSet<>(copied).size() != copied.size()) {
|
||||
throw new IllegalArgumentException(
|
||||
"canonical record component order must contain unique non-blank names");
|
||||
}
|
||||
List<String> actual =
|
||||
java.util.Arrays.stream(payloadType.getRecordComponents())
|
||||
.map(RecordComponent::getName)
|
||||
.toList();
|
||||
if (!copied.equals(actual)) {
|
||||
throw new IllegalArgumentException(
|
||||
"canonical record component order must exactly match record declaration order");
|
||||
}
|
||||
return copied;
|
||||
}
|
||||
|
||||
static CompiledIntegrationEventContract.PayloadShape compilePayloadShape(
|
||||
Class<? extends IntegrationPayload> payloadType) {
|
||||
validateExactPayloadType(payloadType);
|
||||
return compileDeclaredType(payloadType, new ArrayDeque<>());
|
||||
}
|
||||
|
||||
private static CompiledIntegrationEventContract.PayloadShape compileDeclaredType(
|
||||
Type declaredType, ArrayDeque<Class<?>> recordStack) {
|
||||
if (declaredType instanceof WildcardType
|
||||
|| declaredType instanceof TypeVariable<?>
|
||||
|| declaredType instanceof GenericArrayType) {
|
||||
throw unsupportedDeclaredType(declaredType);
|
||||
}
|
||||
if (declaredType instanceof ParameterizedType parameterized) {
|
||||
Type rawType = parameterized.getRawType();
|
||||
Type[] arguments = parameterized.getActualTypeArguments();
|
||||
if (arguments.length != 1 || !(rawType instanceof Class<?> rawClass)) {
|
||||
throw unsupportedDeclaredType(declaredType);
|
||||
}
|
||||
if (rawClass == Optional.class) {
|
||||
return new CompiledIntegrationEventContract.PayloadShape(
|
||||
CompiledIntegrationEventContract.PayloadKind.OPTIONAL,
|
||||
Optional.class,
|
||||
List.of(),
|
||||
compileDeclaredType(arguments[0], recordStack));
|
||||
}
|
||||
if (rawClass == List.class) {
|
||||
return new CompiledIntegrationEventContract.PayloadShape(
|
||||
CompiledIntegrationEventContract.PayloadKind.LIST,
|
||||
List.class,
|
||||
List.of(),
|
||||
compileDeclaredType(arguments[0], recordStack));
|
||||
}
|
||||
throw unsupportedDeclaredType(declaredType);
|
||||
}
|
||||
if (!(declaredType instanceof Class<?> declaredClass)
|
||||
|| declaredClass.isArray()
|
||||
|| declaredClass.getTypeParameters().length != 0) {
|
||||
throw unsupportedDeclaredType(declaredType);
|
||||
}
|
||||
|
||||
CompiledIntegrationEventContract.PayloadKind scalarKind = scalarKind(declaredClass);
|
||||
if (scalarKind != null) {
|
||||
return new CompiledIntegrationEventContract.PayloadShape(
|
||||
scalarKind, declaredClass, List.of(), null);
|
||||
}
|
||||
if (declaredClass == Optional.class || declaredClass == List.class) {
|
||||
throw new IllegalArgumentException(
|
||||
"raw Optional/List payload component types are unsupported");
|
||||
}
|
||||
if (!declaredClass.isRecord() || !Modifier.isFinal(declaredClass.getModifiers())) {
|
||||
throw unsupportedDeclaredType(declaredType);
|
||||
}
|
||||
if (recordStack.contains(declaredClass)) {
|
||||
throw new IllegalArgumentException("cyclic declared payload record graph is unsupported");
|
||||
}
|
||||
|
||||
recordStack.addLast(declaredClass);
|
||||
List<CompiledIntegrationEventContract.PayloadComponent> components = new ArrayList<>();
|
||||
for (RecordComponent component : declaredClass.getRecordComponents()) {
|
||||
components.add(
|
||||
new CompiledIntegrationEventContract.PayloadComponent(
|
||||
component.getName(),
|
||||
component.getAccessor(),
|
||||
compileDeclaredType(component.getGenericType(), recordStack)));
|
||||
}
|
||||
recordStack.removeLast();
|
||||
return new CompiledIntegrationEventContract.PayloadShape(
|
||||
CompiledIntegrationEventContract.PayloadKind.RECORD, declaredClass, components, null);
|
||||
}
|
||||
|
||||
private static CompiledIntegrationEventContract.PayloadKind scalarKind(Class<?> type) {
|
||||
if (type == String.class) {
|
||||
return CompiledIntegrationEventContract.PayloadKind.STRING;
|
||||
}
|
||||
if (type == boolean.class || type == Boolean.class) {
|
||||
return CompiledIntegrationEventContract.PayloadKind.BOOLEAN;
|
||||
}
|
||||
if (type == byte.class
|
||||
|| type == Byte.class
|
||||
|| type == short.class
|
||||
|| type == Short.class
|
||||
|| type == int.class
|
||||
|| type == Integer.class
|
||||
|| type == long.class
|
||||
|| type == Long.class
|
||||
|| type == BigInteger.class) {
|
||||
return CompiledIntegrationEventContract.PayloadKind.INTEGRAL;
|
||||
}
|
||||
if (type == float.class
|
||||
|| type == Float.class
|
||||
|| type == double.class
|
||||
|| type == Double.class
|
||||
|| type == BigDecimal.class) {
|
||||
return CompiledIntegrationEventContract.PayloadKind.DECIMAL;
|
||||
}
|
||||
if (type.isEnum()) {
|
||||
return CompiledIntegrationEventContract.PayloadKind.ENUM;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private static IllegalArgumentException unsupportedDeclaredType(Type type) {
|
||||
return new IllegalArgumentException(
|
||||
"unsupported declared payload component type: " + type.getTypeName());
|
||||
}
|
||||
}
|
||||
+91
@@ -0,0 +1,91 @@
|
||||
package dev.caskeleton.adapter.outbound.messaging.contract;
|
||||
|
||||
import dev.caskeleton.application.messaging.contract.ContractDescriptor;
|
||||
import dev.caskeleton.application.messaging.contract.Sha256;
|
||||
import java.nio.ByteBuffer;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.security.MessageDigest;
|
||||
import java.security.NoSuchAlgorithmException;
|
||||
import java.util.Comparator;
|
||||
import java.util.List;
|
||||
|
||||
/** Stable SHA-256 digest of closed contract semantics, independent of caller collection order. */
|
||||
public final class ContractCatalogDigest {
|
||||
|
||||
private static final byte[] DOMAIN =
|
||||
"ca-skeleton.messaging.contract-catalog-digest.v1".getBytes(StandardCharsets.UTF_8);
|
||||
|
||||
private ContractCatalogDigest() {}
|
||||
|
||||
public static Sha256 compute(List<CompiledIntegrationEventContract> contracts) {
|
||||
if (contracts == null || contracts.stream().anyMatch(java.util.Objects::isNull)) {
|
||||
throw new IllegalArgumentException("compiled contracts must not be null");
|
||||
}
|
||||
MessageDigest digest = sha256();
|
||||
digest.update(DOMAIN);
|
||||
digest.update((byte) 0);
|
||||
|
||||
contracts.stream()
|
||||
.sorted(
|
||||
Comparator.comparing(
|
||||
(CompiledIntegrationEventContract contract) -> contract.contractId().value())
|
||||
.thenComparingInt(CompiledIntegrationEventContract::payloadVersion))
|
||||
.forEach(contract -> updateContract(digest, contract));
|
||||
return new Sha256(digest.digest());
|
||||
}
|
||||
|
||||
private static void updateContract(
|
||||
MessageDigest digest, CompiledIntegrationEventContract contract) {
|
||||
field(digest, "contract", contract.stableKey());
|
||||
field(digest, "contractId", contract.contractId().value());
|
||||
field(digest, "payloadVersion", Integer.toString(contract.payloadVersion()));
|
||||
field(digest, "payloadType", contract.exactPayloadRecordType().getName());
|
||||
for (int index = 0; index < contract.canonicalRecordComponentOrder().size(); index++) {
|
||||
field(
|
||||
digest, "component[" + index + "]", contract.canonicalRecordComponentOrder().get(index));
|
||||
}
|
||||
field(digest, "schemaResource", contract.payloadSchemaResource().value());
|
||||
rawField(digest, "schemaHash", contract.payloadSchemaHash().bytes());
|
||||
|
||||
ContractDescriptor descriptor = contract.descriptor();
|
||||
field(digest, "ownerModule", descriptor.ownerModule());
|
||||
field(digest, "logicalDestination", descriptor.logicalDestination().value());
|
||||
field(digest, "serializerId", descriptor.serializerId());
|
||||
field(digest, "orderingRequired", Boolean.toString(descriptor.orderingRequired()));
|
||||
field(digest, "maximumPayloadBytes", Integer.toString(descriptor.maximumPayloadBytes()));
|
||||
field(digest, "maximumEnvelopeBytes", Integer.toString(descriptor.maximumEnvelopeBytes()));
|
||||
field(digest, "sensitivity", descriptor.sensitivityClassification().name());
|
||||
field(
|
||||
digest,
|
||||
"requeueHorizonSeconds",
|
||||
Long.toString(descriptor.sameEventRequeueHorizon().toSeconds()));
|
||||
field(
|
||||
digest,
|
||||
"requeueHorizonNanos",
|
||||
Integer.toString(descriptor.sameEventRequeueHorizon().toNanosPart()));
|
||||
}
|
||||
|
||||
private static void field(MessageDigest digest, String tag, String value) {
|
||||
rawField(digest, tag, value.getBytes(StandardCharsets.UTF_8));
|
||||
}
|
||||
|
||||
private static void rawField(MessageDigest digest, String tag, byte[] value) {
|
||||
byte[] tagBytes = tag.getBytes(StandardCharsets.UTF_8);
|
||||
digest.update(unsignedLength(tagBytes.length));
|
||||
digest.update(tagBytes);
|
||||
digest.update(unsignedLength(value.length));
|
||||
digest.update(value);
|
||||
}
|
||||
|
||||
private static byte[] unsignedLength(int length) {
|
||||
return ByteBuffer.allocate(Integer.BYTES).putInt(length).array();
|
||||
}
|
||||
|
||||
private static MessageDigest sha256() {
|
||||
try {
|
||||
return MessageDigest.getInstance("SHA-256");
|
||||
} catch (NoSuchAlgorithmException exception) {
|
||||
throw new IllegalStateException("Java runtime does not provide SHA-256", exception);
|
||||
}
|
||||
}
|
||||
}
|
||||
+105
@@ -0,0 +1,105 @@
|
||||
package dev.caskeleton.adapter.outbound.messaging.destination;
|
||||
|
||||
import dev.caskeleton.adapter.outbound.messaging.config.MessagingCapabilityCardRegistry;
|
||||
import dev.caskeleton.adapter.outbound.messaging.contract.CompiledIntegrationEventContract;
|
||||
import dev.caskeleton.application.messaging.contract.LogicalDestinationId;
|
||||
import dev.caskeleton.application.messaging.contract.Sha256;
|
||||
import java.util.HashSet;
|
||||
import java.util.List;
|
||||
|
||||
/** Immutable exact contract/version to physical deployment binding compiled without I/O. */
|
||||
public final class CompiledPublicationBinding {
|
||||
|
||||
private final CompiledIntegrationEventContract contract;
|
||||
private final LogicalDestinationId logicalDestination;
|
||||
private final String physicalTopic;
|
||||
private final List<String> bootstrapServers;
|
||||
private final int effectiveMaximumRecordBytes;
|
||||
private final MessagingCapabilityCardRegistry.ExactSelection selectedCards;
|
||||
private final Sha256 settingsDigest;
|
||||
private final Sha256 schemaSetDigest;
|
||||
|
||||
CompiledPublicationBinding(
|
||||
CompiledIntegrationEventContract contract,
|
||||
LogicalDestinationId logicalDestination,
|
||||
String physicalTopic,
|
||||
List<String> bootstrapServers,
|
||||
int effectiveMaximumRecordBytes,
|
||||
MessagingCapabilityCardRegistry.ExactSelection selectedCards,
|
||||
Sha256 settingsDigest,
|
||||
Sha256 schemaSetDigest) {
|
||||
if (contract == null
|
||||
|| logicalDestination == null
|
||||
|| !logicalDestination.equals(contract.descriptor().logicalDestination())
|
||||
|| !DestinationBindingSettings.DestinationBinding.validPhysicalTopic(physicalTopic)
|
||||
|| bootstrapServers == null
|
||||
|| bootstrapServers.isEmpty()
|
||||
|| bootstrapServers.stream()
|
||||
.anyMatch(
|
||||
server ->
|
||||
server == null
|
||||
|| !DestinationBindingSettings.DestinationBinding.validBootstrapServer(
|
||||
server))
|
||||
|| new HashSet<>(bootstrapServers).size() != bootstrapServers.size()
|
||||
|| effectiveMaximumRecordBytes <= 0
|
||||
|| effectiveMaximumRecordBytes > contract.descriptor().maximumEnvelopeBytes()
|
||||
|| selectedCards == null
|
||||
|| settingsDigest == null
|
||||
|| schemaSetDigest == null) {
|
||||
throw new IllegalArgumentException("compiled publication binding fields must be canonical");
|
||||
}
|
||||
MessagingCapabilityCardRegistry.exactFirstR2().validate(selectedCards);
|
||||
this.contract = contract;
|
||||
this.logicalDestination = logicalDestination;
|
||||
this.physicalTopic = physicalTopic;
|
||||
this.bootstrapServers = List.copyOf(bootstrapServers);
|
||||
this.effectiveMaximumRecordBytes = effectiveMaximumRecordBytes;
|
||||
this.selectedCards = selectedCards;
|
||||
this.settingsDigest = settingsDigest;
|
||||
this.schemaSetDigest = schemaSetDigest;
|
||||
}
|
||||
|
||||
CompiledIntegrationEventContract contract() {
|
||||
return contract;
|
||||
}
|
||||
|
||||
/** Static bridge for adapter-internal composition without an instance response surface. */
|
||||
public static CompiledIntegrationEventContract contractOf(CompiledPublicationBinding binding) {
|
||||
if (binding == null) {
|
||||
throw new IllegalArgumentException("compiled publication binding must not be null");
|
||||
}
|
||||
return binding.contract;
|
||||
}
|
||||
|
||||
public LogicalDestinationId logicalDestination() {
|
||||
return logicalDestination;
|
||||
}
|
||||
|
||||
public String physicalTopic() {
|
||||
return physicalTopic;
|
||||
}
|
||||
|
||||
public List<String> bootstrapServers() {
|
||||
return bootstrapServers;
|
||||
}
|
||||
|
||||
public int effectiveMaximumRecordBytes() {
|
||||
return effectiveMaximumRecordBytes;
|
||||
}
|
||||
|
||||
MessagingCapabilityCardRegistry.ExactSelection selectedCards() {
|
||||
return selectedCards;
|
||||
}
|
||||
|
||||
public Sha256 settingsDigest() {
|
||||
return settingsDigest;
|
||||
}
|
||||
|
||||
public Sha256 schemaSetDigest() {
|
||||
return schemaSetDigest;
|
||||
}
|
||||
|
||||
public String stableKey() {
|
||||
return contract.stableKey() + "@" + logicalDestination.value();
|
||||
}
|
||||
}
|
||||
+248
@@ -0,0 +1,248 @@
|
||||
package dev.caskeleton.adapter.outbound.messaging.destination;
|
||||
|
||||
import dev.caskeleton.adapter.outbound.messaging.config.MessagingCapabilityCardRegistry;
|
||||
import dev.caskeleton.adapter.outbound.messaging.contract.CompiledIntegrationEventContract;
|
||||
import dev.caskeleton.application.messaging.contract.LogicalDestinationId;
|
||||
import dev.caskeleton.application.messaging.contract.Sha256;
|
||||
import java.nio.ByteBuffer;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.security.MessageDigest;
|
||||
import java.security.NoSuchAlgorithmException;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Comparator;
|
||||
import java.util.HashMap;
|
||||
import java.util.HashSet;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
|
||||
/** Pure local compiler joining exact contracts, deployment destinations and closed cards. */
|
||||
public final class DestinationBindingCompiler {
|
||||
|
||||
List<CompiledPublicationBinding> compile(
|
||||
List<CompiledIntegrationEventContract> contracts,
|
||||
DestinationBindingSettings settings,
|
||||
MessagingCapabilityCardRegistry cardRegistry) {
|
||||
if (contracts == null || settings == null || cardRegistry == null) {
|
||||
throw new IllegalArgumentException("binding compiler inputs must not be null");
|
||||
}
|
||||
if (contracts.stream().anyMatch(java.util.Objects::isNull)) {
|
||||
throw new IllegalArgumentException("compiled contracts must not contain null");
|
||||
}
|
||||
cardRegistry.validate(settings.selectedCards());
|
||||
validateRequiredProfiles(settings);
|
||||
if (!settings.destinations().isEmpty() && settings.legacyAliases().isPresent()) {
|
||||
throw new IllegalArgumentException(
|
||||
"legacy and canonical destination configuration must not be simultaneous");
|
||||
}
|
||||
|
||||
Map<String, CompiledIntegrationEventContract> contractsByKey = indexContracts(contracts);
|
||||
Map<LogicalDestinationId, DestinationBindingSettings.DestinationBinding> destinations =
|
||||
indexDestinations(settings.destinations());
|
||||
Map<String, DestinationBindingSettings.DestinationBinding> bindingByContract =
|
||||
indexContractBindings(destinations.values(), contractsByKey);
|
||||
|
||||
for (CompiledIntegrationEventContract contract : contracts) {
|
||||
if (!bindingByContract.containsKey(contract.stableKey())) {
|
||||
throw new IllegalArgumentException(
|
||||
"missing exact destination binding for contract " + contract.stableKey());
|
||||
}
|
||||
}
|
||||
|
||||
Sha256 settingsDigest = settingsDigest(settings);
|
||||
Sha256 schemaSetDigest = schemaSetDigest(contracts);
|
||||
List<CompiledPublicationBinding> result = new ArrayList<>(contracts.size());
|
||||
contracts.stream()
|
||||
.sorted(Comparator.comparing(CompiledIntegrationEventContract::stableKey))
|
||||
.forEach(
|
||||
contract -> {
|
||||
DestinationBindingSettings.DestinationBinding destination =
|
||||
bindingByContract.get(contract.stableKey());
|
||||
int effectiveMaximumRecordBytes =
|
||||
Math.min(
|
||||
contract.descriptor().maximumEnvelopeBytes(),
|
||||
destination.maximumRecordBytes());
|
||||
if (effectiveMaximumRecordBytes <= 0) {
|
||||
throw new IllegalArgumentException(
|
||||
"effective maximum record bytes must be positive");
|
||||
}
|
||||
result.add(
|
||||
new CompiledPublicationBinding(
|
||||
contract,
|
||||
destination.logicalDestination(),
|
||||
destination.physicalTopic(),
|
||||
destination.bootstrapServers(),
|
||||
effectiveMaximumRecordBytes,
|
||||
settings.selectedCards(),
|
||||
settingsDigest,
|
||||
schemaSetDigest));
|
||||
});
|
||||
return List.copyOf(result);
|
||||
}
|
||||
|
||||
/** Narrow immutable composition bridge for callers in sibling messaging adapter packages. */
|
||||
public static List<CompiledPublicationBinding> compileExact(
|
||||
List<CompiledIntegrationEventContract> contracts,
|
||||
DestinationBindingSettings settings,
|
||||
MessagingCapabilityCardRegistry cardRegistry) {
|
||||
return new DestinationBindingCompiler().compile(contracts, settings, cardRegistry);
|
||||
}
|
||||
|
||||
public Sha256 settingsDigest(DestinationBindingSettings settings) {
|
||||
if (settings == null) {
|
||||
throw new IllegalArgumentException("destination settings must not be null");
|
||||
}
|
||||
MessageDigest digest = digest("ca-skeleton.messaging.destination-settings-digest.v1");
|
||||
field(digest, "orderingRequired", Boolean.toString(settings.orderingRequired()));
|
||||
field(
|
||||
digest, "schemaValidationRequired", Boolean.toString(settings.schemaValidationRequired()));
|
||||
field(digest, "secureTransportRequired", Boolean.toString(settings.secureTransportRequired()));
|
||||
updateSelection(digest, settings.selectedCards());
|
||||
settings.destinations().stream()
|
||||
.sorted(Comparator.comparing(destination -> destination.logicalDestination().value()))
|
||||
.forEach(destination -> updateDestination(digest, destination));
|
||||
settings
|
||||
.legacyAliases()
|
||||
.ifPresent(
|
||||
legacy -> {
|
||||
field(digest, "legacyBroker", legacy.brokerAlias().orElse(""));
|
||||
field(digest, "legacyTopic", legacy.topicAlias().orElse(""));
|
||||
legacy.bootstrapServerAliases().stream()
|
||||
.sorted()
|
||||
.forEach(value -> field(digest, "legacyBootstrap", value));
|
||||
});
|
||||
return new Sha256(digest.digest());
|
||||
}
|
||||
|
||||
public Sha256 schemaSetDigest(List<CompiledIntegrationEventContract> contracts) {
|
||||
if (contracts == null || contracts.stream().anyMatch(java.util.Objects::isNull)) {
|
||||
throw new IllegalArgumentException("compiled contracts must not be null");
|
||||
}
|
||||
MessageDigest digest = digest("ca-skeleton.messaging.schema-set-digest.v1");
|
||||
contracts.stream()
|
||||
.sorted(Comparator.comparing(CompiledIntegrationEventContract::stableKey))
|
||||
.forEach(
|
||||
contract -> {
|
||||
field(digest, "contract", contract.stableKey());
|
||||
field(digest, "schemaResource", contract.payloadSchemaResource().value());
|
||||
rawField(digest, "schemaHash", contract.payloadSchemaHash().bytes());
|
||||
});
|
||||
return new Sha256(digest.digest());
|
||||
}
|
||||
|
||||
private static void validateRequiredProfiles(DestinationBindingSettings settings) {
|
||||
if (!settings.orderingRequired()) {
|
||||
throw new IllegalArgumentException("ordering requirement cannot be relaxed");
|
||||
}
|
||||
if (!settings.schemaValidationRequired()) {
|
||||
throw new IllegalArgumentException("schema validation requirement cannot be relaxed");
|
||||
}
|
||||
if (!settings.secureTransportRequired()) {
|
||||
throw new IllegalArgumentException("security requirement cannot be relaxed");
|
||||
}
|
||||
}
|
||||
|
||||
private static Map<String, CompiledIntegrationEventContract> indexContracts(
|
||||
List<CompiledIntegrationEventContract> contracts) {
|
||||
Map<String, CompiledIntegrationEventContract> result = new HashMap<>();
|
||||
for (CompiledIntegrationEventContract contract : contracts) {
|
||||
if (result.putIfAbsent(contract.stableKey(), contract) != null) {
|
||||
throw new IllegalArgumentException("duplicate compiled contract: " + contract.stableKey());
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
private static Map<LogicalDestinationId, DestinationBindingSettings.DestinationBinding>
|
||||
indexDestinations(List<DestinationBindingSettings.DestinationBinding> destinations) {
|
||||
Map<LogicalDestinationId, DestinationBindingSettings.DestinationBinding> result =
|
||||
new HashMap<>();
|
||||
for (DestinationBindingSettings.DestinationBinding destination : destinations) {
|
||||
if (result.putIfAbsent(destination.logicalDestination(), destination) != null) {
|
||||
throw new IllegalArgumentException(
|
||||
"duplicate destination binding: " + destination.logicalDestination().value());
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
private static Map<String, DestinationBindingSettings.DestinationBinding> indexContractBindings(
|
||||
java.util.Collection<DestinationBindingSettings.DestinationBinding> destinations,
|
||||
Map<String, CompiledIntegrationEventContract> contractsByKey) {
|
||||
Map<String, DestinationBindingSettings.DestinationBinding> result = new HashMap<>();
|
||||
Set<LogicalDestinationId> usedDestinations = new HashSet<>();
|
||||
for (DestinationBindingSettings.DestinationBinding destination : destinations) {
|
||||
for (DestinationBindingSettings.ContractVersion reference : destination.contracts()) {
|
||||
CompiledIntegrationEventContract contract = contractsByKey.get(reference.stableKey());
|
||||
if (contract == null) {
|
||||
throw new IllegalArgumentException(
|
||||
"extra contract binding is not in the closed catalog: " + reference.stableKey());
|
||||
}
|
||||
if (!contract.descriptor().logicalDestination().equals(destination.logicalDestination())) {
|
||||
throw new IllegalArgumentException(
|
||||
"contract/destination identity mismatch for " + reference.stableKey());
|
||||
}
|
||||
if (result.putIfAbsent(reference.stableKey(), destination) != null) {
|
||||
throw new IllegalArgumentException(
|
||||
"duplicate exact contract binding: " + reference.stableKey());
|
||||
}
|
||||
usedDestinations.add(destination.logicalDestination());
|
||||
}
|
||||
}
|
||||
if (usedDestinations.size() != destinations.size()) {
|
||||
throw new IllegalArgumentException("extra destination binding has no closed contract");
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
private static void updateDestination(
|
||||
MessageDigest digest, DestinationBindingSettings.DestinationBinding destination) {
|
||||
field(digest, "logicalDestination", destination.logicalDestination().value());
|
||||
destination.contracts().stream()
|
||||
.sorted(Comparator.comparing(DestinationBindingSettings.ContractVersion::stableKey))
|
||||
.forEach(contract -> field(digest, "contract", contract.stableKey()));
|
||||
field(digest, "physicalTopic", destination.physicalTopic());
|
||||
destination.bootstrapServers().stream()
|
||||
.sorted()
|
||||
.forEach(server -> field(digest, "bootstrapServer", server));
|
||||
field(digest, "maximumRecordBytes", Integer.toString(destination.maximumRecordBytes()));
|
||||
}
|
||||
|
||||
private static void updateSelection(
|
||||
MessageDigest digest, MessagingCapabilityCardRegistry.ExactSelection selection) {
|
||||
field(digest, "semanticCard", selection.semanticCardId());
|
||||
field(digest, "producerCard", selection.producerCardId());
|
||||
field(digest, "dispatchCard", selection.dispatchCardId());
|
||||
field(digest, "claimCard", selection.claimCardId());
|
||||
field(digest, "serializationCard", selection.serializationCardId());
|
||||
field(digest, "topicCard", selection.topicCardId());
|
||||
field(digest, "securityCard", selection.securityCardId());
|
||||
field(digest, "compressionCard", selection.compressionCardId());
|
||||
field(digest, "orderingCard", selection.orderingCardId());
|
||||
field(digest, "transactionCard", selection.transactionCardId());
|
||||
field(digest, "operatorControlCard", selection.operatorControlCardId());
|
||||
}
|
||||
|
||||
private static MessageDigest digest(String domain) {
|
||||
try {
|
||||
MessageDigest result = MessageDigest.getInstance("SHA-256");
|
||||
result.update(domain.getBytes(StandardCharsets.UTF_8));
|
||||
result.update((byte) 0);
|
||||
return result;
|
||||
} catch (NoSuchAlgorithmException exception) {
|
||||
throw new IllegalStateException("Java runtime does not provide SHA-256", exception);
|
||||
}
|
||||
}
|
||||
|
||||
private static void field(MessageDigest digest, String tag, String value) {
|
||||
rawField(digest, tag, value.getBytes(StandardCharsets.UTF_8));
|
||||
}
|
||||
|
||||
private static void rawField(MessageDigest digest, String tag, byte[] value) {
|
||||
byte[] tagBytes = tag.getBytes(StandardCharsets.UTF_8);
|
||||
digest.update(ByteBuffer.allocate(Integer.BYTES).putInt(tagBytes.length).array());
|
||||
digest.update(tagBytes);
|
||||
digest.update(ByteBuffer.allocate(Integer.BYTES).putInt(value.length).array());
|
||||
digest.update(value);
|
||||
}
|
||||
}
|
||||
+238
@@ -0,0 +1,238 @@
|
||||
package dev.caskeleton.adapter.outbound.messaging.destination;
|
||||
|
||||
import dev.caskeleton.adapter.outbound.messaging.config.MessagingCapabilityCardRegistry;
|
||||
import dev.caskeleton.application.messaging.contract.ContractId;
|
||||
import dev.caskeleton.application.messaging.contract.LogicalDestinationId;
|
||||
import java.util.HashSet;
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
import java.util.Optional;
|
||||
import java.util.Set;
|
||||
|
||||
/** Deployment-owned, bounded canonical destination binding inputs without credential values. */
|
||||
public final class DestinationBindingSettings {
|
||||
|
||||
private final List<DestinationBinding> destinations;
|
||||
private final MessagingCapabilityCardRegistry.ExactSelection selectedCards;
|
||||
private final boolean orderingRequired;
|
||||
private final boolean schemaValidationRequired;
|
||||
private final boolean secureTransportRequired;
|
||||
private final Optional<LegacyAliases> legacyAliases;
|
||||
|
||||
public DestinationBindingSettings(
|
||||
List<DestinationBinding> destinations,
|
||||
MessagingCapabilityCardRegistry.ExactSelection selectedCards,
|
||||
boolean orderingRequired,
|
||||
boolean schemaValidationRequired,
|
||||
boolean secureTransportRequired,
|
||||
Optional<LegacyAliases> legacyAliases) {
|
||||
if (destinations == null || selectedCards == null || legacyAliases == null) {
|
||||
throw new IllegalArgumentException("destination binding settings fields must not be null");
|
||||
}
|
||||
if (destinations.stream().anyMatch(java.util.Objects::isNull)) {
|
||||
throw new IllegalArgumentException("destination binding must not be null");
|
||||
}
|
||||
List<DestinationBinding> copiedDestinations = List.copyOf(destinations);
|
||||
Set<LogicalDestinationId> logicalDestinations = new HashSet<>();
|
||||
Set<String> physicalTopics = new HashSet<>();
|
||||
for (DestinationBinding destination : copiedDestinations) {
|
||||
if (!logicalDestinations.add(destination.logicalDestination())) {
|
||||
throw new IllegalArgumentException("duplicate destination binding");
|
||||
}
|
||||
if (!physicalTopics.add(destination.physicalTopic())) {
|
||||
throw new IllegalArgumentException("duplicate physical topic binding");
|
||||
}
|
||||
}
|
||||
this.destinations = copiedDestinations;
|
||||
this.selectedCards = selectedCards;
|
||||
this.orderingRequired = orderingRequired;
|
||||
this.schemaValidationRequired = schemaValidationRequired;
|
||||
this.secureTransportRequired = secureTransportRequired;
|
||||
this.legacyAliases = legacyAliases;
|
||||
}
|
||||
|
||||
List<DestinationBinding> destinations() {
|
||||
return destinations;
|
||||
}
|
||||
|
||||
MessagingCapabilityCardRegistry.ExactSelection selectedCards() {
|
||||
return selectedCards;
|
||||
}
|
||||
|
||||
public boolean orderingRequired() {
|
||||
return orderingRequired;
|
||||
}
|
||||
|
||||
public boolean schemaValidationRequired() {
|
||||
return schemaValidationRequired;
|
||||
}
|
||||
|
||||
public boolean secureTransportRequired() {
|
||||
return secureTransportRequired;
|
||||
}
|
||||
|
||||
Optional<LegacyAliases> legacyAliases() {
|
||||
return legacyAliases;
|
||||
}
|
||||
|
||||
/** Narrow immutable composition bridge for sibling messaging adapter packages. */
|
||||
public static List<DestinationBinding> destinationsOf(DestinationBindingSettings settings) {
|
||||
return requireSettings(settings).destinations;
|
||||
}
|
||||
|
||||
/** Narrow immutable composition bridge for sibling messaging adapter packages. */
|
||||
public static Optional<LegacyAliases> legacyAliasesOf(DestinationBindingSettings settings) {
|
||||
return requireSettings(settings).legacyAliases;
|
||||
}
|
||||
|
||||
private static DestinationBindingSettings requireSettings(DestinationBindingSettings settings) {
|
||||
if (settings == null) {
|
||||
throw new IllegalArgumentException("destination binding settings must not be null");
|
||||
}
|
||||
return settings;
|
||||
}
|
||||
|
||||
public record ContractVersion(ContractId contractId, int payloadVersion) {
|
||||
|
||||
public ContractVersion {
|
||||
if (contractId == null || payloadVersion <= 0) {
|
||||
throw new IllegalArgumentException(
|
||||
"contract binding identity and positive version required");
|
||||
}
|
||||
}
|
||||
|
||||
public String stableKey() {
|
||||
return contractId.value() + ":v" + payloadVersion;
|
||||
}
|
||||
}
|
||||
|
||||
public static final class DestinationBinding {
|
||||
|
||||
private static final int MAXIMUM_BOOTSTRAP_SERVERS = 32;
|
||||
private static final String TOPIC_GRAMMAR = "[A-Za-z0-9][A-Za-z0-9._-]{0,248}";
|
||||
private static final String BOOTSTRAP_GRAMMAR =
|
||||
"[A-Za-z0-9](?:[A-Za-z0-9.-]{0,251}[A-Za-z0-9])?:[1-9][0-9]{0,4}";
|
||||
private final LogicalDestinationId logicalDestination;
|
||||
private final List<ContractVersion> contracts;
|
||||
private final String physicalTopic;
|
||||
private final List<String> bootstrapServers;
|
||||
private final int maximumRecordBytes;
|
||||
|
||||
public DestinationBinding(
|
||||
LogicalDestinationId logicalDestination,
|
||||
List<ContractVersion> contracts,
|
||||
String physicalTopic,
|
||||
List<String> bootstrapServers,
|
||||
int maximumRecordBytes) {
|
||||
if (logicalDestination == null
|
||||
|| contracts == null
|
||||
|| contracts.isEmpty()
|
||||
|| physicalTopic == null
|
||||
|| !physicalTopic.matches(TOPIC_GRAMMAR)
|
||||
|| bootstrapServers == null
|
||||
|| bootstrapServers.isEmpty()
|
||||
|| bootstrapServers.size() > MAXIMUM_BOOTSTRAP_SERVERS
|
||||
|| maximumRecordBytes <= 0) {
|
||||
throw new IllegalArgumentException("invalid bounded canonical destination binding");
|
||||
}
|
||||
contracts = List.copyOf(contracts);
|
||||
if (new HashSet<>(contracts).size() != contracts.size()) {
|
||||
throw new IllegalArgumentException("destination contract identities must be unique");
|
||||
}
|
||||
if (bootstrapServers.stream()
|
||||
.anyMatch(server -> server == null || !validBootstrapServer(server))) {
|
||||
throw new IllegalArgumentException(
|
||||
"bootstrap servers must be bounded host:port values without secrets");
|
||||
}
|
||||
if (new HashSet<>(bootstrapServers).size() != bootstrapServers.size()) {
|
||||
throw new IllegalArgumentException("bootstrap servers must be unique");
|
||||
}
|
||||
this.logicalDestination = logicalDestination;
|
||||
this.contracts = contracts;
|
||||
this.physicalTopic = physicalTopic;
|
||||
this.bootstrapServers = bootstrapServers.stream().sorted().toList();
|
||||
this.maximumRecordBytes = maximumRecordBytes;
|
||||
}
|
||||
|
||||
public LogicalDestinationId logicalDestination() {
|
||||
return logicalDestination;
|
||||
}
|
||||
|
||||
List<ContractVersion> contracts() {
|
||||
return contracts;
|
||||
}
|
||||
|
||||
public String physicalTopic() {
|
||||
return physicalTopic;
|
||||
}
|
||||
|
||||
public List<String> bootstrapServers() {
|
||||
return bootstrapServers;
|
||||
}
|
||||
|
||||
public int maximumRecordBytes() {
|
||||
return maximumRecordBytes;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean equals(Object other) {
|
||||
if (this == other) {
|
||||
return true;
|
||||
}
|
||||
if (!(other instanceof DestinationBinding that)) {
|
||||
return false;
|
||||
}
|
||||
return maximumRecordBytes == that.maximumRecordBytes
|
||||
&& logicalDestination.equals(that.logicalDestination)
|
||||
&& contracts.equals(that.contracts)
|
||||
&& physicalTopic.equals(that.physicalTopic)
|
||||
&& bootstrapServers.equals(that.bootstrapServers);
|
||||
}
|
||||
|
||||
@Override
|
||||
public int hashCode() {
|
||||
return Objects.hash(
|
||||
logicalDestination, contracts, physicalTopic, bootstrapServers, maximumRecordBytes);
|
||||
}
|
||||
|
||||
static boolean validPhysicalTopic(String topic) {
|
||||
return topic != null && topic.matches(TOPIC_GRAMMAR);
|
||||
}
|
||||
|
||||
static boolean validBootstrapServer(String server) {
|
||||
if (!server.matches(BOOTSTRAP_GRAMMAR)) {
|
||||
return false;
|
||||
}
|
||||
int port = Integer.parseInt(server.substring(server.lastIndexOf(':') + 1));
|
||||
return port <= 65_535;
|
||||
}
|
||||
}
|
||||
|
||||
/** Read-only migration aliases used solely to reject simultaneous legacy and canonical inputs. */
|
||||
public record LegacyAliases(
|
||||
Optional<String> brokerAlias,
|
||||
Optional<String> topicAlias,
|
||||
List<String> bootstrapServerAliases) {
|
||||
|
||||
public LegacyAliases {
|
||||
if (brokerAlias == null || topicAlias == null || bootstrapServerAliases == null) {
|
||||
throw new IllegalArgumentException("legacy aliases must not be null");
|
||||
}
|
||||
if (brokerAlias.filter(value -> !value.matches("[a-z][a-z0-9-]{0,95}")).isPresent()
|
||||
|| topicAlias.filter(value -> !DestinationBinding.validPhysicalTopic(value)).isPresent()
|
||||
|| bootstrapServerAliases.stream()
|
||||
.anyMatch(
|
||||
alias -> alias == null || !DestinationBinding.validBootstrapServer(alias))) {
|
||||
throw new IllegalArgumentException(
|
||||
"legacy aliases must be canonical identifiers without secrets");
|
||||
}
|
||||
bootstrapServerAliases = List.copyOf(bootstrapServerAliases);
|
||||
if (new HashSet<>(bootstrapServerAliases).size() != bootstrapServerAliases.size()) {
|
||||
throw new IllegalArgumentException("legacy bootstrap aliases must be unique");
|
||||
}
|
||||
if (brokerAlias.isEmpty() && topicAlias.isEmpty() && bootstrapServerAliases.isEmpty()) {
|
||||
throw new IllegalArgumentException("at least one legacy alias must be present");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+132
@@ -0,0 +1,132 @@
|
||||
package dev.caskeleton.adapter.outbound.messaging.destination;
|
||||
|
||||
import dev.caskeleton.application.messaging.contract.LogicalDestinationId;
|
||||
import dev.caskeleton.application.messaging.event.AggregateIdentity;
|
||||
import java.nio.ByteBuffer;
|
||||
import java.nio.CharBuffer;
|
||||
import java.nio.charset.CharacterCodingException;
|
||||
import java.nio.charset.CodingErrorAction;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.security.MessageDigest;
|
||||
import java.security.NoSuchAlgorithmException;
|
||||
import java.util.Arrays;
|
||||
import java.util.HexFormat;
|
||||
import java.util.Objects;
|
||||
|
||||
/**
|
||||
* Exact domain-separated partition-key v1 algorithm shared by polling and future dispatch cards.
|
||||
*/
|
||||
public final class PartitionKeyV1 {
|
||||
|
||||
private static final byte[] DOMAIN =
|
||||
"ca-skeleton.messaging.partition-key.v1".getBytes(StandardCharsets.UTF_8);
|
||||
|
||||
private PartitionKeyV1() {}
|
||||
|
||||
public static Value derive(
|
||||
LogicalDestinationId logicalDestination, AggregateIdentity aggregateIdentity) {
|
||||
if (aggregateIdentity == null) {
|
||||
throw new IllegalArgumentException("aggregate identity must not be null");
|
||||
}
|
||||
return deriveCanonicalComponents(
|
||||
aggregateIdentity.tenantScope(),
|
||||
logicalDestination,
|
||||
aggregateIdentity.aggregateType(),
|
||||
aggregateIdentity.aggregateId());
|
||||
}
|
||||
|
||||
/**
|
||||
* Cross-language vector entry point. Callers retain ownership of their canonical component
|
||||
* grammar; this method never substitutes a missing tenant scope.
|
||||
*/
|
||||
public static Value deriveCanonicalComponents(
|
||||
String tenantScope,
|
||||
LogicalDestinationId logicalDestination,
|
||||
String aggregateType,
|
||||
String aggregateId) {
|
||||
requireBounded("tenantScope", tenantScope, 96);
|
||||
if (logicalDestination == null) {
|
||||
throw new IllegalArgumentException("logicalDestination must not be null");
|
||||
}
|
||||
requireBounded("aggregateType", aggregateType, 64);
|
||||
requireBounded("aggregateId", aggregateId, 160);
|
||||
|
||||
MessageDigest digest = sha256();
|
||||
digest.update(DOMAIN);
|
||||
digest.update((byte) 0);
|
||||
updateLengthPrefixed(digest, strictUtf8("tenantScope", tenantScope));
|
||||
updateLengthPrefixed(digest, strictUtf8("logicalDestination", logicalDestination.value()));
|
||||
updateLengthPrefixed(digest, strictUtf8("aggregateType", aggregateType));
|
||||
updateLengthPrefixed(digest, strictUtf8("aggregateId", aggregateId));
|
||||
String text = HexFormat.of().formatHex(digest.digest());
|
||||
return new Value(text, text.getBytes(StandardCharsets.US_ASCII));
|
||||
}
|
||||
|
||||
private static void updateLengthPrefixed(MessageDigest digest, byte[] bytes) {
|
||||
digest.update(ByteBuffer.allocate(Integer.BYTES).putInt(bytes.length).array());
|
||||
digest.update(bytes);
|
||||
}
|
||||
|
||||
private static byte[] strictUtf8(String field, String value) {
|
||||
try {
|
||||
ByteBuffer encoded =
|
||||
StandardCharsets.UTF_8
|
||||
.newEncoder()
|
||||
.onMalformedInput(CodingErrorAction.REPORT)
|
||||
.onUnmappableCharacter(CodingErrorAction.REPORT)
|
||||
.encode(CharBuffer.wrap(value));
|
||||
byte[] bytes = new byte[encoded.remaining()];
|
||||
encoded.get(bytes);
|
||||
return bytes;
|
||||
} catch (CharacterCodingException exception) {
|
||||
throw new IllegalArgumentException(field + " must contain strict valid UTF-8", exception);
|
||||
}
|
||||
}
|
||||
|
||||
private static void requireBounded(String field, String value, int maximumLength) {
|
||||
if (value == null || value.isBlank() || value.length() > maximumLength) {
|
||||
throw new IllegalArgumentException(
|
||||
field + " must be a canonical non-blank value of at most " + maximumLength + " chars");
|
||||
}
|
||||
}
|
||||
|
||||
private static MessageDigest sha256() {
|
||||
try {
|
||||
return MessageDigest.getInstance("SHA-256");
|
||||
} catch (NoSuchAlgorithmException exception) {
|
||||
throw new IllegalStateException("Java runtime does not provide SHA-256", exception);
|
||||
}
|
||||
}
|
||||
|
||||
@SuppressWarnings("ArrayRecordComponent")
|
||||
public record Value(String text, byte[] bytes) {
|
||||
|
||||
public Value {
|
||||
if (text == null
|
||||
|| !text.matches("[0-9a-f]{64}")
|
||||
|| bytes == null
|
||||
|| !Arrays.equals(bytes, text.getBytes(StandardCharsets.US_ASCII))) {
|
||||
throw new IllegalArgumentException("partition key must be exact lower-case hex ASCII");
|
||||
}
|
||||
bytes = bytes.clone();
|
||||
}
|
||||
|
||||
@Override
|
||||
public byte[] bytes() {
|
||||
return bytes.clone();
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean equals(Object other) {
|
||||
return this == other
|
||||
|| (other instanceof Value that
|
||||
&& text.equals(that.text)
|
||||
&& Arrays.equals(bytes, that.bytes));
|
||||
}
|
||||
|
||||
@Override
|
||||
public int hashCode() {
|
||||
return 31 * Objects.hash(text) + Arrays.hashCode(bytes);
|
||||
}
|
||||
}
|
||||
}
|
||||
+545
@@ -0,0 +1,545 @@
|
||||
package dev.caskeleton.adapter.outbound.messaging.envelope;
|
||||
|
||||
import dev.caskeleton.adapter.outbound.messaging.contract.CompiledIntegrationEventContract;
|
||||
import dev.caskeleton.adapter.outbound.messaging.contract.CompiledIntegrationEventContract.PayloadComponent;
|
||||
import dev.caskeleton.adapter.outbound.messaging.contract.CompiledIntegrationEventContract.PayloadKind;
|
||||
import dev.caskeleton.adapter.outbound.messaging.contract.CompiledIntegrationEventContract.PayloadShape;
|
||||
import dev.caskeleton.application.messaging.contract.IntegrationPayload;
|
||||
import dev.caskeleton.application.messaging.event.IntegrationEventDraft;
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.math.BigDecimal;
|
||||
import java.nio.ByteBuffer;
|
||||
import java.nio.CharBuffer;
|
||||
import java.nio.charset.CharacterCodingException;
|
||||
import java.nio.charset.CodingErrorAction;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Iterator;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import tools.jackson.core.JsonGenerator;
|
||||
import tools.jackson.core.StreamWriteConstraints;
|
||||
import tools.jackson.core.json.JsonFactory;
|
||||
|
||||
/**
|
||||
* Closed declared-shape writer for envelope v1.
|
||||
*
|
||||
* <p>Every record accessor is read exactly once into an immutable snapshot. The payload is encoded
|
||||
* exactly once and those exact trusted bytes are then embedded in the envelope without any raw JSON
|
||||
* parser or generator API.
|
||||
*/
|
||||
final class DeterministicEnvelopeWriter {
|
||||
|
||||
private static final byte[] PAYLOAD_PROPERTY =
|
||||
",\"payload\":".getBytes(StandardCharsets.US_ASCII);
|
||||
|
||||
private final EnvelopeAdmissionLimits limits;
|
||||
private final JsonFactory jsonFactory;
|
||||
|
||||
DeterministicEnvelopeWriter(EnvelopeAdmissionLimits limits) {
|
||||
if (limits == null) {
|
||||
throw new IllegalArgumentException("envelope admission limits must not be null");
|
||||
}
|
||||
this.limits = limits;
|
||||
this.jsonFactory =
|
||||
JsonFactory.builder()
|
||||
.streamWriteConstraints(
|
||||
StreamWriteConstraints.builder().maxNestingDepth(limits.maximumDepth()).build())
|
||||
.build();
|
||||
}
|
||||
|
||||
EncodedEnvelope write(IntegrationEventDraft<?> draft, CompiledIntegrationEventContract contract) {
|
||||
if (draft == null || contract == null) {
|
||||
throw new IllegalArgumentException("draft and compiled contract must not be null");
|
||||
}
|
||||
IntegrationPayload payload = draft.featurePayload();
|
||||
if (payload.getClass() != contract.exactPayloadRecordType()) {
|
||||
throw new IllegalArgumentException(
|
||||
"draft payload must have the exact registered final record class");
|
||||
}
|
||||
if (!draft.contractId().equals(contract.contractId())
|
||||
|| draft.payloadVersion() != contract.payloadVersion()) {
|
||||
throw new IllegalArgumentException("draft identity must match the exact compiled contract");
|
||||
}
|
||||
|
||||
SnapshotValue payloadSnapshot =
|
||||
snapshot(CompiledIntegrationEventContract.payloadShapeOf(contract), payload, 1);
|
||||
byte[] payloadBytes =
|
||||
generate(
|
||||
limits.maximumPayloadBytes(), generator -> writeSnapshot(generator, payloadSnapshot));
|
||||
byte[] metadataBytes =
|
||||
generate(
|
||||
limits.maximumEnvelopeBytes(), generator -> writeEnvelopeMetadata(generator, draft));
|
||||
byte[] envelopeBytes = embedExactPayload(metadataBytes, payloadBytes);
|
||||
return new EncodedEnvelope(envelopeBytes, payloadBytes);
|
||||
}
|
||||
|
||||
private SnapshotValue snapshot(PayloadShape shape, Object value, int depth) {
|
||||
requireDepth(depth);
|
||||
PayloadKind kind = PayloadShape.kindOf(shape);
|
||||
return switch (kind) {
|
||||
case RECORD -> snapshotRecord(shape, value, depth);
|
||||
case OPTIONAL -> snapshotOptional(shape, value, depth);
|
||||
case LIST -> snapshotList(shape, value, depth);
|
||||
case STRING -> snapshotString(shape, value);
|
||||
case BOOLEAN -> snapshotBoolean(shape, value);
|
||||
case INTEGRAL -> snapshotIntegral(shape, value);
|
||||
case DECIMAL -> snapshotDecimal(shape, value);
|
||||
case ENUM -> snapshotEnum(shape, value);
|
||||
};
|
||||
}
|
||||
|
||||
private SnapshotValue snapshotRecord(PayloadShape shape, Object value, int depth) {
|
||||
Class<?> exactType = PayloadShape.exactJavaTypeOf(shape);
|
||||
if (value == null || value.getClass() != exactType) {
|
||||
throw new IllegalArgumentException("exact declared final record value required");
|
||||
}
|
||||
List<PayloadComponent> components = PayloadShape.componentsOf(shape);
|
||||
if (components.size() > limits.maximumObjectProperties()) {
|
||||
throw new IllegalArgumentException("record exceeds object properties admission limit");
|
||||
}
|
||||
List<String> names = new ArrayList<>(components.size());
|
||||
List<SnapshotValue> values = new ArrayList<>(components.size());
|
||||
for (PayloadComponent component : components) {
|
||||
String name = PayloadComponent.nameOf(component);
|
||||
requireString("record component name", name);
|
||||
names.add(name);
|
||||
values.add(
|
||||
snapshot(
|
||||
PayloadComponent.shapeOf(component),
|
||||
CompiledIntegrationEventContract.readPayloadComponentOnce(component, value),
|
||||
depth + 1));
|
||||
}
|
||||
return SnapshotValue.object(names, values);
|
||||
}
|
||||
|
||||
private SnapshotValue snapshotOptional(PayloadShape shape, Object value, int depth) {
|
||||
if (value == null || value.getClass() != Optional.class) {
|
||||
throw new IllegalArgumentException("a non-null exact Optional value is required");
|
||||
}
|
||||
Optional<?> optional = (Optional<?>) value;
|
||||
return optional.isEmpty()
|
||||
? SnapshotValue.nullValue()
|
||||
: snapshot(PayloadShape.elementShapeOf(shape), optional.get(), depth);
|
||||
}
|
||||
|
||||
private SnapshotValue snapshotList(PayloadShape shape, Object value, int depth) {
|
||||
if (!(value instanceof List<?> list)) {
|
||||
throw new IllegalArgumentException("a value declared as List is required");
|
||||
}
|
||||
int declaredSize = boundedListSize(list);
|
||||
List<SnapshotValue> snapshots = new ArrayList<>(declaredSize);
|
||||
PayloadShape elementShape = PayloadShape.elementShapeOf(shape);
|
||||
Iterator<?> iterator = listIterator(list);
|
||||
for (int index = 0; index < declaredSize; index++) {
|
||||
if (!hasNext(iterator)) {
|
||||
throw mutatedList();
|
||||
}
|
||||
snapshots.add(snapshot(elementShape, next(iterator), depth + 1));
|
||||
}
|
||||
if (hasNext(iterator)) {
|
||||
throw mutatedList();
|
||||
}
|
||||
return SnapshotValue.array(snapshots);
|
||||
}
|
||||
|
||||
private int boundedListSize(List<?> list) {
|
||||
int size;
|
||||
try {
|
||||
size = list.size();
|
||||
} catch (RuntimeException exception) {
|
||||
throw mutatedList(exception);
|
||||
}
|
||||
if (size < 0 || size > limits.maximumArrayItems()) {
|
||||
throw new IllegalArgumentException("list exceeds array items admission limit");
|
||||
}
|
||||
return size;
|
||||
}
|
||||
|
||||
private static Iterator<?> listIterator(List<?> list) {
|
||||
try {
|
||||
return list.iterator();
|
||||
} catch (RuntimeException exception) {
|
||||
throw mutatedList(exception);
|
||||
}
|
||||
}
|
||||
|
||||
private static boolean hasNext(Iterator<?> iterator) {
|
||||
try {
|
||||
return iterator.hasNext();
|
||||
} catch (RuntimeException exception) {
|
||||
throw mutatedList(exception);
|
||||
}
|
||||
}
|
||||
|
||||
private static Object next(Iterator<?> iterator) {
|
||||
try {
|
||||
return iterator.next();
|
||||
} catch (RuntimeException exception) {
|
||||
throw mutatedList(exception);
|
||||
}
|
||||
}
|
||||
|
||||
private static IllegalArgumentException mutatedList() {
|
||||
return new IllegalArgumentException("list mutated or failed during bounded snapshot");
|
||||
}
|
||||
|
||||
private static IllegalArgumentException mutatedList(RuntimeException cause) {
|
||||
return new IllegalArgumentException("list mutated or failed during bounded snapshot", cause);
|
||||
}
|
||||
|
||||
private SnapshotValue snapshotString(PayloadShape shape, Object value) {
|
||||
requireExactRuntimeType(shape, value);
|
||||
String text = (String) value;
|
||||
requireString("string value", text);
|
||||
return SnapshotValue.string(text);
|
||||
}
|
||||
|
||||
private SnapshotValue snapshotBoolean(PayloadShape shape, Object value) {
|
||||
requireExactRuntimeType(shape, value);
|
||||
return SnapshotValue.bool((Boolean) value);
|
||||
}
|
||||
|
||||
private SnapshotValue snapshotIntegral(PayloadShape shape, Object value) {
|
||||
requireExactRuntimeType(shape, value);
|
||||
String text = value.toString();
|
||||
requireNumberDigits(text);
|
||||
return SnapshotValue.number(text);
|
||||
}
|
||||
|
||||
private SnapshotValue snapshotDecimal(PayloadShape shape, Object value) {
|
||||
requireExactRuntimeType(shape, value);
|
||||
BigDecimal decimal;
|
||||
if (value instanceof BigDecimal exact) {
|
||||
decimal = exact;
|
||||
} else if (value instanceof Double floating) {
|
||||
if (!Double.isFinite(floating)) {
|
||||
throw new IllegalArgumentException("decimal values must be finite");
|
||||
}
|
||||
decimal = BigDecimal.valueOf(floating);
|
||||
} else if (value instanceof Float floating) {
|
||||
if (!Float.isFinite(floating)) {
|
||||
throw new IllegalArgumentException("decimal values must be finite");
|
||||
}
|
||||
decimal = new BigDecimal(Float.toString(floating));
|
||||
} else {
|
||||
throw new IllegalArgumentException("unsupported declared decimal runtime class");
|
||||
}
|
||||
BigDecimal canonical = canonicalDecimal(decimal);
|
||||
String text = canonical.toPlainString();
|
||||
requireNumberDigits(text);
|
||||
return SnapshotValue.number(text);
|
||||
}
|
||||
|
||||
private SnapshotValue snapshotEnum(PayloadShape shape, Object value) {
|
||||
requireExactRuntimeType(shape, value);
|
||||
String name = ((Enum<?>) value).name();
|
||||
requireString("enum value", name);
|
||||
return SnapshotValue.string(name);
|
||||
}
|
||||
|
||||
private static void requireExactRuntimeType(PayloadShape shape, Object value) {
|
||||
Class<?> declared = PayloadShape.exactJavaTypeOf(shape);
|
||||
Class<?> runtimeType = wrapperType(declared);
|
||||
if (value == null || value.getClass() != runtimeType) {
|
||||
throw new IllegalArgumentException("payload scalar must match its exact declared type");
|
||||
}
|
||||
}
|
||||
|
||||
private static Class<?> wrapperType(Class<?> type) {
|
||||
if (!type.isPrimitive()) {
|
||||
return type;
|
||||
}
|
||||
if (type == boolean.class) {
|
||||
return Boolean.class;
|
||||
}
|
||||
if (type == byte.class) {
|
||||
return Byte.class;
|
||||
}
|
||||
if (type == short.class) {
|
||||
return Short.class;
|
||||
}
|
||||
if (type == int.class) {
|
||||
return Integer.class;
|
||||
}
|
||||
if (type == long.class) {
|
||||
return Long.class;
|
||||
}
|
||||
if (type == float.class) {
|
||||
return Float.class;
|
||||
}
|
||||
if (type == double.class) {
|
||||
return Double.class;
|
||||
}
|
||||
throw new IllegalArgumentException("unsupported primitive payload component");
|
||||
}
|
||||
|
||||
private void writeSnapshot(JsonGenerator generator, SnapshotValue snapshot) {
|
||||
switch (snapshot.kind()) {
|
||||
case NULL -> generator.writeNull();
|
||||
case STRING -> generator.writeString((String) snapshot.scalar());
|
||||
case BOOLEAN -> generator.writeBoolean((Boolean) snapshot.scalar());
|
||||
case NUMBER -> generator.writeNumber((String) snapshot.scalar());
|
||||
case ARRAY -> {
|
||||
generator.writeStartArray();
|
||||
for (SnapshotValue value : snapshot.values()) {
|
||||
writeSnapshot(generator, value);
|
||||
}
|
||||
generator.writeEndArray();
|
||||
}
|
||||
case OBJECT -> {
|
||||
generator.writeStartObject();
|
||||
for (int index = 0; index < snapshot.names().size(); index++) {
|
||||
generator.writeName(snapshot.names().get(index));
|
||||
writeSnapshot(generator, snapshot.values().get(index));
|
||||
}
|
||||
generator.writeEndObject();
|
||||
}
|
||||
default -> throw new IllegalStateException("unknown immutable payload snapshot kind");
|
||||
}
|
||||
}
|
||||
|
||||
private void writeEnvelopeMetadata(JsonGenerator generator, IntegrationEventDraft<?> draft) {
|
||||
if (10 > limits.maximumObjectProperties()) {
|
||||
throw new IllegalArgumentException("envelope exceeds object properties admission limit");
|
||||
}
|
||||
generator.writeStartObject();
|
||||
generator.writeNumberProperty("envelopeVersion", 1);
|
||||
writeStringProperty(generator, "eventId", draft.eventId().value());
|
||||
writeStringProperty(generator, "contractId", draft.contractId().value());
|
||||
generator.writeNumberProperty("payloadVersion", draft.payloadVersion());
|
||||
writeStringProperty(generator, "logicalDestination", draft.destinationId().value());
|
||||
generator.writeObjectPropertyStart("aggregate");
|
||||
writeStringProperty(generator, "type", draft.aggregate().aggregateType());
|
||||
writeStringProperty(generator, "id", draft.aggregate().aggregateId());
|
||||
generator.writeNumberProperty("sequence", draft.order().sequence());
|
||||
generator.writeNumberProperty("eventIndex", draft.order().eventIndex());
|
||||
generator.writeEndObject();
|
||||
writeStringProperty(generator, "occurredAt", draft.occurredAt().toString());
|
||||
writeStringProperty(generator, "correlationId", draft.correlationId());
|
||||
writeStringProperty(generator, "contentType", "application/json");
|
||||
generator.writeEndObject();
|
||||
}
|
||||
|
||||
private void writeStringProperty(JsonGenerator generator, String name, String value) {
|
||||
requireString(name, value);
|
||||
generator.writeStringProperty(name, value);
|
||||
}
|
||||
|
||||
private byte[] embedExactPayload(byte[] metadataBytes, byte[] payloadBytes) {
|
||||
if (metadataBytes.length < 2 || metadataBytes[metadataBytes.length - 1] != (byte) '}') {
|
||||
throw new IllegalStateException("generated envelope metadata is not a JSON object");
|
||||
}
|
||||
long exactLength =
|
||||
(long) metadataBytes.length - 1L + PAYLOAD_PROPERTY.length + payloadBytes.length + 1L;
|
||||
if (exactLength > limits.maximumEnvelopeBytes()) {
|
||||
throw new IllegalArgumentException("encoded JSON exceeds byte admission limit");
|
||||
}
|
||||
byte[] envelope = new byte[(int) exactLength];
|
||||
int offset = metadataBytes.length - 1;
|
||||
System.arraycopy(metadataBytes, 0, envelope, 0, offset);
|
||||
System.arraycopy(PAYLOAD_PROPERTY, 0, envelope, offset, PAYLOAD_PROPERTY.length);
|
||||
offset += PAYLOAD_PROPERTY.length;
|
||||
System.arraycopy(payloadBytes, 0, envelope, offset, payloadBytes.length);
|
||||
envelope[envelope.length - 1] = (byte) '}';
|
||||
return envelope;
|
||||
}
|
||||
|
||||
private byte[] generate(int maximumBytes, GeneratorAction action) {
|
||||
ByteArrayOutputStream output = new BoundedByteArrayOutputStream(maximumBytes);
|
||||
try (JsonGenerator generator = jsonFactory.createGenerator(output)) {
|
||||
action.write(generator);
|
||||
}
|
||||
byte[] result = output.toByteArray();
|
||||
if (result.length > maximumBytes) {
|
||||
throw new IllegalArgumentException("encoded JSON exceeds byte admission limit");
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
private void requireDepth(int depth) {
|
||||
if (depth > limits.maximumDepth()) {
|
||||
throw new IllegalArgumentException("payload exceeds depth admission limit");
|
||||
}
|
||||
}
|
||||
|
||||
private void requireString(String field, String value) {
|
||||
if (value == null) {
|
||||
throw new IllegalArgumentException(field + " must not be null");
|
||||
}
|
||||
if (value.codePointCount(0, value.length()) > limits.maximumStringCharacters()) {
|
||||
throw new IllegalArgumentException(field + " exceeds string character admission limit");
|
||||
}
|
||||
byte[] bytes = strictUtf8(field, value);
|
||||
if (bytes.length > limits.maximumStringUtf8Bytes()) {
|
||||
throw new IllegalArgumentException(field + " exceeds UTF-8 byte admission limit");
|
||||
}
|
||||
}
|
||||
|
||||
private static byte[] strictUtf8(String field, String value) {
|
||||
try {
|
||||
ByteBuffer encoded =
|
||||
StandardCharsets.UTF_8
|
||||
.newEncoder()
|
||||
.onMalformedInput(CodingErrorAction.REPORT)
|
||||
.onUnmappableCharacter(CodingErrorAction.REPORT)
|
||||
.encode(CharBuffer.wrap(value));
|
||||
byte[] bytes = new byte[encoded.remaining()];
|
||||
encoded.get(bytes);
|
||||
return bytes;
|
||||
} catch (CharacterCodingException exception) {
|
||||
throw new IllegalArgumentException(field + " contains an unpaired surrogate", exception);
|
||||
}
|
||||
}
|
||||
|
||||
private void requireNumberDigits(String text) {
|
||||
long digits = text.codePoints().filter(Character::isDigit).count();
|
||||
if (digits > limits.maximumNumberDigits()) {
|
||||
throw new IllegalArgumentException("number exceeds digits admission limit");
|
||||
}
|
||||
}
|
||||
|
||||
private BigDecimal canonicalDecimal(BigDecimal decimal) {
|
||||
if (decimal == null) {
|
||||
throw new IllegalArgumentException("decimal must not be null");
|
||||
}
|
||||
BigDecimal stripped = decimal.stripTrailingZeros();
|
||||
long precision = stripped.precision();
|
||||
long scale = stripped.scale();
|
||||
long plainDigits = scale >= precision ? 1L + scale : precision + Math.max(0L, -scale);
|
||||
if (plainDigits > limits.maximumNumberDigits()) {
|
||||
throw new IllegalArgumentException("number exceeds digits admission limit");
|
||||
}
|
||||
return stripped.scale() < 0 ? stripped.setScale(0) : stripped;
|
||||
}
|
||||
|
||||
@SuppressWarnings("ArrayRecordComponent")
|
||||
record EncodedEnvelope(byte[] envelopeBytes, byte[] payloadBytes) {
|
||||
|
||||
EncodedEnvelope {
|
||||
if (envelopeBytes == null
|
||||
|| envelopeBytes.length == 0
|
||||
|| payloadBytes == null
|
||||
|| payloadBytes.length == 0) {
|
||||
throw new IllegalArgumentException("encoded envelope and payload bytes are required");
|
||||
}
|
||||
envelopeBytes = envelopeBytes.clone();
|
||||
payloadBytes = payloadBytes.clone();
|
||||
}
|
||||
|
||||
@Override
|
||||
public byte[] envelopeBytes() {
|
||||
return envelopeBytes.clone();
|
||||
}
|
||||
|
||||
@Override
|
||||
public byte[] payloadBytes() {
|
||||
return payloadBytes.clone();
|
||||
}
|
||||
}
|
||||
|
||||
private enum SnapshotKind {
|
||||
NULL,
|
||||
STRING,
|
||||
BOOLEAN,
|
||||
NUMBER,
|
||||
ARRAY,
|
||||
OBJECT
|
||||
}
|
||||
|
||||
private static final class SnapshotValue {
|
||||
|
||||
private final SnapshotKind kind;
|
||||
private final Object scalar;
|
||||
private final List<String> names;
|
||||
private final List<SnapshotValue> values;
|
||||
|
||||
private SnapshotValue(
|
||||
SnapshotKind kind, Object scalar, List<String> names, List<SnapshotValue> values) {
|
||||
this.kind = kind;
|
||||
this.scalar = scalar;
|
||||
this.names = List.copyOf(names);
|
||||
this.values = List.copyOf(values);
|
||||
}
|
||||
|
||||
private SnapshotKind kind() {
|
||||
return kind;
|
||||
}
|
||||
|
||||
private Object scalar() {
|
||||
return scalar;
|
||||
}
|
||||
|
||||
private List<String> names() {
|
||||
return names;
|
||||
}
|
||||
|
||||
private List<SnapshotValue> values() {
|
||||
return values;
|
||||
}
|
||||
|
||||
private static SnapshotValue nullValue() {
|
||||
return new SnapshotValue(SnapshotKind.NULL, null, List.of(), List.of());
|
||||
}
|
||||
|
||||
private static SnapshotValue string(String value) {
|
||||
return new SnapshotValue(SnapshotKind.STRING, value, List.of(), List.of());
|
||||
}
|
||||
|
||||
private static SnapshotValue bool(boolean value) {
|
||||
return new SnapshotValue(SnapshotKind.BOOLEAN, value, List.of(), List.of());
|
||||
}
|
||||
|
||||
private static SnapshotValue number(String value) {
|
||||
return new SnapshotValue(SnapshotKind.NUMBER, value, List.of(), List.of());
|
||||
}
|
||||
|
||||
private static SnapshotValue array(List<SnapshotValue> values) {
|
||||
return new SnapshotValue(SnapshotKind.ARRAY, null, List.of(), values);
|
||||
}
|
||||
|
||||
private static SnapshotValue object(List<String> names, List<SnapshotValue> values) {
|
||||
return new SnapshotValue(SnapshotKind.OBJECT, null, names, values);
|
||||
}
|
||||
}
|
||||
|
||||
private static final class BoundedByteArrayOutputStream extends ByteArrayOutputStream {
|
||||
|
||||
private final int maximumBytes;
|
||||
|
||||
private BoundedByteArrayOutputStream(int maximumBytes) {
|
||||
super(Math.min(maximumBytes, 8192));
|
||||
if (maximumBytes <= 0) {
|
||||
throw new IllegalArgumentException("encoded JSON byte admission limit must be positive");
|
||||
}
|
||||
this.maximumBytes = maximumBytes;
|
||||
}
|
||||
|
||||
@Override
|
||||
public synchronized void write(int value) {
|
||||
requireCapacity(1);
|
||||
super.write(value);
|
||||
}
|
||||
|
||||
@Override
|
||||
public synchronized void write(byte[] value, int offset, int length) {
|
||||
if (value == null) {
|
||||
throw new IllegalArgumentException("encoded JSON bytes must not be null");
|
||||
}
|
||||
java.util.Objects.checkFromIndexSize(offset, length, value.length);
|
||||
requireCapacity(length);
|
||||
super.write(value, offset, length);
|
||||
}
|
||||
|
||||
private void requireCapacity(int additionalBytes) {
|
||||
if ((long) count + additionalBytes > maximumBytes) {
|
||||
throw new IllegalArgumentException("encoded JSON exceeds byte admission limit");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@FunctionalInterface
|
||||
private interface GeneratorAction {
|
||||
void write(JsonGenerator generator);
|
||||
}
|
||||
}
|
||||
+56
@@ -0,0 +1,56 @@
|
||||
package dev.caskeleton.adapter.outbound.messaging.envelope;
|
||||
|
||||
/**
|
||||
* Closed, finite admission budgets shared by the deterministic writer and local schema validator.
|
||||
*/
|
||||
public record EnvelopeAdmissionLimits(
|
||||
int maximumDepth,
|
||||
int maximumStringCharacters,
|
||||
int maximumStringUtf8Bytes,
|
||||
int maximumArrayItems,
|
||||
int maximumObjectProperties,
|
||||
int maximumNumberDigits,
|
||||
int maximumPayloadBytes,
|
||||
int maximumEnvelopeBytes,
|
||||
int maximumValidationErrors,
|
||||
int maximumRegexInputCharacters,
|
||||
int maximumReferenceDepth) {
|
||||
|
||||
private static final int HARD_MAXIMUM_DEPTH = 128;
|
||||
private static final int HARD_MAXIMUM_STRING_CHARACTERS = 1_000_000;
|
||||
private static final int HARD_MAXIMUM_STRING_BYTES = 4_000_000;
|
||||
private static final int HARD_MAXIMUM_COLLECTION_ITEMS = 100_000;
|
||||
private static final int HARD_MAXIMUM_NUMBER_DIGITS = 10_000;
|
||||
private static final int HARD_MAXIMUM_DOCUMENT_BYTES = 100_000_000;
|
||||
private static final int HARD_MAXIMUM_VALIDATION_ERRORS = 1_000;
|
||||
private static final int HARD_MAXIMUM_REFERENCE_DEPTH = 64;
|
||||
|
||||
public EnvelopeAdmissionLimits {
|
||||
requireFinite("maximumDepth", maximumDepth, HARD_MAXIMUM_DEPTH);
|
||||
requireFinite(
|
||||
"maximumStringCharacters", maximumStringCharacters, HARD_MAXIMUM_STRING_CHARACTERS);
|
||||
requireFinite("maximumStringUtf8Bytes", maximumStringUtf8Bytes, HARD_MAXIMUM_STRING_BYTES);
|
||||
requireFinite("maximumArrayItems", maximumArrayItems, HARD_MAXIMUM_COLLECTION_ITEMS);
|
||||
requireFinite(
|
||||
"maximumObjectProperties", maximumObjectProperties, HARD_MAXIMUM_COLLECTION_ITEMS);
|
||||
requireFinite("maximumNumberDigits", maximumNumberDigits, HARD_MAXIMUM_NUMBER_DIGITS);
|
||||
requireFinite("maximumPayloadBytes", maximumPayloadBytes, HARD_MAXIMUM_DOCUMENT_BYTES);
|
||||
requireFinite("maximumEnvelopeBytes", maximumEnvelopeBytes, HARD_MAXIMUM_DOCUMENT_BYTES);
|
||||
requireFinite(
|
||||
"maximumValidationErrors", maximumValidationErrors, HARD_MAXIMUM_VALIDATION_ERRORS);
|
||||
requireFinite(
|
||||
"maximumRegexInputCharacters", maximumRegexInputCharacters, HARD_MAXIMUM_STRING_CHARACTERS);
|
||||
requireFinite("maximumReferenceDepth", maximumReferenceDepth, HARD_MAXIMUM_REFERENCE_DEPTH);
|
||||
if (maximumEnvelopeBytes < maximumPayloadBytes) {
|
||||
throw new IllegalArgumentException(
|
||||
"maximumEnvelopeBytes must be at least maximumPayloadBytes");
|
||||
}
|
||||
}
|
||||
|
||||
private static void requireFinite(String field, int value, int hardMaximum) {
|
||||
if (value <= 0 || value > hardMaximum || value == Integer.MAX_VALUE) {
|
||||
throw new IllegalArgumentException(
|
||||
field + " must be a positive finite bound at most " + hardMaximum);
|
||||
}
|
||||
}
|
||||
}
|
||||
+36
@@ -0,0 +1,36 @@
|
||||
package dev.caskeleton.adapter.outbound.messaging.envelope;
|
||||
|
||||
import dev.caskeleton.application.messaging.contract.Sha256;
|
||||
import java.nio.ByteBuffer;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.security.MessageDigest;
|
||||
import java.security.NoSuchAlgorithmException;
|
||||
|
||||
/** Domain-separated exact-byte hash for the immutable v1 envelope. */
|
||||
public final class EnvelopeHashV1 {
|
||||
|
||||
private static final byte[] DOMAIN =
|
||||
"ca-skeleton.messaging.envelope.v1".getBytes(StandardCharsets.UTF_8);
|
||||
|
||||
private EnvelopeHashV1() {}
|
||||
|
||||
public static Sha256 compute(byte[] exactEnvelopeBytes) {
|
||||
if (exactEnvelopeBytes == null || exactEnvelopeBytes.length == 0) {
|
||||
throw new IllegalArgumentException("exact envelope bytes must not be null or empty");
|
||||
}
|
||||
MessageDigest digest = sha256();
|
||||
digest.update(DOMAIN);
|
||||
digest.update((byte) 0);
|
||||
digest.update(ByteBuffer.allocate(Integer.BYTES).putInt(exactEnvelopeBytes.length).array());
|
||||
digest.update(exactEnvelopeBytes);
|
||||
return new Sha256(digest.digest());
|
||||
}
|
||||
|
||||
private static MessageDigest sha256() {
|
||||
try {
|
||||
return MessageDigest.getInstance("SHA-256");
|
||||
} catch (NoSuchAlgorithmException exception) {
|
||||
throw new IllegalStateException("Java runtime does not provide SHA-256", exception);
|
||||
}
|
||||
}
|
||||
}
|
||||
+232
@@ -0,0 +1,232 @@
|
||||
package dev.caskeleton.adapter.outbound.messaging.envelope;
|
||||
|
||||
import dev.caskeleton.adapter.outbound.messaging.contract.CompiledIntegrationEventContract;
|
||||
import dev.caskeleton.adapter.outbound.messaging.contract.ContractCatalogDigest;
|
||||
import dev.caskeleton.adapter.outbound.messaging.destination.CompiledPublicationBinding;
|
||||
import dev.caskeleton.adapter.outbound.messaging.destination.PartitionKeyV1;
|
||||
import dev.caskeleton.application.messaging.contract.Sha256;
|
||||
import dev.caskeleton.application.messaging.event.IntegrationEventDraft;
|
||||
import dev.caskeleton.application.messaging.event.IntegrationEventEncoderPort;
|
||||
import dev.caskeleton.application.messaging.event.ValidatedIntegrationEvent;
|
||||
import java.nio.ByteBuffer;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.security.MessageDigest;
|
||||
import java.security.NoSuchAlgorithmException;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/** Pure local encoder joining exact compiled contracts, bindings and precompiled schemas. */
|
||||
public final class JsonSchemaIntegrationEventEncoder implements IntegrationEventEncoderPort {
|
||||
|
||||
public static final String ENVELOPE_SCHEMA_RESOURCE =
|
||||
"contracts/messaging/envelope/v1.schema.json";
|
||||
private static final byte[] SCHEMA_SET_DOMAIN =
|
||||
"ca-skeleton.messaging.schema-set.v1".getBytes(StandardCharsets.UTF_8);
|
||||
|
||||
private final Map<String, Entry> entries;
|
||||
private final LocalJsonSchemaRegistry registry;
|
||||
private final DeterministicEnvelopeWriter writer;
|
||||
private final EnvelopeAdmissionLimits limits;
|
||||
private final String envelopeSchemaId;
|
||||
private final Sha256 envelopeSchemaHash;
|
||||
private final String catalogRevision;
|
||||
|
||||
public JsonSchemaIntegrationEventEncoder(
|
||||
List<CompiledIntegrationEventContract> contracts,
|
||||
List<CompiledPublicationBinding> bindings,
|
||||
LocalJsonSchemaRegistry registry,
|
||||
EnvelopeAdmissionLimits limits) {
|
||||
if (contracts == null
|
||||
|| contracts.isEmpty()
|
||||
|| bindings == null
|
||||
|| registry == null
|
||||
|| limits == null) {
|
||||
throw new IllegalArgumentException(
|
||||
"closed contracts, bindings, registry and limits are required");
|
||||
}
|
||||
this.registry = registry;
|
||||
this.limits = limits;
|
||||
this.writer = new DeterministicEnvelopeWriter(limits);
|
||||
this.envelopeSchemaId = registry.schemaId(ENVELOPE_SCHEMA_RESOURCE);
|
||||
this.envelopeSchemaHash = registry.schemaHash(ENVELOPE_SCHEMA_RESOURCE);
|
||||
this.catalogRevision = ContractCatalogDigest.compute(contracts).toString();
|
||||
|
||||
Map<String, CompiledPublicationBinding> bindingByKey = new HashMap<>();
|
||||
for (CompiledPublicationBinding binding : bindings) {
|
||||
if (binding == null
|
||||
|| bindingByKey.putIfAbsent(
|
||||
CompiledPublicationBinding.contractOf(binding).stableKey(), binding)
|
||||
!= null) {
|
||||
throw new IllegalArgumentException("bindings must be unique and non-null");
|
||||
}
|
||||
}
|
||||
Map<String, Entry> indexed = new HashMap<>();
|
||||
for (CompiledIntegrationEventContract contract : contracts) {
|
||||
if (contract == null) {
|
||||
throw new IllegalArgumentException("compiled contract must not be null");
|
||||
}
|
||||
CompiledPublicationBinding binding = bindingByKey.remove(contract.stableKey());
|
||||
if (binding == null || CompiledPublicationBinding.contractOf(binding) != contract) {
|
||||
throw new IllegalArgumentException(
|
||||
"each exact compiled contract requires its canonical compiled binding");
|
||||
}
|
||||
Sha256 registeredPayloadHash = registry.schemaHash(contract.payloadSchemaResource().value());
|
||||
if (!registeredPayloadHash.equals(contract.payloadSchemaHash())) {
|
||||
throw new IllegalArgumentException(
|
||||
"compiled contract payload schema hash differs from the exact registry bytes");
|
||||
}
|
||||
String payloadSchemaId = registry.schemaId(contract.payloadSchemaResource().value());
|
||||
Entry entry =
|
||||
new Entry(
|
||||
contract,
|
||||
binding,
|
||||
payloadSchemaId,
|
||||
registeredPayloadHash,
|
||||
schemaSetHash(envelopeSchemaHash, registeredPayloadHash));
|
||||
if (indexed.putIfAbsent(contract.stableKey(), entry) != null) {
|
||||
throw new IllegalArgumentException("duplicate compiled contract identity");
|
||||
}
|
||||
}
|
||||
if (!bindingByKey.isEmpty()) {
|
||||
throw new IllegalArgumentException("extra publication binding outside the closed catalog");
|
||||
}
|
||||
this.entries = Map.copyOf(indexed);
|
||||
}
|
||||
|
||||
@Override
|
||||
public ValidatedIntegrationEvent encode(IntegrationEventDraft<?> draft) {
|
||||
if (draft == null) {
|
||||
throw new IllegalArgumentException("integration event draft must not be null");
|
||||
}
|
||||
String stableKey = draft.contractId().value() + ":v" + draft.payloadVersion();
|
||||
Entry entry = entries.get(stableKey);
|
||||
if (entry == null) {
|
||||
throw new IllegalArgumentException("unknown exact contract and payload version");
|
||||
}
|
||||
if (!draft.destinationId().equals(entry.contract().descriptor().logicalDestination())
|
||||
|| !draft.destinationId().equals(entry.binding().logicalDestination())) {
|
||||
throw new IllegalArgumentException("draft logical destination does not match binding");
|
||||
}
|
||||
if (draft.featurePayload().getClass() != entry.contract().exactPayloadRecordType()) {
|
||||
throw new IllegalArgumentException("draft payload must have the exact registered type");
|
||||
}
|
||||
|
||||
DeterministicEnvelopeWriter.EncodedEnvelope encoded = writer.write(draft, entry.contract());
|
||||
byte[] payloadBytes = encoded.payloadBytes();
|
||||
byte[] envelopeBytes = encoded.envelopeBytes();
|
||||
int payloadBound =
|
||||
Math.min(limits.maximumPayloadBytes(), entry.contract().descriptor().maximumPayloadBytes());
|
||||
int envelopeBound =
|
||||
Math.min(
|
||||
Math.min(
|
||||
limits.maximumEnvelopeBytes(),
|
||||
entry.contract().descriptor().maximumEnvelopeBytes()),
|
||||
entry.binding().effectiveMaximumRecordBytes());
|
||||
if (payloadBytes.length > payloadBound) {
|
||||
throw new IllegalArgumentException("payload exceeds effective code/deployment byte bound");
|
||||
}
|
||||
if (envelopeBytes.length > envelopeBound) {
|
||||
throw new IllegalArgumentException("envelope exceeds effective code/deployment byte bound");
|
||||
}
|
||||
|
||||
List<String> payloadErrors = registry.validate(entry.payloadSchemaId(), payloadBytes);
|
||||
if (!payloadErrors.isEmpty()) {
|
||||
throw new IllegalArgumentException(
|
||||
"payload schema validation failed: " + String.join(",", payloadErrors));
|
||||
}
|
||||
List<String> envelopeErrors = registry.validate(envelopeSchemaId, envelopeBytes);
|
||||
if (!envelopeErrors.isEmpty()) {
|
||||
throw new IllegalArgumentException(
|
||||
"envelope schema validation failed: " + String.join(",", envelopeErrors));
|
||||
}
|
||||
|
||||
PartitionKeyV1.Value partitionKey =
|
||||
PartitionKeyV1.derive(draft.destinationId(), draft.aggregate());
|
||||
return new ValidatedIntegrationEvent(
|
||||
draft.eventId(),
|
||||
draft.contractId(),
|
||||
1,
|
||||
draft.payloadVersion(),
|
||||
draft.destinationId(),
|
||||
draft.aggregate(),
|
||||
draft.order(),
|
||||
draft.occurredAt(),
|
||||
draft.correlationId(),
|
||||
draft.causationId(),
|
||||
partitionKey.text(),
|
||||
partitionKey.bytes(),
|
||||
envelopeBytes,
|
||||
"application/json",
|
||||
entry.schemaSetHash(),
|
||||
EnvelopeHashV1.compute(envelopeBytes),
|
||||
envelopeSchemaHash,
|
||||
entry.payloadSchemaHash(),
|
||||
catalogRevision,
|
||||
entry.binding().settingsDigest().toString());
|
||||
}
|
||||
|
||||
private static Sha256 schemaSetHash(Sha256 envelope, Sha256 payload) {
|
||||
MessageDigest digest = sha256();
|
||||
digest.update(SCHEMA_SET_DOMAIN);
|
||||
digest.update((byte) 0);
|
||||
updateLengthPrefixed(digest, envelope.bytes());
|
||||
updateLengthPrefixed(digest, payload.bytes());
|
||||
return new Sha256(digest.digest());
|
||||
}
|
||||
|
||||
private static void updateLengthPrefixed(MessageDigest digest, byte[] value) {
|
||||
digest.update(ByteBuffer.allocate(Integer.BYTES).putInt(value.length).array());
|
||||
digest.update(value);
|
||||
}
|
||||
|
||||
private static MessageDigest sha256() {
|
||||
try {
|
||||
return MessageDigest.getInstance("SHA-256");
|
||||
} catch (NoSuchAlgorithmException exception) {
|
||||
throw new IllegalStateException("Java runtime does not provide SHA-256", exception);
|
||||
}
|
||||
}
|
||||
|
||||
private static final class Entry {
|
||||
|
||||
private final CompiledIntegrationEventContract contract;
|
||||
private final CompiledPublicationBinding binding;
|
||||
private final String payloadSchemaId;
|
||||
private final Sha256 payloadSchemaHash;
|
||||
private final Sha256 schemaSetHash;
|
||||
|
||||
private Entry(
|
||||
CompiledIntegrationEventContract contract,
|
||||
CompiledPublicationBinding binding,
|
||||
String payloadSchemaId,
|
||||
Sha256 payloadSchemaHash,
|
||||
Sha256 schemaSetHash) {
|
||||
this.contract = contract;
|
||||
this.binding = binding;
|
||||
this.payloadSchemaId = payloadSchemaId;
|
||||
this.payloadSchemaHash = payloadSchemaHash;
|
||||
this.schemaSetHash = schemaSetHash;
|
||||
}
|
||||
|
||||
private CompiledIntegrationEventContract contract() {
|
||||
return contract;
|
||||
}
|
||||
|
||||
private CompiledPublicationBinding binding() {
|
||||
return binding;
|
||||
}
|
||||
|
||||
private String payloadSchemaId() {
|
||||
return payloadSchemaId;
|
||||
}
|
||||
|
||||
private Sha256 payloadSchemaHash() {
|
||||
return payloadSchemaHash;
|
||||
}
|
||||
|
||||
private Sha256 schemaSetHash() {
|
||||
return schemaSetHash;
|
||||
}
|
||||
}
|
||||
}
|
||||
+701
@@ -0,0 +1,701 @@
|
||||
package dev.caskeleton.adapter.outbound.messaging.envelope;
|
||||
|
||||
import com.networknt.schema.Error;
|
||||
import com.networknt.schema.Schema;
|
||||
import com.networknt.schema.SchemaLocation;
|
||||
import com.networknt.schema.SchemaRegistry;
|
||||
import com.networknt.schema.SchemaRegistryConfig;
|
||||
import com.networknt.schema.SpecificationVersion;
|
||||
import dev.caskeleton.application.messaging.contract.Sha256;
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.net.URI;
|
||||
import java.net.URISyntaxException;
|
||||
import java.nio.ByteBuffer;
|
||||
import java.nio.CharBuffer;
|
||||
import java.nio.charset.CharacterCodingException;
|
||||
import java.nio.charset.CodingErrorAction;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.security.MessageDigest;
|
||||
import java.security.NoSuchAlgorithmException;
|
||||
import java.util.ArrayDeque;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Comparator;
|
||||
import java.util.HashMap;
|
||||
import java.util.HexFormat;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import tools.jackson.core.StreamReadConstraints;
|
||||
import tools.jackson.core.StreamReadFeature;
|
||||
import tools.jackson.core.json.JsonFactory;
|
||||
import tools.jackson.databind.DeserializationFeature;
|
||||
import tools.jackson.databind.JsonNode;
|
||||
import tools.jackson.databind.ObjectMapper;
|
||||
import tools.jackson.databind.json.JsonMapper;
|
||||
|
||||
/**
|
||||
* Immutable, startup-compiled Draft 2020-12 registry backed only by explicitly supplied bytes.
|
||||
*
|
||||
* <p>Every reference is checked before NetworkNT compilation. After construction this type exposes
|
||||
* no loader, URL, file or classpath fetch operation.
|
||||
*/
|
||||
public final class LocalJsonSchemaRegistry {
|
||||
|
||||
public static final String DRAFT_2020_12 = "https://json-schema.org/draft/2020-12/schema";
|
||||
|
||||
private static final Set<String> KNOWN_VOCABULARIES =
|
||||
Set.of(
|
||||
"https://json-schema.org/draft/2020-12/vocab/core",
|
||||
"https://json-schema.org/draft/2020-12/vocab/applicator",
|
||||
"https://json-schema.org/draft/2020-12/vocab/unevaluated",
|
||||
"https://json-schema.org/draft/2020-12/vocab/validation",
|
||||
"https://json-schema.org/draft/2020-12/vocab/meta-data",
|
||||
"https://json-schema.org/draft/2020-12/vocab/format-annotation",
|
||||
"https://json-schema.org/draft/2020-12/vocab/format-assertion",
|
||||
"https://json-schema.org/draft/2020-12/vocab/content");
|
||||
private static final Set<String> UNSUPPORTED_CLOSED_SUBSET_KEYWORDS =
|
||||
Set.of("$anchor", "$dynamicRef", "$dynamicAnchor", "$recursiveRef", "$recursiveAnchor");
|
||||
private static final String PINNED_AUTHORITY_ROOT = "contracts/messaging/meta/draft-2020-12/";
|
||||
private static final String PINNED_AUTHORITY_MANIFEST =
|
||||
PINNED_AUTHORITY_ROOT + "authority.sha256";
|
||||
private static final byte[] PINNED_AUTHORITY_DOMAIN =
|
||||
"ca-skeleton.messaging.draft-2020-12-authority.v1".getBytes(StandardCharsets.UTF_8);
|
||||
private static final Map<String, String> PINNED_META_IDS =
|
||||
Map.ofEntries(
|
||||
Map.entry("draft/2020-12/schema", DRAFT_2020_12),
|
||||
Map.entry(
|
||||
"draft/2020-12/meta/applicator",
|
||||
"https://json-schema.org/draft/2020-12/meta/applicator"),
|
||||
Map.entry(
|
||||
"draft/2020-12/meta/content", "https://json-schema.org/draft/2020-12/meta/content"),
|
||||
Map.entry("draft/2020-12/meta/core", "https://json-schema.org/draft/2020-12/meta/core"),
|
||||
Map.entry(
|
||||
"draft/2020-12/meta/format-annotation",
|
||||
"https://json-schema.org/draft/2020-12/meta/format-annotation"),
|
||||
Map.entry(
|
||||
"draft/2020-12/meta/format-assertion",
|
||||
"https://json-schema.org/draft/2020-12/meta/format-assertion"),
|
||||
Map.entry(
|
||||
"draft/2020-12/meta/meta-data",
|
||||
"https://json-schema.org/draft/2020-12/meta/meta-data"),
|
||||
Map.entry(
|
||||
"draft/2020-12/meta/unevaluated",
|
||||
"https://json-schema.org/draft/2020-12/meta/unevaluated"),
|
||||
Map.entry(
|
||||
"draft/2020-12/meta/validation",
|
||||
"https://json-schema.org/draft/2020-12/meta/validation"));
|
||||
|
||||
private final EnvelopeAdmissionLimits limits;
|
||||
private final ObjectMapper mapper;
|
||||
private final Map<String, CompiledSchema> schemasById;
|
||||
private final Map<String, CompiledSchema> schemasByResource;
|
||||
private final Sha256 pinnedDraft202012AuthorityHash;
|
||||
|
||||
public LocalJsonSchemaRegistry(
|
||||
Map<String, SchemaSource> exactSources, EnvelopeAdmissionLimits limits) {
|
||||
if (exactSources == null || exactSources.isEmpty() || limits == null) {
|
||||
throw new IllegalArgumentException(
|
||||
"an explicit non-empty schema source map and limits are required");
|
||||
}
|
||||
this.limits = limits;
|
||||
this.mapper = strictMapper(limits);
|
||||
PinnedAuthority pinnedAuthority =
|
||||
loadPinnedAuthority(mapper, LocalJsonSchemaRegistry.class.getClassLoader());
|
||||
|
||||
Map<String, ParsedSource> parsedById = new LinkedHashMap<>();
|
||||
Map<String, ParsedSource> parsedByResource = new LinkedHashMap<>();
|
||||
exactSources.entrySet().stream()
|
||||
.sorted(Map.Entry.comparingByKey())
|
||||
.forEach(
|
||||
entry -> {
|
||||
SchemaSource source = entry.getValue();
|
||||
if (source == null || !entry.getKey().equals(source.resourcePath())) {
|
||||
throw new IllegalArgumentException(
|
||||
"schema resource map key must equal its non-null exact source path");
|
||||
}
|
||||
ParsedSource parsed = parseAndCheck(source);
|
||||
if (parsedByResource.putIfAbsent(source.resourcePath(), parsed) != null) {
|
||||
throw new IllegalArgumentException(
|
||||
"duplicate schema resource " + source.resourcePath());
|
||||
}
|
||||
if (parsedById.putIfAbsent(parsed.schemaId(), parsed) != null) {
|
||||
throw new IllegalArgumentException("duplicate schema $id " + parsed.schemaId());
|
||||
}
|
||||
});
|
||||
|
||||
validateAllReferences(parsedById);
|
||||
SchemaRegistry registry = compileRegistry(parsedById);
|
||||
validatePinnedAuthorityAgainstRuntime(registry, pinnedAuthority);
|
||||
validateAgainstBundledMetaSchema(registry, parsedById.values());
|
||||
|
||||
Map<String, CompiledSchema> byId = new LinkedHashMap<>();
|
||||
Map<String, CompiledSchema> byResource = new LinkedHashMap<>();
|
||||
parsedById.values().stream()
|
||||
.sorted(Comparator.comparing(ParsedSource::schemaId))
|
||||
.forEach(
|
||||
parsed -> {
|
||||
Schema compiled;
|
||||
try {
|
||||
compiled = registry.getSchema(SchemaLocation.of(parsed.schemaId()));
|
||||
} catch (RuntimeException exception) {
|
||||
throw new IllegalArgumentException(
|
||||
"schema compilation failed for " + parsed.resourcePath(), exception);
|
||||
}
|
||||
CompiledSchema value =
|
||||
new CompiledSchema(
|
||||
parsed.resourcePath(),
|
||||
parsed.schemaId(),
|
||||
parsed.exactBytes(),
|
||||
parsed.hash(),
|
||||
compiled);
|
||||
byId.put(parsed.schemaId(), value);
|
||||
byResource.put(parsed.resourcePath(), value);
|
||||
});
|
||||
this.schemasById = Map.copyOf(byId);
|
||||
this.schemasByResource = Map.copyOf(byResource);
|
||||
this.pinnedDraft202012AuthorityHash = pinnedAuthority.authorityHash();
|
||||
}
|
||||
|
||||
public List<String> validate(String schemaId, byte[] exactUtf8Bytes) {
|
||||
if (exactUtf8Bytes == null || exactUtf8Bytes.length == 0) {
|
||||
throw new IllegalArgumentException("exact UTF-8 JSON bytes must not be null or empty");
|
||||
}
|
||||
if (exactUtf8Bytes.length > limits.maximumEnvelopeBytes()) {
|
||||
throw new IllegalArgumentException("JSON document exceeds envelope byte admission limit");
|
||||
}
|
||||
JsonNode node;
|
||||
try {
|
||||
node = mapper.readTree(exactUtf8Bytes);
|
||||
} catch (RuntimeException exception) {
|
||||
String message;
|
||||
if (!strictUtf8Decodable(exactUtf8Bytes)) {
|
||||
message = "malformed UTF-8";
|
||||
} else if (exception.getMessage() != null
|
||||
&& exception.getMessage().toLowerCase(java.util.Locale.ROOT).contains("string")) {
|
||||
message = "JSON string exceeds parser admission limit";
|
||||
} else {
|
||||
message = "invalid bounded JSON document";
|
||||
}
|
||||
throw new IllegalArgumentException(message, exception);
|
||||
}
|
||||
return validate(schemaId, node);
|
||||
}
|
||||
|
||||
private List<String> validate(String schemaId, JsonNode instance) {
|
||||
CompiledSchema schema = schemasById.get(schemaId);
|
||||
if (schema == null || instance == null) {
|
||||
throw new IllegalArgumentException("unknown schema ID or null JSON tree");
|
||||
}
|
||||
enforceInstanceLimits(instance, 1);
|
||||
List<Error> errors;
|
||||
try {
|
||||
errors = schema.compiled().validate(instance);
|
||||
} catch (RuntimeException exception) {
|
||||
throw new IllegalArgumentException("local schema validation failed", exception);
|
||||
}
|
||||
return errors.stream()
|
||||
.sorted(
|
||||
Comparator.comparing((Error error) -> error.getInstanceLocation().toString())
|
||||
.thenComparing(Error::getKeyword))
|
||||
.limit(limits.maximumValidationErrors())
|
||||
.map(
|
||||
error ->
|
||||
error.getInstanceLocation()
|
||||
+ ":"
|
||||
+ (error.getKeyword() == null ? "schema" : error.getKeyword()))
|
||||
.toList();
|
||||
}
|
||||
|
||||
public String schemaId(String resourcePath) {
|
||||
return requireResource(resourcePath).schemaId();
|
||||
}
|
||||
|
||||
public Sha256 schemaHash(String resourcePath) {
|
||||
return requireResource(resourcePath).hash();
|
||||
}
|
||||
|
||||
public byte[] exactSchemaBytes(String resourcePath) {
|
||||
return requireResource(resourcePath).exactBytes();
|
||||
}
|
||||
|
||||
public Sha256 pinnedDraft202012AuthorityHash() {
|
||||
return pinnedDraft202012AuthorityHash;
|
||||
}
|
||||
|
||||
private CompiledSchema requireResource(String resourcePath) {
|
||||
CompiledSchema schema = schemasByResource.get(resourcePath);
|
||||
if (schema == null) {
|
||||
throw new IllegalArgumentException("unknown schema resource");
|
||||
}
|
||||
return schema;
|
||||
}
|
||||
|
||||
private ParsedSource parseAndCheck(SchemaSource source) {
|
||||
byte[] bytes = source.exactBytes();
|
||||
Sha256 actual = sha256(bytes);
|
||||
if (!actual.equals(source.expectedSha256())) {
|
||||
throw new IllegalArgumentException("schema checksum mismatch for " + source.resourcePath());
|
||||
}
|
||||
JsonNode root;
|
||||
try {
|
||||
root = mapper.readTree(bytes);
|
||||
} catch (RuntimeException exception) {
|
||||
throw new IllegalArgumentException(
|
||||
"schema is not a strict bounded JSON document: " + source.resourcePath(), exception);
|
||||
}
|
||||
if (!root.isObject()) {
|
||||
throw new IllegalArgumentException("schema root must be an object");
|
||||
}
|
||||
JsonNode dialect = root.get("$schema");
|
||||
if (dialect == null || !dialect.isTextual() || !DRAFT_2020_12.equals(dialect.textValue())) {
|
||||
throw new IllegalArgumentException("schema dialect must explicitly be Draft 2020-12");
|
||||
}
|
||||
JsonNode identifier = root.get("$id");
|
||||
if (identifier == null || !identifier.isTextual()) {
|
||||
throw new IllegalArgumentException("schema requires an explicit absolute immutable $id");
|
||||
}
|
||||
String schemaId = requireAbsoluteImmutableId(identifier.textValue());
|
||||
rejectUnsupportedSchemaLocations(root, true);
|
||||
validateVocabulary(root.get("$vocabulary"));
|
||||
return new ParsedSource(source.resourcePath(), schemaId, source.exactBytes(), actual, root);
|
||||
}
|
||||
|
||||
private static String requireAbsoluteImmutableId(String value) {
|
||||
try {
|
||||
URI id = new URI(value);
|
||||
if (!id.isAbsolute() || !"urn".equals(id.getScheme()) || id.getFragment() != null) {
|
||||
throw new IllegalArgumentException(
|
||||
"schema $id must use the exact urn scheme and must not contain a fragment");
|
||||
}
|
||||
return id.toASCIIString();
|
||||
} catch (URISyntaxException exception) {
|
||||
throw new IllegalArgumentException("schema $id must be an absolute immutable URI", exception);
|
||||
}
|
||||
}
|
||||
|
||||
private static void validateVocabulary(JsonNode vocabulary) {
|
||||
if (vocabulary == null) {
|
||||
return;
|
||||
}
|
||||
if (!vocabulary.isObject()) {
|
||||
throw new IllegalArgumentException("$vocabulary must be an object");
|
||||
}
|
||||
for (Map.Entry<String, JsonNode> entry : vocabulary.properties()) {
|
||||
if (!KNOWN_VOCABULARIES.contains(entry.getKey())) {
|
||||
throw new IllegalArgumentException("unknown JSON Schema vocabulary");
|
||||
}
|
||||
if (!entry.getValue().isBoolean()) {
|
||||
throw new IllegalArgumentException("vocabulary declarations must be boolean");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static void rejectUnsupportedSchemaLocations(JsonNode node, boolean root) {
|
||||
if (node.isObject()) {
|
||||
if (!root && node.has("$id")) {
|
||||
throw new IllegalArgumentException(
|
||||
"nested $id is unsupported by the closed local schema subset");
|
||||
}
|
||||
for (String keyword : UNSUPPORTED_CLOSED_SUBSET_KEYWORDS) {
|
||||
if (node.has(keyword)) {
|
||||
throw new IllegalArgumentException(
|
||||
keyword + " is unsupported by the closed local schema subset");
|
||||
}
|
||||
}
|
||||
node.properties().forEach(entry -> rejectUnsupportedSchemaLocations(entry.getValue(), false));
|
||||
} else if (node.isArray()) {
|
||||
node.forEach(child -> rejectUnsupportedSchemaLocations(child, false));
|
||||
}
|
||||
}
|
||||
|
||||
private void validateAllReferences(Map<String, ParsedSource> parsedById) {
|
||||
for (ParsedSource source : parsedById.values()) {
|
||||
List<ReferenceTarget> roots = collectReferences(source.schemaId(), source.root());
|
||||
for (ReferenceTarget target : roots) {
|
||||
validateReferenceChain(target, parsedById, new ArrayDeque<>(), 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void validateReferenceChain(
|
||||
ReferenceTarget target,
|
||||
Map<String, ParsedSource> sources,
|
||||
ArrayDeque<ReferenceTarget> stack,
|
||||
int depth) {
|
||||
if (depth > limits.maximumReferenceDepth() || stack.contains(target)) {
|
||||
throw new IllegalArgumentException("schema reference cycle/depth exceeds supported depth");
|
||||
}
|
||||
ParsedSource source = sources.get(target.schemaId());
|
||||
if (source == null) {
|
||||
throw new IllegalArgumentException("schema reference is not in the exact supplied $id map");
|
||||
}
|
||||
JsonNode referenced = resolveFragment(source.root(), target.fragment());
|
||||
if (referenced == null) {
|
||||
throw new IllegalArgumentException("schema reference fragment does not exist");
|
||||
}
|
||||
stack.addLast(target);
|
||||
for (ReferenceTarget nested : collectReferences(source.schemaId(), referenced)) {
|
||||
validateReferenceChain(nested, sources, stack, depth + 1);
|
||||
}
|
||||
stack.removeLast();
|
||||
}
|
||||
|
||||
private static List<ReferenceTarget> collectReferences(String ownerId, JsonNode node) {
|
||||
List<ReferenceTarget> result = new ArrayList<>();
|
||||
collectReferences(ownerId, node, result);
|
||||
return result;
|
||||
}
|
||||
|
||||
private static void collectReferences(
|
||||
String ownerId, JsonNode node, List<ReferenceTarget> result) {
|
||||
if (node.isObject()) {
|
||||
JsonNode ref = node.get("$ref");
|
||||
if (ref != null) {
|
||||
if (!ref.isTextual()) {
|
||||
throw new IllegalArgumentException("$ref must be a string");
|
||||
}
|
||||
result.add(parseReference(ownerId, ref.textValue()));
|
||||
}
|
||||
node.properties().forEach(entry -> collectReferences(ownerId, entry.getValue(), result));
|
||||
} else if (node.isArray()) {
|
||||
node.forEach(child -> collectReferences(ownerId, child, result));
|
||||
}
|
||||
}
|
||||
|
||||
private static ReferenceTarget parseReference(String ownerId, String value) {
|
||||
try {
|
||||
URI reference = new URI(value);
|
||||
if (reference.isAbsolute() && !"urn".equals(reference.getScheme())) {
|
||||
throw new IllegalArgumentException(
|
||||
"absolute schema reference $ref must use the exact urn scheme");
|
||||
}
|
||||
String fragment = reference.getRawFragment();
|
||||
if (fragment != null && !fragment.isEmpty() && !fragment.startsWith("/")) {
|
||||
throw new IllegalArgumentException("only JSON Pointer schema fragments are supported");
|
||||
}
|
||||
if (!reference.isAbsolute() && !value.startsWith("#")) {
|
||||
throw new IllegalArgumentException("relative external schema reference forbidden");
|
||||
}
|
||||
String base =
|
||||
value.startsWith("#")
|
||||
? ownerId
|
||||
: new URI(reference.getScheme(), reference.getSchemeSpecificPart(), null)
|
||||
.toASCIIString();
|
||||
return new ReferenceTarget(base, fragment == null ? "" : fragment);
|
||||
} catch (URISyntaxException exception) {
|
||||
throw new IllegalArgumentException("invalid schema reference", exception);
|
||||
}
|
||||
}
|
||||
|
||||
private static JsonNode resolveFragment(JsonNode root, String fragment) {
|
||||
if (fragment.isEmpty()) {
|
||||
return root;
|
||||
}
|
||||
JsonNode resolved = root.at(fragment);
|
||||
return resolved.isMissingNode() ? null : resolved;
|
||||
}
|
||||
|
||||
private static SchemaRegistry compileRegistry(Map<String, ParsedSource> parsedById) {
|
||||
Map<String, String> exactSchemas = new HashMap<>();
|
||||
parsedById.forEach(
|
||||
(id, source) ->
|
||||
exactSchemas.put(id, new String(source.exactBytes(), StandardCharsets.UTF_8)));
|
||||
SchemaRegistryConfig config =
|
||||
SchemaRegistryConfig.builder()
|
||||
.formatAssertionsEnabled(true)
|
||||
.typeLoose(false)
|
||||
.failFast(false)
|
||||
.cacheRefs(true)
|
||||
.build();
|
||||
try {
|
||||
return SchemaRegistry.withDefaultDialect(
|
||||
SpecificationVersion.DRAFT_2020_12,
|
||||
builder -> builder.schemaRegistryConfig(config).schemas(exactSchemas));
|
||||
} catch (RuntimeException exception) {
|
||||
throw new IllegalArgumentException(
|
||||
"local Draft 2020-12 registry compilation failed", exception);
|
||||
}
|
||||
}
|
||||
|
||||
private static void validateAgainstBundledMetaSchema(
|
||||
SchemaRegistry registry, java.util.Collection<ParsedSource> sources) {
|
||||
Schema metaSchema;
|
||||
try {
|
||||
metaSchema = registry.getSchema(SchemaLocation.of(DRAFT_2020_12));
|
||||
} catch (RuntimeException exception) {
|
||||
throw new IllegalArgumentException(
|
||||
"bundled Draft 2020-12 meta-schema is unavailable", exception);
|
||||
}
|
||||
for (ParsedSource source : sources) {
|
||||
List<Error> errors = metaSchema.validate(source.root());
|
||||
if (!errors.isEmpty()) {
|
||||
throw new IllegalArgumentException(
|
||||
"schema fails bundled Draft 2020-12 meta-schema validation: " + source.resourcePath());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static PinnedAuthority loadPinnedAuthority(ObjectMapper mapper, ClassLoader classLoader) {
|
||||
byte[] manifestBytes = readRequiredClasspathBytes(classLoader, PINNED_AUTHORITY_MANIFEST);
|
||||
String manifest;
|
||||
try {
|
||||
manifest =
|
||||
StandardCharsets.UTF_8
|
||||
.newDecoder()
|
||||
.onMalformedInput(CodingErrorAction.REPORT)
|
||||
.onUnmappableCharacter(CodingErrorAction.REPORT)
|
||||
.decode(ByteBuffer.wrap(manifestBytes))
|
||||
.toString();
|
||||
} catch (CharacterCodingException exception) {
|
||||
throw new IllegalArgumentException(
|
||||
"pinned Draft 2020-12 authority manifest is not strict UTF-8", exception);
|
||||
}
|
||||
|
||||
Map<String, Sha256> expectedByPath = new LinkedHashMap<>();
|
||||
for (String line : manifest.lines().toList()) {
|
||||
if (!line.matches("[a-f0-9]{64} draft/2020-12/(schema|meta/[a-z-]+)")) {
|
||||
throw new IllegalArgumentException(
|
||||
"pinned Draft 2020-12 authority manifest has an invalid entry");
|
||||
}
|
||||
String path = line.substring(66);
|
||||
Sha256 expected = new Sha256(HexFormat.of().parseHex(line.substring(0, 64)));
|
||||
if (expectedByPath.putIfAbsent(path, expected) != null) {
|
||||
throw new IllegalArgumentException(
|
||||
"pinned Draft 2020-12 authority manifest contains a duplicate path");
|
||||
}
|
||||
}
|
||||
if (!expectedByPath.keySet().equals(PINNED_META_IDS.keySet())) {
|
||||
throw new IllegalArgumentException(
|
||||
"pinned Draft 2020-12 authority manifest has an incomplete resource set");
|
||||
}
|
||||
MessageDigest authorityDigest = sha256Digest();
|
||||
authorityDigest.update(PINNED_AUTHORITY_DOMAIN);
|
||||
authorityDigest.update((byte) 0);
|
||||
Map<String, JsonNode> nodesById = new LinkedHashMap<>();
|
||||
expectedByPath.entrySet().stream()
|
||||
.sorted(Map.Entry.comparingByKey())
|
||||
.forEach(
|
||||
entry -> {
|
||||
String path = entry.getKey();
|
||||
byte[] pinnedBytes =
|
||||
readRequiredClasspathBytes(classLoader, PINNED_AUTHORITY_ROOT + path);
|
||||
if (!sha256(pinnedBytes).equals(entry.getValue())) {
|
||||
throw new IllegalArgumentException(
|
||||
"pinned Draft 2020-12 authority checksum mismatch for " + path);
|
||||
}
|
||||
JsonNode node;
|
||||
try {
|
||||
node = mapper.readTree(pinnedBytes);
|
||||
} catch (RuntimeException exception) {
|
||||
throw new IllegalArgumentException(
|
||||
"pinned Draft 2020-12 authority is not bounded strict JSON", exception);
|
||||
}
|
||||
String expectedId = PINNED_META_IDS.get(path);
|
||||
JsonNode id = node.get("$id");
|
||||
if (id == null || !id.isTextual() || !expectedId.equals(id.textValue())) {
|
||||
throw new IllegalArgumentException(
|
||||
"pinned Draft 2020-12 authority has an unexpected $id for " + path);
|
||||
}
|
||||
nodesById.put(expectedId, node);
|
||||
updateLengthPrefixed(authorityDigest, path.getBytes(StandardCharsets.UTF_8));
|
||||
updateLengthPrefixed(authorityDigest, pinnedBytes);
|
||||
});
|
||||
return new PinnedAuthority(Map.copyOf(nodesById), new Sha256(authorityDigest.digest()));
|
||||
}
|
||||
|
||||
private static void validatePinnedAuthorityAgainstRuntime(
|
||||
SchemaRegistry registry, PinnedAuthority authority) {
|
||||
for (Map.Entry<String, JsonNode> entry : authority.nodesById().entrySet()) {
|
||||
Schema runtimeSchema;
|
||||
try {
|
||||
runtimeSchema = registry.getSchema(SchemaLocation.of(entry.getKey()));
|
||||
} catch (RuntimeException exception) {
|
||||
throw new IllegalArgumentException(
|
||||
"NetworkNT runtime is missing pinned Draft 2020-12 authority " + entry.getKey(),
|
||||
exception);
|
||||
}
|
||||
if (!entry.getValue().equals(runtimeSchema.getSchemaNode())) {
|
||||
throw new IllegalArgumentException(
|
||||
"NetworkNT runtime Draft 2020-12 authority tree differs from pinned exact bytes");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static byte[] readRequiredClasspathBytes(ClassLoader classLoader, String path) {
|
||||
try (InputStream input = classLoader.getResourceAsStream(path)) {
|
||||
if (input == null) {
|
||||
throw new IllegalArgumentException(
|
||||
"required pinned Draft 2020-12 classpath resource is missing: " + path);
|
||||
}
|
||||
return input.readAllBytes();
|
||||
} catch (IOException exception) {
|
||||
throw new IllegalArgumentException(
|
||||
"required pinned Draft 2020-12 classpath resource could not be read: " + path, exception);
|
||||
}
|
||||
}
|
||||
|
||||
private static void updateLengthPrefixed(MessageDigest digest, byte[] value) {
|
||||
digest.update(ByteBuffer.allocate(Integer.BYTES).putInt(value.length).array());
|
||||
digest.update(value);
|
||||
}
|
||||
|
||||
private void enforceInstanceLimits(JsonNode node, int depth) {
|
||||
if (depth > limits.maximumDepth()) {
|
||||
throw new IllegalArgumentException("JSON tree exceeds depth admission limit");
|
||||
}
|
||||
if (node.isObject()) {
|
||||
if (node.size() > limits.maximumObjectProperties()) {
|
||||
throw new IllegalArgumentException("JSON object exceeds properties admission limit");
|
||||
}
|
||||
for (Map.Entry<String, JsonNode> entry : node.properties()) {
|
||||
enforceString(entry.getKey());
|
||||
enforceInstanceLimits(entry.getValue(), depth + 1);
|
||||
}
|
||||
} else if (node.isArray()) {
|
||||
if (node.size() > limits.maximumArrayItems()) {
|
||||
throw new IllegalArgumentException("JSON array exceeds items admission limit");
|
||||
}
|
||||
node.forEach(child -> enforceInstanceLimits(child, depth + 1));
|
||||
} else if (node.isTextual()) {
|
||||
enforceString(node.textValue());
|
||||
if (node.textValue().codePointCount(0, node.textValue().length())
|
||||
> limits.maximumRegexInputCharacters()) {
|
||||
throw new IllegalArgumentException("JSON string exceeds regex input admission limit");
|
||||
}
|
||||
} else if (node.isNumber()) {
|
||||
long digits = node.toString().codePoints().filter(Character::isDigit).count();
|
||||
if (digits > limits.maximumNumberDigits()) {
|
||||
throw new IllegalArgumentException("JSON number exceeds digits admission limit");
|
||||
}
|
||||
if (node.isFloatingPointNumber() && !Double.isFinite(node.doubleValue())) {
|
||||
throw new IllegalArgumentException("JSON number must be finite");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void enforceString(String value) {
|
||||
if (value.codePointCount(0, value.length()) > limits.maximumStringCharacters()) {
|
||||
throw new IllegalArgumentException("JSON string exceeds character admission limit");
|
||||
}
|
||||
byte[] bytes = strictUtf8(value);
|
||||
if (bytes.length > limits.maximumStringUtf8Bytes()) {
|
||||
throw new IllegalArgumentException("JSON string exceeds UTF-8 byte admission limit");
|
||||
}
|
||||
}
|
||||
|
||||
private static boolean strictUtf8Decodable(byte[] bytes) {
|
||||
try {
|
||||
StandardCharsets.UTF_8
|
||||
.newDecoder()
|
||||
.onMalformedInput(CodingErrorAction.REPORT)
|
||||
.onUnmappableCharacter(CodingErrorAction.REPORT)
|
||||
.decode(ByteBuffer.wrap(bytes));
|
||||
return true;
|
||||
} catch (CharacterCodingException exception) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private static byte[] strictUtf8(String value) {
|
||||
try {
|
||||
ByteBuffer encoded =
|
||||
StandardCharsets.UTF_8
|
||||
.newEncoder()
|
||||
.onMalformedInput(CodingErrorAction.REPORT)
|
||||
.onUnmappableCharacter(CodingErrorAction.REPORT)
|
||||
.encode(CharBuffer.wrap(value));
|
||||
byte[] result = new byte[encoded.remaining()];
|
||||
encoded.get(result);
|
||||
return result;
|
||||
} catch (CharacterCodingException exception) {
|
||||
throw new IllegalArgumentException("JSON string contains an unpaired surrogate", exception);
|
||||
}
|
||||
}
|
||||
|
||||
private static ObjectMapper strictMapper(EnvelopeAdmissionLimits limits) {
|
||||
JsonFactory factory =
|
||||
JsonFactory.builder()
|
||||
.streamReadConstraints(
|
||||
StreamReadConstraints.builder()
|
||||
.maxNestingDepth(limits.maximumDepth())
|
||||
.maxDocumentLength(limits.maximumEnvelopeBytes())
|
||||
.maxNumberLength(limits.maximumNumberDigits())
|
||||
.maxStringLength(limits.maximumStringCharacters())
|
||||
.maxNameLength(limits.maximumStringCharacters())
|
||||
.build())
|
||||
.enable(StreamReadFeature.STRICT_DUPLICATE_DETECTION)
|
||||
.build();
|
||||
return JsonMapper.builder(factory)
|
||||
.enable(DeserializationFeature.FAIL_ON_READING_DUP_TREE_KEY)
|
||||
.enable(DeserializationFeature.FAIL_ON_TRAILING_TOKENS)
|
||||
.build();
|
||||
}
|
||||
|
||||
private static Sha256 sha256(byte[] bytes) {
|
||||
return new Sha256(sha256Digest().digest(bytes));
|
||||
}
|
||||
|
||||
private static MessageDigest sha256Digest() {
|
||||
try {
|
||||
return MessageDigest.getInstance("SHA-256");
|
||||
} catch (NoSuchAlgorithmException exception) {
|
||||
throw new IllegalStateException("Java runtime does not provide SHA-256", exception);
|
||||
}
|
||||
}
|
||||
|
||||
@SuppressWarnings("ArrayRecordComponent")
|
||||
public record SchemaSource(String resourcePath, byte[] exactBytes, Sha256 expectedSha256) {
|
||||
|
||||
public SchemaSource {
|
||||
if (resourcePath == null
|
||||
|| resourcePath.isBlank()
|
||||
|| resourcePath.startsWith("/")
|
||||
|| resourcePath.contains("..")
|
||||
|| exactBytes == null
|
||||
|| exactBytes.length == 0
|
||||
|| expectedSha256 == null) {
|
||||
throw new IllegalArgumentException("exact local schema source fields are required");
|
||||
}
|
||||
exactBytes = exactBytes.clone();
|
||||
}
|
||||
|
||||
@Override
|
||||
public byte[] exactBytes() {
|
||||
return exactBytes.clone();
|
||||
}
|
||||
}
|
||||
|
||||
@SuppressWarnings("ArrayRecordComponent")
|
||||
private record ParsedSource(
|
||||
String resourcePath, String schemaId, byte[] exactBytes, Sha256 hash, JsonNode root) {
|
||||
|
||||
private ParsedSource {
|
||||
exactBytes = exactBytes.clone();
|
||||
}
|
||||
|
||||
@Override
|
||||
public byte[] exactBytes() {
|
||||
return exactBytes.clone();
|
||||
}
|
||||
}
|
||||
|
||||
@SuppressWarnings("ArrayRecordComponent")
|
||||
private record CompiledSchema(
|
||||
String resourcePath, String schemaId, byte[] exactBytes, Sha256 hash, Schema compiled) {
|
||||
|
||||
private CompiledSchema {
|
||||
exactBytes = exactBytes.clone();
|
||||
}
|
||||
|
||||
@Override
|
||||
public byte[] exactBytes() {
|
||||
return exactBytes.clone();
|
||||
}
|
||||
}
|
||||
|
||||
private record PinnedAuthority(Map<String, JsonNode> nodesById, Sha256 authorityHash) {}
|
||||
|
||||
private record ReferenceTarget(String schemaId, String fragment) {}
|
||||
}
|
||||
+9
@@ -0,0 +1,9 @@
|
||||
41da76f5afb7ce062d248f762463a92f7ca47e4e0f905b224ba6afeef91ded0f draft/2020-12/schema
|
||||
c4a6e4147b91fef7fea6dc058cb1bf93402f7414b76578a8b16aaf1dad6aacef draft/2020-12/meta/applicator
|
||||
08343747764e4a5814262793cf4d652057a7913863c5950d43297e8e1fdac5b6 draft/2020-12/meta/content
|
||||
c2d12a8e4dd11d336dfc83a3f663aa4c69f0b49b3beb094ffeb25b5316f4803d draft/2020-12/meta/core
|
||||
abc775adfefd89d22358170d9bf93f4ebd2349563bbbedd60f02bef7c812bcc0 draft/2020-12/meta/format-annotation
|
||||
6a5a8e13c605e3eff51f9bf8da18078880d81ff1634e391760ccc2e16ee2146f draft/2020-12/meta/format-assertion
|
||||
8f76d6e14f41b9b92ef933b708cdc5144c8b5268651ad11918485fb1754f1c76 draft/2020-12/meta/meta-data
|
||||
2dbfbcb73994b670b0976492adee1fffb46c21682784d2f5a4ca561f9e2d0cb4 draft/2020-12/meta/unevaluated
|
||||
7010a31e541f32d2be721e2de348df75c9b36876a3ed304877fc0abda1d37a58 draft/2020-12/meta/validation
|
||||
+48
@@ -0,0 +1,48 @@
|
||||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"$id": "https://json-schema.org/draft/2020-12/meta/applicator",
|
||||
"$vocabulary": {
|
||||
"https://json-schema.org/draft/2020-12/vocab/applicator": true
|
||||
},
|
||||
"$dynamicAnchor": "meta",
|
||||
|
||||
"title": "Applicator vocabulary meta-schema",
|
||||
"type": ["object", "boolean"],
|
||||
"properties": {
|
||||
"prefixItems": { "$ref": "#/$defs/schemaArray" },
|
||||
"items": { "$dynamicRef": "#meta" },
|
||||
"contains": { "$dynamicRef": "#meta" },
|
||||
"additionalProperties": { "$dynamicRef": "#meta" },
|
||||
"properties": {
|
||||
"type": "object",
|
||||
"additionalProperties": { "$dynamicRef": "#meta" },
|
||||
"default": {}
|
||||
},
|
||||
"patternProperties": {
|
||||
"type": "object",
|
||||
"additionalProperties": { "$dynamicRef": "#meta" },
|
||||
"propertyNames": { "format": "regex" },
|
||||
"default": {}
|
||||
},
|
||||
"dependentSchemas": {
|
||||
"type": "object",
|
||||
"additionalProperties": { "$dynamicRef": "#meta" },
|
||||
"default": {}
|
||||
},
|
||||
"propertyNames": { "$dynamicRef": "#meta" },
|
||||
"if": { "$dynamicRef": "#meta" },
|
||||
"then": { "$dynamicRef": "#meta" },
|
||||
"else": { "$dynamicRef": "#meta" },
|
||||
"allOf": { "$ref": "#/$defs/schemaArray" },
|
||||
"anyOf": { "$ref": "#/$defs/schemaArray" },
|
||||
"oneOf": { "$ref": "#/$defs/schemaArray" },
|
||||
"not": { "$dynamicRef": "#meta" }
|
||||
},
|
||||
"$defs": {
|
||||
"schemaArray": {
|
||||
"type": "array",
|
||||
"minItems": 1,
|
||||
"items": { "$dynamicRef": "#meta" }
|
||||
}
|
||||
}
|
||||
}
|
||||
+17
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"$id": "https://json-schema.org/draft/2020-12/meta/content",
|
||||
"$vocabulary": {
|
||||
"https://json-schema.org/draft/2020-12/vocab/content": true
|
||||
},
|
||||
"$dynamicAnchor": "meta",
|
||||
|
||||
"title": "Content vocabulary meta-schema",
|
||||
|
||||
"type": ["object", "boolean"],
|
||||
"properties": {
|
||||
"contentEncoding": { "type": "string" },
|
||||
"contentMediaType": { "type": "string" },
|
||||
"contentSchema": { "$dynamicRef": "#meta" }
|
||||
}
|
||||
}
|
||||
+51
@@ -0,0 +1,51 @@
|
||||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"$id": "https://json-schema.org/draft/2020-12/meta/core",
|
||||
"$vocabulary": {
|
||||
"https://json-schema.org/draft/2020-12/vocab/core": true
|
||||
},
|
||||
"$dynamicAnchor": "meta",
|
||||
|
||||
"title": "Core vocabulary meta-schema",
|
||||
"type": ["object", "boolean"],
|
||||
"properties": {
|
||||
"$id": {
|
||||
"$ref": "#/$defs/uriReferenceString",
|
||||
"$comment": "Non-empty fragments not allowed.",
|
||||
"pattern": "^[^#]*#?$"
|
||||
},
|
||||
"$schema": { "$ref": "#/$defs/uriString" },
|
||||
"$ref": { "$ref": "#/$defs/uriReferenceString" },
|
||||
"$anchor": { "$ref": "#/$defs/anchorString" },
|
||||
"$dynamicRef": { "$ref": "#/$defs/uriReferenceString" },
|
||||
"$dynamicAnchor": { "$ref": "#/$defs/anchorString" },
|
||||
"$vocabulary": {
|
||||
"type": "object",
|
||||
"propertyNames": { "$ref": "#/$defs/uriString" },
|
||||
"additionalProperties": {
|
||||
"type": "boolean"
|
||||
}
|
||||
},
|
||||
"$comment": {
|
||||
"type": "string"
|
||||
},
|
||||
"$defs": {
|
||||
"type": "object",
|
||||
"additionalProperties": { "$dynamicRef": "#meta" }
|
||||
}
|
||||
},
|
||||
"$defs": {
|
||||
"anchorString": {
|
||||
"type": "string",
|
||||
"pattern": "^[A-Za-z_][-A-Za-z0-9._]*$"
|
||||
},
|
||||
"uriString": {
|
||||
"type": "string",
|
||||
"format": "uri"
|
||||
},
|
||||
"uriReferenceString": {
|
||||
"type": "string",
|
||||
"format": "uri-reference"
|
||||
}
|
||||
}
|
||||
}
|
||||
+14
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"$id": "https://json-schema.org/draft/2020-12/meta/format-annotation",
|
||||
"$vocabulary": {
|
||||
"https://json-schema.org/draft/2020-12/vocab/format-annotation": true
|
||||
},
|
||||
"$dynamicAnchor": "meta",
|
||||
|
||||
"title": "Format vocabulary meta-schema for annotation results",
|
||||
"type": ["object", "boolean"],
|
||||
"properties": {
|
||||
"format": { "type": "string" }
|
||||
}
|
||||
}
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"$id": "https://json-schema.org/draft/2020-12/meta/format-assertion",
|
||||
"$dynamicAnchor": "meta",
|
||||
|
||||
"title": "Format vocabulary meta-schema for assertion results",
|
||||
"type": ["object", "boolean"],
|
||||
"properties": {
|
||||
"format": { "type": "string" }
|
||||
}
|
||||
}
|
||||
+37
@@ -0,0 +1,37 @@
|
||||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"$id": "https://json-schema.org/draft/2020-12/meta/meta-data",
|
||||
"$vocabulary": {
|
||||
"https://json-schema.org/draft/2020-12/vocab/meta-data": true
|
||||
},
|
||||
"$dynamicAnchor": "meta",
|
||||
|
||||
"title": "Meta-data vocabulary meta-schema",
|
||||
|
||||
"type": ["object", "boolean"],
|
||||
"properties": {
|
||||
"title": {
|
||||
"type": "string"
|
||||
},
|
||||
"description": {
|
||||
"type": "string"
|
||||
},
|
||||
"default": true,
|
||||
"deprecated": {
|
||||
"type": "boolean",
|
||||
"default": false
|
||||
},
|
||||
"readOnly": {
|
||||
"type": "boolean",
|
||||
"default": false
|
||||
},
|
||||
"writeOnly": {
|
||||
"type": "boolean",
|
||||
"default": false
|
||||
},
|
||||
"examples": {
|
||||
"type": "array",
|
||||
"items": true
|
||||
}
|
||||
}
|
||||
}
|
||||
+15
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"$id": "https://json-schema.org/draft/2020-12/meta/unevaluated",
|
||||
"$vocabulary": {
|
||||
"https://json-schema.org/draft/2020-12/vocab/unevaluated": true
|
||||
},
|
||||
"$dynamicAnchor": "meta",
|
||||
|
||||
"title": "Unevaluated applicator vocabulary meta-schema",
|
||||
"type": ["object", "boolean"],
|
||||
"properties": {
|
||||
"unevaluatedItems": { "$dynamicRef": "#meta" },
|
||||
"unevaluatedProperties": { "$dynamicRef": "#meta" }
|
||||
}
|
||||
}
|
||||
+98
@@ -0,0 +1,98 @@
|
||||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"$id": "https://json-schema.org/draft/2020-12/meta/validation",
|
||||
"$vocabulary": {
|
||||
"https://json-schema.org/draft/2020-12/vocab/validation": true
|
||||
},
|
||||
"$dynamicAnchor": "meta",
|
||||
|
||||
"title": "Validation vocabulary meta-schema",
|
||||
"type": ["object", "boolean"],
|
||||
"properties": {
|
||||
"type": {
|
||||
"anyOf": [
|
||||
{ "$ref": "#/$defs/simpleTypes" },
|
||||
{
|
||||
"type": "array",
|
||||
"items": { "$ref": "#/$defs/simpleTypes" },
|
||||
"minItems": 1,
|
||||
"uniqueItems": true
|
||||
}
|
||||
]
|
||||
},
|
||||
"const": true,
|
||||
"enum": {
|
||||
"type": "array",
|
||||
"items": true
|
||||
},
|
||||
"multipleOf": {
|
||||
"type": "number",
|
||||
"exclusiveMinimum": 0
|
||||
},
|
||||
"maximum": {
|
||||
"type": "number"
|
||||
},
|
||||
"exclusiveMaximum": {
|
||||
"type": "number"
|
||||
},
|
||||
"minimum": {
|
||||
"type": "number"
|
||||
},
|
||||
"exclusiveMinimum": {
|
||||
"type": "number"
|
||||
},
|
||||
"maxLength": { "$ref": "#/$defs/nonNegativeInteger" },
|
||||
"minLength": { "$ref": "#/$defs/nonNegativeIntegerDefault0" },
|
||||
"pattern": {
|
||||
"type": "string",
|
||||
"format": "regex"
|
||||
},
|
||||
"maxItems": { "$ref": "#/$defs/nonNegativeInteger" },
|
||||
"minItems": { "$ref": "#/$defs/nonNegativeIntegerDefault0" },
|
||||
"uniqueItems": {
|
||||
"type": "boolean",
|
||||
"default": false
|
||||
},
|
||||
"maxContains": { "$ref": "#/$defs/nonNegativeInteger" },
|
||||
"minContains": {
|
||||
"$ref": "#/$defs/nonNegativeInteger",
|
||||
"default": 1
|
||||
},
|
||||
"maxProperties": { "$ref": "#/$defs/nonNegativeInteger" },
|
||||
"minProperties": { "$ref": "#/$defs/nonNegativeIntegerDefault0" },
|
||||
"required": { "$ref": "#/$defs/stringArray" },
|
||||
"dependentRequired": {
|
||||
"type": "object",
|
||||
"additionalProperties": {
|
||||
"$ref": "#/$defs/stringArray"
|
||||
}
|
||||
}
|
||||
},
|
||||
"$defs": {
|
||||
"nonNegativeInteger": {
|
||||
"type": "integer",
|
||||
"minimum": 0
|
||||
},
|
||||
"nonNegativeIntegerDefault0": {
|
||||
"$ref": "#/$defs/nonNegativeInteger",
|
||||
"default": 0
|
||||
},
|
||||
"simpleTypes": {
|
||||
"enum": [
|
||||
"array",
|
||||
"boolean",
|
||||
"integer",
|
||||
"null",
|
||||
"number",
|
||||
"object",
|
||||
"string"
|
||||
]
|
||||
},
|
||||
"stringArray": {
|
||||
"type": "array",
|
||||
"items": { "type": "string" },
|
||||
"uniqueItems": true,
|
||||
"default": []
|
||||
}
|
||||
}
|
||||
}
|
||||
+58
@@ -0,0 +1,58 @@
|
||||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"$id": "https://json-schema.org/draft/2020-12/schema",
|
||||
"$vocabulary": {
|
||||
"https://json-schema.org/draft/2020-12/vocab/core": true,
|
||||
"https://json-schema.org/draft/2020-12/vocab/applicator": true,
|
||||
"https://json-schema.org/draft/2020-12/vocab/unevaluated": true,
|
||||
"https://json-schema.org/draft/2020-12/vocab/validation": true,
|
||||
"https://json-schema.org/draft/2020-12/vocab/meta-data": true,
|
||||
"https://json-schema.org/draft/2020-12/vocab/format-annotation": true,
|
||||
"https://json-schema.org/draft/2020-12/vocab/content": true
|
||||
},
|
||||
"$dynamicAnchor": "meta",
|
||||
|
||||
"title": "Core and Validation specifications meta-schema",
|
||||
"allOf": [
|
||||
{"$ref": "meta/core"},
|
||||
{"$ref": "meta/applicator"},
|
||||
{"$ref": "meta/unevaluated"},
|
||||
{"$ref": "meta/validation"},
|
||||
{"$ref": "meta/meta-data"},
|
||||
{"$ref": "meta/format-annotation"},
|
||||
{"$ref": "meta/content"}
|
||||
],
|
||||
"type": ["object", "boolean"],
|
||||
"$comment": "This meta-schema also defines keywords that have appeared in previous drafts in order to prevent incompatible extensions as they remain in common use.",
|
||||
"properties": {
|
||||
"definitions": {
|
||||
"$comment": "\"definitions\" has been replaced by \"$defs\".",
|
||||
"type": "object",
|
||||
"additionalProperties": { "$dynamicRef": "#meta" },
|
||||
"deprecated": true,
|
||||
"default": {}
|
||||
},
|
||||
"dependencies": {
|
||||
"$comment": "\"dependencies\" has been split and replaced by \"dependentSchemas\" and \"dependentRequired\" in order to serve their differing semantics.",
|
||||
"type": "object",
|
||||
"additionalProperties": {
|
||||
"anyOf": [
|
||||
{ "$dynamicRef": "#meta" },
|
||||
{ "$ref": "meta/validation#/$defs/stringArray" }
|
||||
]
|
||||
},
|
||||
"deprecated": true,
|
||||
"default": {}
|
||||
},
|
||||
"$recursiveAnchor": {
|
||||
"$comment": "\"$recursiveAnchor\" has been replaced by \"$dynamicAnchor\".",
|
||||
"$ref": "meta/core#/$defs/anchorString",
|
||||
"deprecated": true
|
||||
},
|
||||
"$recursiveRef": {
|
||||
"$comment": "\"$recursiveRef\" has been replaced by \"$dynamicRef\".",
|
||||
"$ref": "meta/core#/$defs/uriReferenceString",
|
||||
"deprecated": true
|
||||
}
|
||||
}
|
||||
}
|
||||
+88
@@ -0,0 +1,88 @@
|
||||
package dev.caskeleton.adapter.outbound.messaging;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
import dev.caskeleton.adapter.outbound.messaging.core.DisabledMessagePublisher;
|
||||
import dev.caskeleton.adapter.outbound.messaging.core.MessageBroker;
|
||||
import dev.caskeleton.adapter.outbound.messaging.core.MessagePublisher;
|
||||
import dev.caskeleton.adapter.outbound.messaging.kafka.KafkaAdapterConfig;
|
||||
import dev.caskeleton.adapter.outbound.messaging.kafka.KafkaSender;
|
||||
import dev.caskeleton.adapter.outbound.messaging.outbox.DisabledOutboxMessagePublisher;
|
||||
import dev.caskeleton.adapter.outbound.support.FailOpenDependencyLogger;
|
||||
import dev.caskeleton.application.outbox.OutboxMessagePublishPort;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.beans.factory.NoSuchBeanDefinitionException;
|
||||
import org.springframework.boot.test.context.runner.ApplicationContextRunner;
|
||||
|
||||
class MessagingConfigTest {
|
||||
|
||||
private final ApplicationContextRunner runner =
|
||||
new ApplicationContextRunner()
|
||||
.withBean(FailOpenDependencyLogger.class, FailOpenDependencyLogger::new)
|
||||
.withUserConfiguration(MessagingConfig.class);
|
||||
|
||||
@Test
|
||||
void blankBrokerBindsDisabledSentinelsCharacterization() {
|
||||
runner
|
||||
.withPropertyValues("app.messaging.broker=")
|
||||
.run(
|
||||
context -> {
|
||||
assertThat(context).hasNotFailed();
|
||||
assertThat(context.getBean(MessagePublisher.class))
|
||||
.isInstanceOf(DisabledMessagePublisher.class);
|
||||
assertThat(context.getBean(OutboxMessagePublishPort.class))
|
||||
.isInstanceOf(DisabledOutboxMessagePublisher.class);
|
||||
});
|
||||
}
|
||||
|
||||
@Test
|
||||
void selectedKafkaBrokerWithoutProjectSenderFailsStartupCharacterization() {
|
||||
runner
|
||||
.withUserConfiguration(KafkaAdapterConfig.class)
|
||||
.withPropertyValues(
|
||||
"app.messaging.broker=kafka", "app.messaging.kafka.brokers=localhost:9092")
|
||||
.run(
|
||||
context -> {
|
||||
assertThat(context).hasFailed();
|
||||
assertThat(rootCause(context.getStartupFailure()))
|
||||
.isInstanceOf(NoSuchBeanDefinitionException.class)
|
||||
.hasMessageContaining(KafkaSender.class.getName());
|
||||
});
|
||||
}
|
||||
|
||||
@Test
|
||||
void selectedBrokerIdMismatchFailsStartupCharacterization() {
|
||||
runner
|
||||
.withBean(MessageBroker.class, () -> brokerReporting("other"))
|
||||
.withPropertyValues("app.messaging.broker=kafka")
|
||||
.run(
|
||||
context ->
|
||||
assertThat(context)
|
||||
.hasFailed()
|
||||
.getFailure()
|
||||
.hasRootCauseMessage(
|
||||
"app.messaging.broker=kafka but the active MessageBroker reports brokerId"
|
||||
+ " 'other'"));
|
||||
}
|
||||
|
||||
private static MessageBroker brokerReporting(String brokerId) {
|
||||
return new MessageBroker() {
|
||||
@Override
|
||||
public String brokerId() {
|
||||
return brokerId;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void send(
|
||||
dev.caskeleton.adapter.outbound.messaging.core.OutboundMessage ignoredMessage) {}
|
||||
};
|
||||
}
|
||||
|
||||
private static Throwable rootCause(Throwable failure) {
|
||||
Throwable current = failure;
|
||||
while (current.getCause() != null) {
|
||||
current = current.getCause();
|
||||
}
|
||||
return current;
|
||||
}
|
||||
}
|
||||
+97
@@ -0,0 +1,97 @@
|
||||
package dev.caskeleton.adapter.outbound.messaging.config;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
|
||||
import dev.caskeleton.adapter.outbound.messaging.config.MessagingCapabilityCardRegistry.CapabilityCard;
|
||||
import dev.caskeleton.adapter.outbound.messaging.config.MessagingCapabilityCardRegistry.CardRole;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
class MessagingCapabilityCardRegistryTest {
|
||||
|
||||
@Test
|
||||
void exposesExactlyTheFirstR2TupleWithOneClosedCardPerRole() {
|
||||
MessagingCapabilityCardRegistry registry = MessagingCapabilityCardRegistry.exactFirstR2();
|
||||
|
||||
assertThat(registry.cards())
|
||||
.extracting(CapabilityCard::cardId)
|
||||
.containsExactly(
|
||||
"messaging-outbox-publish.v1",
|
||||
"kafka-spring-acknowledged-idempotent.v1",
|
||||
"postgresql-polling-outbox.v2",
|
||||
"postgresql-per-record-jit-claim.v1",
|
||||
"json-schema-envelope.v1",
|
||||
"external-topic-validated.v1",
|
||||
"kafka-sasl-ssl-scram-sha-512.v1",
|
||||
"kafka-compression-none.v1",
|
||||
"per-key-normal-path-sequence-detectable.v1",
|
||||
"same-postgresql-transaction-resource.v1",
|
||||
"authenticated-internal-web-disposition.v1");
|
||||
assertThat(registry.cards())
|
||||
.extracting(CapabilityCard::role)
|
||||
.containsExactlyInAnyOrder(
|
||||
CardRole.SEMANTIC,
|
||||
CardRole.PRODUCER,
|
||||
CardRole.DISPATCH,
|
||||
CardRole.CLAIM,
|
||||
CardRole.SERIALIZATION,
|
||||
CardRole.TOPIC,
|
||||
CardRole.SECURITY,
|
||||
CardRole.COMPRESSION,
|
||||
CardRole.ORDERING,
|
||||
CardRole.TRANSACTION,
|
||||
CardRole.OPERATOR_CONTROL);
|
||||
assertThat(registry.selection().producerCardId())
|
||||
.isEqualTo("kafka-spring-acknowledged-idempotent.v1");
|
||||
assertThat(registry.selection().serializationCardId()).isEqualTo("json-schema-envelope.v1");
|
||||
assertThat(registry.claimsReleaseEligibility()).isFalse();
|
||||
assertThatThrownBy(() -> registry.cards().add(registry.cards().getFirst()))
|
||||
.isInstanceOf(UnsupportedOperationException.class);
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsNullUnknownConsumerCdcEosSchemaRegistryAndDuplicateCards() {
|
||||
List<CapabilityCard> exact =
|
||||
new ArrayList<>(MessagingCapabilityCardRegistry.exactFirstR2().cards());
|
||||
|
||||
assertThatThrownBy(() -> MessagingCapabilityCardRegistry.compile(null))
|
||||
.isInstanceOf(IllegalArgumentException.class);
|
||||
assertRejected(exact, new CapabilityCard("future-provider.v1", CardRole.PRODUCER), "unknown");
|
||||
assertRejected(
|
||||
exact, new CapabilityCard("messaging-inbox-consume.v1", CardRole.SEMANTIC), "unknown");
|
||||
assertRejected(
|
||||
exact, new CapabilityCard("messaging-cdc-dispatch.v1", CardRole.DISPATCH), "unknown");
|
||||
assertRejected(
|
||||
exact, new CapabilityCard("kafka-exactly-once.v1", CardRole.PRODUCER), "unknown");
|
||||
assertRejected(
|
||||
exact, new CapabilityCard("json-schema-registry.v1", CardRole.SERIALIZATION), "unknown");
|
||||
|
||||
exact.add(exact.getFirst());
|
||||
assertThatThrownBy(() -> MessagingCapabilityCardRegistry.compile(exact))
|
||||
.isInstanceOf(IllegalArgumentException.class)
|
||||
.hasMessageContaining("duplicate");
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsRoleSubstitutionEvenWhenTheCardIdItselfIsKnown() {
|
||||
List<CapabilityCard> cards =
|
||||
new ArrayList<>(MessagingCapabilityCardRegistry.exactFirstR2().cards());
|
||||
cards.set(
|
||||
1, new CapabilityCard("kafka-spring-acknowledged-idempotent.v1", CardRole.SERIALIZATION));
|
||||
|
||||
assertThatThrownBy(() -> MessagingCapabilityCardRegistry.compile(cards))
|
||||
.isInstanceOf(IllegalArgumentException.class)
|
||||
.hasMessageContaining("role");
|
||||
}
|
||||
|
||||
private static void assertRejected(
|
||||
List<CapabilityCard> exact, CapabilityCard unsupported, String message) {
|
||||
List<CapabilityCard> cards = new ArrayList<>(exact);
|
||||
cards.set(1, unsupported);
|
||||
assertThatThrownBy(() -> MessagingCapabilityCardRegistry.compile(cards))
|
||||
.isInstanceOf(IllegalArgumentException.class)
|
||||
.hasMessageContaining(message);
|
||||
}
|
||||
}
|
||||
+564
@@ -0,0 +1,564 @@
|
||||
package dev.caskeleton.adapter.outbound.messaging.contract;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
|
||||
import dev.caskeleton.application.messaging.contract.ContractDescriptor;
|
||||
import dev.caskeleton.application.messaging.contract.ContractId;
|
||||
import dev.caskeleton.application.messaging.contract.IntegrationEventContractContribution;
|
||||
import dev.caskeleton.application.messaging.contract.IntegrationPayload;
|
||||
import dev.caskeleton.application.messaging.contract.LogicalDestinationId;
|
||||
import dev.caskeleton.application.messaging.contract.SchemaResourceId;
|
||||
import dev.caskeleton.application.messaging.contract.Sha256;
|
||||
import java.lang.reflect.Modifier;
|
||||
import java.math.BigDecimal;
|
||||
import java.math.BigInteger;
|
||||
import java.time.Duration;
|
||||
import java.util.ArrayList;
|
||||
import java.util.HexFormat;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
class ContractCatalogCompilerTest {
|
||||
|
||||
private static final ContractCatalogCompiler COMPILER = new ContractCatalogCompiler();
|
||||
|
||||
@Test
|
||||
void compilesAnImmutableClosedCatalogFromExactRecordTypeTokens() {
|
||||
ArrayList<String> componentOrder = new ArrayList<>(List.of("eventId", "count"));
|
||||
Contribution<ValidPayload> contribution =
|
||||
contribution(
|
||||
"fixture.event.created",
|
||||
1,
|
||||
ValidPayload.class,
|
||||
componentOrder,
|
||||
"contracts/messaging/fixture.event.created/v1.schema.json",
|
||||
hash(1),
|
||||
descriptor("fixture-events", true, 256));
|
||||
|
||||
List<CompiledIntegrationEventContract> catalog = COMPILER.compile(List.of(contribution));
|
||||
componentOrder.set(0, "mutated");
|
||||
|
||||
assertThat(catalog).hasSize(1);
|
||||
CompiledIntegrationEventContract compiled = catalog.getFirst();
|
||||
assertThat(compiled.stableKey()).isEqualTo("fixture.event.created:v1");
|
||||
assertThat(compiled.exactPayloadRecordType()).isEqualTo(ValidPayload.class);
|
||||
assertThat(compiled.exactPayloadRecordType().isRecord()).isTrue();
|
||||
assertThat(Modifier.isFinal(compiled.exactPayloadRecordType().getModifiers())).isTrue();
|
||||
assertThat(compiled.canonicalRecordComponentOrder()).containsExactly("eventId", "count");
|
||||
assertThat(compiled.descriptor().logicalDestination())
|
||||
.isEqualTo(new LogicalDestinationId("fixture-events"));
|
||||
assertThatThrownBy(() -> catalog.add(compiled))
|
||||
.isInstanceOf(UnsupportedOperationException.class);
|
||||
assertThatThrownBy(() -> compiled.canonicalRecordComponentOrder().add("another"))
|
||||
.isInstanceOf(UnsupportedOperationException.class);
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsNullInputsDuplicateStableSchemaAndExactPayloadIdentities() {
|
||||
Contribution<ValidPayload> first = validContribution("fixture.event.created", 1);
|
||||
|
||||
assertThatThrownBy(() -> COMPILER.compile(null)).isInstanceOf(IllegalArgumentException.class);
|
||||
List<Contribution<ValidPayload>> withNull = new ArrayList<>();
|
||||
withNull.add(first);
|
||||
withNull.add(null);
|
||||
assertThatThrownBy(() -> COMPILER.compile(withNull))
|
||||
.isInstanceOf(IllegalArgumentException.class)
|
||||
.hasMessageContaining("contribution");
|
||||
assertThatThrownBy(
|
||||
() -> COMPILER.compile(List.of(first, validContribution("fixture.event.created", 1))))
|
||||
.isInstanceOf(IllegalArgumentException.class)
|
||||
.hasMessageContaining("contract");
|
||||
assertThatThrownBy(
|
||||
() ->
|
||||
COMPILER.compile(
|
||||
List.of(
|
||||
first,
|
||||
contribution(
|
||||
"fixture.other.created",
|
||||
1,
|
||||
OtherPayload.class,
|
||||
List.of("value"),
|
||||
first.payloadSchemaResource().value(),
|
||||
hash(2),
|
||||
descriptor("fixture-events", true, 256)))))
|
||||
.isInstanceOf(IllegalArgumentException.class)
|
||||
.hasMessageContaining("schema");
|
||||
assertThatThrownBy(
|
||||
() ->
|
||||
COMPILER.compile(
|
||||
List.of(
|
||||
first,
|
||||
contribution(
|
||||
"fixture.other.created",
|
||||
1,
|
||||
ValidPayload.class,
|
||||
List.of("eventId", "count"),
|
||||
"contracts/messaging/fixture.other.created/v1.schema.json",
|
||||
hash(2),
|
||||
descriptor("fixture-events", true, 256)))))
|
||||
.isInstanceOf(IllegalArgumentException.class)
|
||||
.hasMessageContaining("payload");
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsNonPositiveVersionAndInvalidExactPayloadKinds() {
|
||||
assertThatThrownBy(
|
||||
() ->
|
||||
COMPILER.compile(
|
||||
List.of(
|
||||
contribution(
|
||||
"fixture.event.created",
|
||||
0,
|
||||
ValidPayload.class,
|
||||
List.of("eventId", "count"),
|
||||
"contracts/messaging/fixture.event.created/v1.schema.json",
|
||||
hash(1),
|
||||
descriptor("fixture-events", true, 256)))))
|
||||
.isInstanceOf(IllegalArgumentException.class)
|
||||
.hasMessageContaining("version");
|
||||
assertThatThrownBy(
|
||||
() ->
|
||||
COMPILER.compile(
|
||||
List.of(
|
||||
contribution(
|
||||
"fixture.event.created",
|
||||
1,
|
||||
MutablePayload.class,
|
||||
List.of(),
|
||||
"contracts/messaging/fixture.event.created/v1.schema.json",
|
||||
hash(1),
|
||||
descriptor("fixture-events", true, 256)))))
|
||||
.isInstanceOf(IllegalArgumentException.class)
|
||||
.hasMessageContaining("record");
|
||||
assertThatThrownBy(
|
||||
() ->
|
||||
COMPILER.compile(
|
||||
List.of(
|
||||
contribution(
|
||||
"fixture.event.created",
|
||||
1,
|
||||
null,
|
||||
List.of(),
|
||||
"contracts/messaging/fixture.event.created/v1.schema.json",
|
||||
hash(1),
|
||||
descriptor("fixture-events", true, 256)))))
|
||||
.isInstanceOf(IllegalArgumentException.class)
|
||||
.hasMessageContaining("payload");
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsNullBlankDuplicateAndReflectionMismatchedComponentOrders() {
|
||||
assertInvalidOrder(null);
|
||||
assertInvalidOrder(List.of("eventId", " "));
|
||||
assertInvalidOrder(List.of("eventId", "eventId"));
|
||||
assertInvalidOrder(List.of("count", "eventId"));
|
||||
assertInvalidOrder(List.of("eventId"));
|
||||
assertInvalidOrder(List.of("eventId", "count", "unknown"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsMissingDescriptorsAndLogicalDestinationDriftAcrossPayloadVersions() {
|
||||
assertThatThrownBy(
|
||||
() ->
|
||||
COMPILER.compile(
|
||||
List.of(
|
||||
contribution(
|
||||
"fixture.event.created",
|
||||
1,
|
||||
ValidPayload.class,
|
||||
List.of("eventId", "count"),
|
||||
"contracts/messaging/fixture.event.created/v1.schema.json",
|
||||
hash(1),
|
||||
null))))
|
||||
.isInstanceOf(IllegalArgumentException.class)
|
||||
.hasMessageContaining("descriptor");
|
||||
|
||||
assertThatThrownBy(
|
||||
() ->
|
||||
COMPILER.compile(
|
||||
List.of(
|
||||
validContribution("fixture.event.created", 1),
|
||||
contribution(
|
||||
"fixture.event.created",
|
||||
2,
|
||||
OtherPayload.class,
|
||||
List.of("value"),
|
||||
"contracts/messaging/fixture.event.created/v2.schema.json",
|
||||
hash(2),
|
||||
descriptor("other-events", true, 256)))))
|
||||
.isInstanceOf(IllegalArgumentException.class)
|
||||
.hasMessageContaining("logical destination");
|
||||
}
|
||||
|
||||
@Test
|
||||
void compiledContractCannotBeConstructedWithAnInvalidExactTypeOrComponentOrder() {
|
||||
Contribution<ValidPayload> contribution = validContribution("fixture.event.created", 1);
|
||||
|
||||
assertThatThrownBy(
|
||||
() ->
|
||||
new CompiledIntegrationEventContract(
|
||||
contribution.contractId(),
|
||||
contribution.payloadVersion(),
|
||||
MutablePayload.class,
|
||||
List.of(),
|
||||
contribution.payloadSchemaResource(),
|
||||
contribution.payloadSchemaHash(),
|
||||
contribution.descriptor()))
|
||||
.isInstanceOf(IllegalArgumentException.class)
|
||||
.hasMessageContaining("record");
|
||||
assertThatThrownBy(
|
||||
() ->
|
||||
new CompiledIntegrationEventContract(
|
||||
contribution.contractId(),
|
||||
contribution.payloadVersion(),
|
||||
ValidPayload.class,
|
||||
List.of("count", "eventId"),
|
||||
contribution.payloadSchemaResource(),
|
||||
contribution.payloadSchemaHash(),
|
||||
contribution.descriptor()))
|
||||
.isInstanceOf(IllegalArgumentException.class)
|
||||
.hasMessageContaining("component");
|
||||
}
|
||||
|
||||
@Test
|
||||
void compiledContractUsesOnlyAStaticPublicCompositionBridgeWithoutReflectionLeak()
|
||||
throws NoSuchMethodException {
|
||||
assertThat(Modifier.isFinal(CompiledIntegrationEventContract.class.getModifiers())).isTrue();
|
||||
assertThat(CompiledIntegrationEventContract.class.isRecord()).isFalse();
|
||||
assertThat(CompiledIntegrationEventContract.class.getConstructors()).isEmpty();
|
||||
assertThat(CompiledIntegrationEventContract.class.getDeclaredConstructors())
|
||||
.allSatisfy(
|
||||
constructor -> assertThat(Modifier.isPublic(constructor.getModifiers())).isFalse());
|
||||
assertThat(
|
||||
Modifier.isPublic(
|
||||
ContractCatalogCompiler.class
|
||||
.getDeclaredMethod("compile", List.class)
|
||||
.getModifiers()))
|
||||
.isFalse();
|
||||
assertThat(
|
||||
ContractCatalogCompiler.class
|
||||
.getDeclaredMethod("compileExact", List.class)
|
||||
.getModifiers())
|
||||
.satisfies(
|
||||
modifiers -> {
|
||||
assertThat(Modifier.isPublic(modifiers)).isTrue();
|
||||
assertThat(Modifier.isStatic(modifiers)).isTrue();
|
||||
});
|
||||
assertThat(
|
||||
java.util.Arrays.stream(CompiledIntegrationEventContract.class.getMethods())
|
||||
.map(java.lang.reflect.Method::getReturnType))
|
||||
.doesNotContain(java.lang.reflect.Method.class);
|
||||
}
|
||||
|
||||
@Test
|
||||
void snapshotsEveryContributionAccessorExactlyOnceIncludingAStatefulSchemaHash() {
|
||||
StatefulContribution<ValidPayload> contribution =
|
||||
new StatefulContribution<>(
|
||||
new ContractId("fixture.event.created"),
|
||||
1,
|
||||
ValidPayload.class,
|
||||
List.of("eventId", "count"),
|
||||
new SchemaResourceId("contracts/messaging/fixture.event.created/v1.schema.json"),
|
||||
List.of(hash(1), hash(2)),
|
||||
List.of(descriptor("fixture-events", true, 256)));
|
||||
|
||||
CompiledIntegrationEventContract compiled = COMPILER.compile(List.of(contribution)).getFirst();
|
||||
|
||||
assertThat(compiled.payloadSchemaHash()).isEqualTo(hash(1));
|
||||
assertThat(contribution.callCounts()).containsOnly(1);
|
||||
}
|
||||
|
||||
@Test
|
||||
void statefulDescriptorCannotBypassCrossVersionLogicalDestinationDrift() {
|
||||
StatefulContribution<OtherPayload> statefulSecondVersion =
|
||||
new StatefulContribution<>(
|
||||
new ContractId("fixture.event.created"),
|
||||
2,
|
||||
OtherPayload.class,
|
||||
List.of("value"),
|
||||
new SchemaResourceId("contracts/messaging/fixture.event.created/v2.schema.json"),
|
||||
List.of(hash(2)),
|
||||
List.of(
|
||||
descriptor("other-events", true, 256),
|
||||
descriptor("fixture-events", true, 256),
|
||||
descriptor("other-events", true, 256)));
|
||||
|
||||
assertThatThrownBy(
|
||||
() ->
|
||||
COMPILER.compile(
|
||||
List.of(validContribution("fixture.event.created", 1), statefulSecondVersion)))
|
||||
.isInstanceOf(IllegalArgumentException.class)
|
||||
.hasMessageContaining("logical destination");
|
||||
}
|
||||
|
||||
@Test
|
||||
void recursivelyFreezesOnlyTheClosedDeclaredGenericPayloadGraph() {
|
||||
Contribution<ClosedGraphPayload> contribution =
|
||||
contribution(
|
||||
"fixture.closed.graph",
|
||||
1,
|
||||
ClosedGraphPayload.class,
|
||||
List.of("name", "count", "amount", "state", "nested"),
|
||||
"contracts/messaging/fixture.closed.graph/v1.schema.json",
|
||||
hash(7),
|
||||
descriptor("fixture-events", true, 256));
|
||||
|
||||
CompiledIntegrationEventContract contract = COMPILER.compile(List.of(contribution)).getFirst();
|
||||
CompiledIntegrationEventContract.PayloadShape root =
|
||||
CompiledIntegrationEventContract.payloadShapeOf(contract);
|
||||
List<CompiledIntegrationEventContract.PayloadComponent> components =
|
||||
CompiledIntegrationEventContract.PayloadShape.componentsOf(root);
|
||||
|
||||
assertThat(CompiledIntegrationEventContract.PayloadShape.kindOf(root))
|
||||
.isEqualTo(CompiledIntegrationEventContract.PayloadKind.RECORD);
|
||||
assertThat(CompiledIntegrationEventContract.PayloadShape.exactJavaTypeOf(root))
|
||||
.isEqualTo(ClosedGraphPayload.class);
|
||||
assertThat(components.stream().map(CompiledIntegrationEventContract.PayloadComponent::nameOf))
|
||||
.containsExactly("name", "count", "amount", "state", "nested");
|
||||
CompiledIntegrationEventContract.PayloadShape optional =
|
||||
CompiledIntegrationEventContract.PayloadComponent.shapeOf(components.getLast());
|
||||
assertThat(CompiledIntegrationEventContract.PayloadShape.kindOf(optional))
|
||||
.isEqualTo(CompiledIntegrationEventContract.PayloadKind.OPTIONAL);
|
||||
CompiledIntegrationEventContract.PayloadShape list =
|
||||
CompiledIntegrationEventContract.PayloadShape.elementShapeOf(optional);
|
||||
assertThat(CompiledIntegrationEventContract.PayloadShape.kindOf(list))
|
||||
.isEqualTo(CompiledIntegrationEventContract.PayloadKind.LIST);
|
||||
assertThat(
|
||||
CompiledIntegrationEventContract.PayloadShape.exactJavaTypeOf(
|
||||
CompiledIntegrationEventContract.PayloadShape.elementShapeOf(list)))
|
||||
.isEqualTo(ClosedNested.class);
|
||||
assertThatThrownBy(() -> components.add(components.getFirst()))
|
||||
.isInstanceOf(UnsupportedOperationException.class);
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsOpenRawWildcardMapJsonTreeInterfaceAndGenericRecordGraphs() {
|
||||
assertUnsupportedGraph(ObjectPayload.class, List.of("value"));
|
||||
assertUnsupportedGraph(InterfacePayload.class, List.of("value"));
|
||||
assertUnsupportedGraph(MapPayload.class, List.of("value"));
|
||||
assertUnsupportedGraph(RawListPayload.class, List.of("value"));
|
||||
assertUnsupportedGraph(WildcardListPayload.class, List.of("value"));
|
||||
assertUnsupportedGraph(JsonTreePayload.class, List.of("value"));
|
||||
assertUnsupportedGraph(GenericRecordPayload.class, List.of("value"));
|
||||
}
|
||||
|
||||
private static void assertInvalidOrder(List<String> order) {
|
||||
assertThatThrownBy(
|
||||
() ->
|
||||
COMPILER.compile(
|
||||
List.of(
|
||||
contribution(
|
||||
"fixture.event.created",
|
||||
1,
|
||||
ValidPayload.class,
|
||||
order,
|
||||
"contracts/messaging/fixture.event.created/v1.schema.json",
|
||||
hash(1),
|
||||
descriptor("fixture-events", true, 256)))))
|
||||
.isInstanceOf(IllegalArgumentException.class)
|
||||
.hasMessageContaining("component");
|
||||
}
|
||||
|
||||
@SuppressWarnings({"rawtypes", "unchecked"})
|
||||
private static void assertUnsupportedGraph(
|
||||
Class<? extends IntegrationPayload> type, List<String> order) {
|
||||
IntegrationEventContractContribution<?> contribution =
|
||||
contribution(
|
||||
"fixture.unsupported.graph",
|
||||
1,
|
||||
(Class) type,
|
||||
order,
|
||||
"contracts/messaging/fixture.unsupported.graph/v1.schema.json",
|
||||
hash(9),
|
||||
descriptor("fixture-events", true, 256));
|
||||
assertThatThrownBy(() -> COMPILER.compile(List.of(contribution)))
|
||||
.isInstanceOf(IllegalArgumentException.class)
|
||||
.hasMessageMatching(".*(unsupported declared|raw Optional/List).*");
|
||||
}
|
||||
|
||||
private static Contribution<ValidPayload> validContribution(String contractId, int version) {
|
||||
return contribution(
|
||||
contractId,
|
||||
version,
|
||||
ValidPayload.class,
|
||||
List.of("eventId", "count"),
|
||||
"contracts/messaging/" + contractId + "/v" + version + ".schema.json",
|
||||
hash(version),
|
||||
descriptor("fixture-events", true, 256));
|
||||
}
|
||||
|
||||
private static ContractDescriptor descriptor(
|
||||
String destination, boolean orderingRequired, int maximumEnvelopeBytes) {
|
||||
return new ContractDescriptor(
|
||||
"adapter-outbound-messaging",
|
||||
new LogicalDestinationId(destination),
|
||||
"json-schema-envelope-v1",
|
||||
orderingRequired,
|
||||
128,
|
||||
maximumEnvelopeBytes,
|
||||
ContractDescriptor.SensitivityClassification.INTERNAL,
|
||||
Duration.ofDays(7));
|
||||
}
|
||||
|
||||
private static Sha256 hash(int seed) {
|
||||
return new Sha256(HexFormat.of().parseHex(String.format("%064x", seed)));
|
||||
}
|
||||
|
||||
private static <P extends IntegrationPayload> Contribution<P> contribution(
|
||||
String contractId,
|
||||
int version,
|
||||
Class<P> type,
|
||||
List<String> componentOrder,
|
||||
String schemaResource,
|
||||
Sha256 schemaHash,
|
||||
ContractDescriptor descriptor) {
|
||||
return new Contribution<>(
|
||||
new ContractId(contractId),
|
||||
version,
|
||||
type,
|
||||
componentOrder,
|
||||
new SchemaResourceId(schemaResource),
|
||||
schemaHash,
|
||||
descriptor);
|
||||
}
|
||||
|
||||
private record Contribution<P extends IntegrationPayload>(
|
||||
ContractId contractId,
|
||||
int payloadVersion,
|
||||
Class<P> exactPayloadRecordType,
|
||||
List<String> canonicalRecordComponentOrder,
|
||||
SchemaResourceId payloadSchemaResource,
|
||||
Sha256 payloadSchemaHash,
|
||||
ContractDescriptor descriptor)
|
||||
implements IntegrationEventContractContribution<P> {}
|
||||
|
||||
private static final class StatefulContribution<P extends IntegrationPayload>
|
||||
implements IntegrationEventContractContribution<P> {
|
||||
|
||||
private final ContractId contractId;
|
||||
private final int payloadVersion;
|
||||
private final Class<P> exactPayloadRecordType;
|
||||
private final List<String> canonicalRecordComponentOrder;
|
||||
private final SchemaResourceId payloadSchemaResource;
|
||||
private final List<Sha256> payloadSchemaHashes;
|
||||
private final List<ContractDescriptor> descriptors;
|
||||
private int contractIdCalls;
|
||||
private int payloadVersionCalls;
|
||||
private int exactPayloadRecordTypeCalls;
|
||||
private int canonicalRecordComponentOrderCalls;
|
||||
private int payloadSchemaResourceCalls;
|
||||
private int payloadSchemaHashCalls;
|
||||
private int descriptorCalls;
|
||||
|
||||
private StatefulContribution(
|
||||
ContractId contractId,
|
||||
int payloadVersion,
|
||||
Class<P> exactPayloadRecordType,
|
||||
List<String> canonicalRecordComponentOrder,
|
||||
SchemaResourceId payloadSchemaResource,
|
||||
List<Sha256> payloadSchemaHashes,
|
||||
List<ContractDescriptor> descriptors) {
|
||||
this.contractId = contractId;
|
||||
this.payloadVersion = payloadVersion;
|
||||
this.exactPayloadRecordType = exactPayloadRecordType;
|
||||
this.canonicalRecordComponentOrder = canonicalRecordComponentOrder;
|
||||
this.payloadSchemaResource = payloadSchemaResource;
|
||||
this.payloadSchemaHashes = payloadSchemaHashes;
|
||||
this.descriptors = descriptors;
|
||||
}
|
||||
|
||||
@Override
|
||||
public ContractId contractId() {
|
||||
contractIdCalls++;
|
||||
return contractId;
|
||||
}
|
||||
|
||||
@Override
|
||||
public int payloadVersion() {
|
||||
payloadVersionCalls++;
|
||||
return payloadVersion;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Class<P> exactPayloadRecordType() {
|
||||
exactPayloadRecordTypeCalls++;
|
||||
return exactPayloadRecordType;
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<String> canonicalRecordComponentOrder() {
|
||||
canonicalRecordComponentOrderCalls++;
|
||||
return canonicalRecordComponentOrder;
|
||||
}
|
||||
|
||||
@Override
|
||||
public SchemaResourceId payloadSchemaResource() {
|
||||
payloadSchemaResourceCalls++;
|
||||
return payloadSchemaResource;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Sha256 payloadSchemaHash() {
|
||||
int index = Math.min(payloadSchemaHashCalls++, payloadSchemaHashes.size() - 1);
|
||||
return payloadSchemaHashes.get(index);
|
||||
}
|
||||
|
||||
@Override
|
||||
public ContractDescriptor descriptor() {
|
||||
int index = Math.min(descriptorCalls++, descriptors.size() - 1);
|
||||
return descriptors.get(index);
|
||||
}
|
||||
|
||||
private List<Integer> callCounts() {
|
||||
return List.of(
|
||||
contractIdCalls,
|
||||
payloadVersionCalls,
|
||||
exactPayloadRecordTypeCalls,
|
||||
canonicalRecordComponentOrderCalls,
|
||||
payloadSchemaResourceCalls,
|
||||
payloadSchemaHashCalls,
|
||||
descriptorCalls);
|
||||
}
|
||||
}
|
||||
|
||||
private record ValidPayload(String eventId, int count) implements IntegrationPayload {}
|
||||
|
||||
private record OtherPayload(String value) implements IntegrationPayload {}
|
||||
|
||||
private record ClosedGraphPayload(
|
||||
String name,
|
||||
long count,
|
||||
BigDecimal amount,
|
||||
GraphState state,
|
||||
Optional<List<ClosedNested>> nested)
|
||||
implements IntegrationPayload {}
|
||||
|
||||
private record ClosedNested(String code, BigInteger rank) {}
|
||||
|
||||
private enum GraphState {
|
||||
READY
|
||||
}
|
||||
|
||||
private record ObjectPayload(Object value) implements IntegrationPayload {}
|
||||
|
||||
private record InterfacePayload(CharSequence value) implements IntegrationPayload {}
|
||||
|
||||
private record MapPayload(Map<String, String> value) implements IntegrationPayload {}
|
||||
|
||||
@SuppressWarnings("rawtypes")
|
||||
private record RawListPayload(List value) implements IntegrationPayload {}
|
||||
|
||||
private record WildcardListPayload(List<?> value) implements IntegrationPayload {}
|
||||
|
||||
private record JsonTreePayload(tools.jackson.databind.JsonNode value)
|
||||
implements IntegrationPayload {}
|
||||
|
||||
private record GenericRecordPayload(GenericNested<String> value) implements IntegrationPayload {}
|
||||
|
||||
private record GenericNested<T>(T value) {}
|
||||
|
||||
private static final class MutablePayload implements IntegrationPayload {}
|
||||
}
|
||||
+112
@@ -0,0 +1,112 @@
|
||||
package dev.caskeleton.adapter.outbound.messaging.contract;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
import dev.caskeleton.application.messaging.contract.ContractDescriptor;
|
||||
import dev.caskeleton.application.messaging.contract.ContractId;
|
||||
import dev.caskeleton.application.messaging.contract.IntegrationEventContractContribution;
|
||||
import dev.caskeleton.application.messaging.contract.IntegrationPayload;
|
||||
import dev.caskeleton.application.messaging.contract.LogicalDestinationId;
|
||||
import dev.caskeleton.application.messaging.contract.SchemaResourceId;
|
||||
import dev.caskeleton.application.messaging.contract.Sha256;
|
||||
import java.time.Duration;
|
||||
import java.util.HexFormat;
|
||||
import java.util.List;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
class ContractCatalogDigestTest {
|
||||
|
||||
@Test
|
||||
void digestIsDeterministicForEmptyAndInputOrderIndependentForNonEmptyCatalogs() {
|
||||
CompiledIntegrationEventContract first = contract("fixture.a.created", 1, hash(1), 256);
|
||||
CompiledIntegrationEventContract second = contract("fixture.b.created", 2, hash(2), 512);
|
||||
|
||||
assertThat(ContractCatalogDigest.compute(List.of()).toString())
|
||||
.isEqualTo("a86e4f7991482c9ee798a606da37ae379e36d81ca2fa3c2ea190ba2b0cee1da0");
|
||||
assertThat(ContractCatalogDigest.compute(List.of(first, second)))
|
||||
.isEqualTo(ContractCatalogDigest.compute(List.of(second, first)));
|
||||
}
|
||||
|
||||
@Test
|
||||
void digestChangesForSchemaHashDescriptorAndCanonicalComponentOrderSemantics() {
|
||||
CompiledIntegrationEventContract baseline = contract("fixture.a.created", 1, hash(1), 256);
|
||||
CompiledIntegrationEventContract schemaChanged = contract("fixture.a.created", 1, hash(2), 256);
|
||||
CompiledIntegrationEventContract descriptorChanged =
|
||||
contract("fixture.a.created", 1, hash(1), 512);
|
||||
CompiledIntegrationEventContract orderChanged =
|
||||
compile(
|
||||
contribution(
|
||||
"fixture.a.created",
|
||||
1,
|
||||
ReorderedDigestPayload.class,
|
||||
List.of("count", "eventId"),
|
||||
hash(1),
|
||||
256));
|
||||
|
||||
Sha256 digest = ContractCatalogDigest.compute(List.of(baseline));
|
||||
|
||||
assertThat(ContractCatalogDigest.compute(List.of(schemaChanged))).isNotEqualTo(digest);
|
||||
assertThat(ContractCatalogDigest.compute(List.of(descriptorChanged))).isNotEqualTo(digest);
|
||||
assertThat(ContractCatalogDigest.compute(List.of(orderChanged))).isNotEqualTo(digest);
|
||||
}
|
||||
|
||||
private static CompiledIntegrationEventContract contract(
|
||||
String contractId, int version, Sha256 schemaHash, int maximumEnvelopeBytes) {
|
||||
return compile(
|
||||
contribution(
|
||||
contractId,
|
||||
version,
|
||||
DigestPayload.class,
|
||||
List.of("eventId", "count"),
|
||||
schemaHash,
|
||||
maximumEnvelopeBytes));
|
||||
}
|
||||
|
||||
private static CompiledIntegrationEventContract compile(
|
||||
IntegrationEventContractContribution<?> contribution) {
|
||||
return new ContractCatalogCompiler().compile(List.of(contribution)).getFirst();
|
||||
}
|
||||
|
||||
private static <P extends IntegrationPayload> DigestContribution<P> contribution(
|
||||
String contractId,
|
||||
int version,
|
||||
Class<P> payloadType,
|
||||
List<String> componentOrder,
|
||||
Sha256 schemaHash,
|
||||
int maximumEnvelopeBytes) {
|
||||
return new DigestContribution<>(
|
||||
new ContractId(contractId),
|
||||
version,
|
||||
payloadType,
|
||||
componentOrder,
|
||||
new SchemaResourceId("contracts/messaging/" + contractId + "/v" + version + ".schema.json"),
|
||||
schemaHash,
|
||||
new ContractDescriptor(
|
||||
"adapter-outbound-messaging",
|
||||
new LogicalDestinationId("fixture-events"),
|
||||
"json-schema-envelope-v1",
|
||||
true,
|
||||
128,
|
||||
maximumEnvelopeBytes,
|
||||
ContractDescriptor.SensitivityClassification.INTERNAL,
|
||||
Duration.ofDays(7)));
|
||||
}
|
||||
|
||||
private static Sha256 hash(int seed) {
|
||||
return new Sha256(HexFormat.of().parseHex(String.format("%064x", seed)));
|
||||
}
|
||||
|
||||
private record DigestPayload(String eventId, int count) implements IntegrationPayload {}
|
||||
|
||||
private record ReorderedDigestPayload(int count, String eventId) implements IntegrationPayload {}
|
||||
|
||||
private record DigestContribution<P extends IntegrationPayload>(
|
||||
ContractId contractId,
|
||||
int payloadVersion,
|
||||
Class<P> exactPayloadRecordType,
|
||||
List<String> canonicalRecordComponentOrder,
|
||||
SchemaResourceId payloadSchemaResource,
|
||||
Sha256 payloadSchemaHash,
|
||||
ContractDescriptor descriptor)
|
||||
implements IntegrationEventContractContribution<P> {}
|
||||
}
|
||||
+648
@@ -0,0 +1,648 @@
|
||||
package dev.caskeleton.adapter.outbound.messaging.destination;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
|
||||
import dev.caskeleton.adapter.outbound.messaging.config.CompiledMessagingDescriptor;
|
||||
import dev.caskeleton.adapter.outbound.messaging.config.MessagingCapabilityCardRegistry;
|
||||
import dev.caskeleton.adapter.outbound.messaging.contract.CompiledIntegrationEventContract;
|
||||
import dev.caskeleton.adapter.outbound.messaging.contract.ContractCatalogCompiler;
|
||||
import dev.caskeleton.adapter.outbound.messaging.contract.ContractCatalogDigest;
|
||||
import dev.caskeleton.application.messaging.contract.ContractDescriptor;
|
||||
import dev.caskeleton.application.messaging.contract.ContractId;
|
||||
import dev.caskeleton.application.messaging.contract.IntegrationEventContractContribution;
|
||||
import dev.caskeleton.application.messaging.contract.IntegrationPayload;
|
||||
import dev.caskeleton.application.messaging.contract.LogicalDestinationId;
|
||||
import dev.caskeleton.application.messaging.contract.SchemaResourceId;
|
||||
import dev.caskeleton.application.messaging.contract.Sha256;
|
||||
import java.lang.reflect.Modifier;
|
||||
import java.time.Duration;
|
||||
import java.util.ArrayList;
|
||||
import java.util.HexFormat;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
class DestinationBindingCompilerTest {
|
||||
|
||||
private static final MessagingCapabilityCardRegistry CARDS =
|
||||
MessagingCapabilityCardRegistry.exactFirstR2();
|
||||
private static final DestinationBindingCompiler COMPILER = new DestinationBindingCompiler();
|
||||
|
||||
@Test
|
||||
void compilesExactContractBindingWithCodeDeploymentByteIntersectionAndStableDigests() {
|
||||
CompiledIntegrationEventContract contract = contract("fixture.event.created", 1, 256);
|
||||
DestinationBindingSettings first =
|
||||
settings(
|
||||
List.of(binding("fixture-events", "fixture.events.v1", 512, ref(contract))),
|
||||
MessagingCapabilityCardRegistry.exactFirstR2Selection(),
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
Optional.empty());
|
||||
DestinationBindingSettings shuffledBootstrap =
|
||||
settings(
|
||||
List.of(
|
||||
binding(
|
||||
"fixture-events",
|
||||
"fixture.events.v1",
|
||||
512,
|
||||
List.of("broker-b:9093", "broker-a:9093"),
|
||||
ref(contract))),
|
||||
MessagingCapabilityCardRegistry.exactFirstR2Selection(),
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
Optional.empty());
|
||||
|
||||
List<CompiledPublicationBinding> result = COMPILER.compile(List.of(contract), first, CARDS);
|
||||
|
||||
assertThat(result).hasSize(1);
|
||||
CompiledPublicationBinding compiled = result.getFirst();
|
||||
assertThat(compiled.contract().stableKey()).isEqualTo("fixture.event.created:v1");
|
||||
assertThat(compiled.logicalDestination()).isEqualTo(contract.descriptor().logicalDestination());
|
||||
assertThat(compiled.physicalTopic()).isEqualTo("fixture.events.v1");
|
||||
assertThat(compiled.bootstrapServers()).containsExactly("broker-a:9093", "broker-b:9093");
|
||||
assertThat(compiled.effectiveMaximumRecordBytes()).isEqualTo(256);
|
||||
assertThat(compiled.settingsDigest())
|
||||
.isEqualTo(
|
||||
COMPILER
|
||||
.compile(List.of(contract), shuffledBootstrap, CARDS)
|
||||
.getFirst()
|
||||
.settingsDigest());
|
||||
assertThat(compiled.schemaSetDigest()).isNotNull();
|
||||
assertThatThrownBy(() -> compiled.bootstrapServers().add("broker-c:9093"))
|
||||
.isInstanceOf(UnsupportedOperationException.class);
|
||||
}
|
||||
|
||||
@Test
|
||||
void deploymentMaximumBelowCodeMaximumWinsAndMustRemainPositive() {
|
||||
CompiledIntegrationEventContract contract = contract("fixture.event.created", 1, 256);
|
||||
DestinationBindingSettings settings =
|
||||
settings(
|
||||
List.of(binding("fixture-events", "fixture.events.v1", 192, ref(contract))),
|
||||
MessagingCapabilityCardRegistry.exactFirstR2Selection(),
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
Optional.empty());
|
||||
|
||||
assertThat(
|
||||
COMPILER
|
||||
.compile(List.of(contract), settings, CARDS)
|
||||
.getFirst()
|
||||
.effectiveMaximumRecordBytes())
|
||||
.isEqualTo(192);
|
||||
assertThatThrownBy(
|
||||
() ->
|
||||
new DestinationBindingSettings.DestinationBinding(
|
||||
new LogicalDestinationId("fixture-events"),
|
||||
List.of(ref(contract)),
|
||||
"fixture.events.v1",
|
||||
List.of("broker-a:9093"),
|
||||
0))
|
||||
.isInstanceOf(IllegalArgumentException.class);
|
||||
}
|
||||
|
||||
@Test
|
||||
void settingsRejectDuplicateLogicalDestinationsInvalidPortsAndSecretShapedLegacyAliases() {
|
||||
CompiledIntegrationEventContract contract = contract("fixture.event.created", 1, 256);
|
||||
DestinationBindingSettings.DestinationBinding exact =
|
||||
binding("fixture-events", "fixture.events.v1", 512, ref(contract));
|
||||
|
||||
assertThatThrownBy(
|
||||
() ->
|
||||
settings(
|
||||
List.of(exact, exact),
|
||||
MessagingCapabilityCardRegistry.exactFirstR2Selection(),
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
Optional.empty()))
|
||||
.isInstanceOf(IllegalArgumentException.class)
|
||||
.hasMessageContaining("duplicate destination");
|
||||
assertThatThrownBy(
|
||||
() ->
|
||||
binding(
|
||||
"fixture-events",
|
||||
"fixture.events.v1",
|
||||
512,
|
||||
List.of("broker-a:99999"),
|
||||
ref(contract)))
|
||||
.isInstanceOf(IllegalArgumentException.class)
|
||||
.hasMessageContaining("bootstrap");
|
||||
assertThatThrownBy(
|
||||
() ->
|
||||
new DestinationBindingSettings.LegacyAliases(
|
||||
Optional.of("user:password"), Optional.empty(), List.of("broker-a:9093")))
|
||||
.isInstanceOf(IllegalArgumentException.class)
|
||||
.hasMessageContaining("legacy");
|
||||
}
|
||||
|
||||
@Test
|
||||
void settingsRejectNullDestinationAndDuplicatePhysicalTopicAcrossLogicalDestinations() {
|
||||
CompiledIntegrationEventContract contract = contract("fixture.event.created", 1, 256);
|
||||
DestinationBindingSettings.DestinationBinding first =
|
||||
binding("fixture-events", "shared.events.v1", 512, ref(contract));
|
||||
DestinationBindingSettings.DestinationBinding second =
|
||||
binding(
|
||||
"other-events",
|
||||
"shared.events.v1",
|
||||
512,
|
||||
new DestinationBindingSettings.ContractVersion(
|
||||
new ContractId("fixture.other.created"), 1));
|
||||
|
||||
assertThatThrownBy(
|
||||
() ->
|
||||
settings(
|
||||
List.of(first, second),
|
||||
MessagingCapabilityCardRegistry.exactFirstR2Selection(),
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
Optional.empty()))
|
||||
.isInstanceOf(IllegalArgumentException.class)
|
||||
.hasMessageContaining("physical topic");
|
||||
|
||||
List<DestinationBindingSettings.DestinationBinding> withNull = new ArrayList<>();
|
||||
withNull.add(null);
|
||||
assertThatThrownBy(
|
||||
() ->
|
||||
settings(
|
||||
withNull,
|
||||
MessagingCapabilityCardRegistry.exactFirstR2Selection(),
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
Optional.empty()))
|
||||
.isInstanceOf(IllegalArgumentException.class)
|
||||
.hasMessageContaining("destination");
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsMissingDuplicateExtraAndContractDestinationMismatchedBindings() {
|
||||
CompiledIntegrationEventContract contract = contract("fixture.event.created", 1, 256);
|
||||
DestinationBindingSettings.DestinationBinding exact =
|
||||
binding("fixture-events", "fixture.events.v1", 512, ref(contract));
|
||||
|
||||
assertThatThrownBy(
|
||||
() ->
|
||||
COMPILER.compile(
|
||||
List.of(contract),
|
||||
settings(
|
||||
List.of(),
|
||||
MessagingCapabilityCardRegistry.exactFirstR2Selection(),
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
Optional.empty()),
|
||||
CARDS))
|
||||
.isInstanceOf(IllegalArgumentException.class)
|
||||
.hasMessageContaining("missing");
|
||||
assertThatThrownBy(
|
||||
() ->
|
||||
COMPILER.compile(
|
||||
List.of(contract),
|
||||
settings(
|
||||
List.of(exact, exact),
|
||||
MessagingCapabilityCardRegistry.exactFirstR2Selection(),
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
Optional.empty()),
|
||||
CARDS))
|
||||
.isInstanceOf(IllegalArgumentException.class)
|
||||
.hasMessageContaining("duplicate destination");
|
||||
assertThatThrownBy(
|
||||
() ->
|
||||
COMPILER.compile(
|
||||
List.of(contract),
|
||||
settings(
|
||||
List.of(
|
||||
exact,
|
||||
binding(
|
||||
"unused-events",
|
||||
"unused.events.v1",
|
||||
512,
|
||||
new DestinationBindingSettings.ContractVersion(
|
||||
new ContractId("fixture.unknown.created"), 1))),
|
||||
MessagingCapabilityCardRegistry.exactFirstR2Selection(),
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
Optional.empty()),
|
||||
CARDS))
|
||||
.isInstanceOf(IllegalArgumentException.class)
|
||||
.hasMessageContaining("extra");
|
||||
assertThatThrownBy(
|
||||
() ->
|
||||
COMPILER.compile(
|
||||
List.of(contract),
|
||||
settings(
|
||||
List.of(binding("other-events", "other.events.v1", 512, ref(contract))),
|
||||
MessagingCapabilityCardRegistry.exactFirstR2Selection(),
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
Optional.empty()),
|
||||
CARDS))
|
||||
.isInstanceOf(IllegalArgumentException.class)
|
||||
.hasMessageContaining("identity");
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsUnknownCardsRoleMismatchLegacyConflictAndRelaxedRequirements() {
|
||||
CompiledIntegrationEventContract contract = contract("fixture.event.created", 1, 256);
|
||||
List<DestinationBindingSettings.DestinationBinding> bindings =
|
||||
List.of(binding("fixture-events", "fixture.events.v1", 512, ref(contract)));
|
||||
|
||||
assertRejected(
|
||||
contract,
|
||||
settings(
|
||||
bindings,
|
||||
selectionWithProducer("future-consumer.v1"),
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
Optional.empty()),
|
||||
"unknown");
|
||||
assertRejected(
|
||||
contract,
|
||||
settings(
|
||||
bindings,
|
||||
selectionWithProducer(
|
||||
MessagingCapabilityCardRegistry.exactFirstR2Selection().serializationCardId()),
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
Optional.empty()),
|
||||
"role");
|
||||
assertRejected(
|
||||
contract,
|
||||
settings(
|
||||
bindings,
|
||||
MessagingCapabilityCardRegistry.exactFirstR2Selection(),
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
Optional.of(
|
||||
new DestinationBindingSettings.LegacyAliases(
|
||||
Optional.of("kafka"), Optional.of("legacy.topic"), List.of("legacy:9092")))),
|
||||
"legacy");
|
||||
assertRejected(
|
||||
contract,
|
||||
settings(
|
||||
bindings,
|
||||
MessagingCapabilityCardRegistry.exactFirstR2Selection(),
|
||||
false,
|
||||
true,
|
||||
true,
|
||||
Optional.empty()),
|
||||
"ordering");
|
||||
assertRejected(
|
||||
contract,
|
||||
settings(
|
||||
bindings,
|
||||
MessagingCapabilityCardRegistry.exactFirstR2Selection(),
|
||||
true,
|
||||
false,
|
||||
true,
|
||||
Optional.empty()),
|
||||
"schema");
|
||||
assertRejected(
|
||||
contract,
|
||||
settings(
|
||||
bindings,
|
||||
MessagingCapabilityCardRegistry.exactFirstR2Selection(),
|
||||
true,
|
||||
true,
|
||||
false,
|
||||
Optional.empty()),
|
||||
"security");
|
||||
}
|
||||
|
||||
@Test
|
||||
void disabledEmptyCatalogIsZeroResourceWhileActiveRequiresExactCatalogAndBindings() {
|
||||
DestinationBindingSettings emptySettings =
|
||||
settings(
|
||||
List.of(),
|
||||
MessagingCapabilityCardRegistry.exactFirstR2Selection(),
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
Optional.empty());
|
||||
|
||||
CompiledMessagingDescriptor disabled =
|
||||
CompiledMessagingDescriptor.compile(
|
||||
CompiledMessagingDescriptor.ActivationMode.DISABLED, List.of(), emptySettings, CARDS);
|
||||
|
||||
assertThat(CompiledMessagingDescriptor.contractsOf(disabled)).isEmpty();
|
||||
assertThat(CompiledMessagingDescriptor.bindingsOf(disabled)).isEmpty();
|
||||
assertThat(disabled.resourceCount()).isZero();
|
||||
assertThatThrownBy(
|
||||
() ->
|
||||
CompiledMessagingDescriptor.compile(
|
||||
CompiledMessagingDescriptor.ActivationMode.ACTIVE,
|
||||
List.of(),
|
||||
emptySettings,
|
||||
CARDS))
|
||||
.isInstanceOf(IllegalArgumentException.class)
|
||||
.hasMessageContaining("empty catalog");
|
||||
|
||||
CompiledIntegrationEventContract contract = contract("fixture.event.created", 1, 256);
|
||||
DestinationBindingSettings activeSettings =
|
||||
settings(
|
||||
List.of(binding("fixture-events", "fixture.events.v1", 512, ref(contract))),
|
||||
MessagingCapabilityCardRegistry.exactFirstR2Selection(),
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
Optional.empty());
|
||||
CompiledMessagingDescriptor active =
|
||||
CompiledMessagingDescriptor.compile(
|
||||
CompiledMessagingDescriptor.ActivationMode.ACTIVE,
|
||||
List.of(contract),
|
||||
activeSettings,
|
||||
CARDS);
|
||||
|
||||
assertThat(CompiledMessagingDescriptor.bindingsOf(active)).hasSize(1);
|
||||
assertThat(active.catalogDigest()).isEqualTo(ContractCatalogDigest.compute(List.of(contract)));
|
||||
assertThat(active.settingsDigest())
|
||||
.isEqualTo(CompiledMessagingDescriptor.bindingsOf(active).getFirst().settingsDigest());
|
||||
assertThat(active.schemaSetDigest())
|
||||
.isEqualTo(CompiledMessagingDescriptor.bindingsOf(active).getFirst().schemaSetDigest());
|
||||
assertThat(CompiledMessagingDescriptor.bindingsOf(active).getFirst().contract())
|
||||
.isSameAs(CompiledMessagingDescriptor.contractsOf(active).getFirst());
|
||||
assertThat(active.resourceCount()).isZero();
|
||||
}
|
||||
|
||||
@Test
|
||||
void compiledArtifactsExposeOnlyNarrowStaticImmutableCompositionBridges()
|
||||
throws NoSuchMethodException {
|
||||
assertThat(Modifier.isFinal(CompiledPublicationBinding.class.getModifiers())).isTrue();
|
||||
assertThat(Modifier.isFinal(CompiledMessagingDescriptor.class.getModifiers())).isTrue();
|
||||
assertThat(Modifier.isFinal(CompiledIntegrationEventContract.class.getModifiers())).isTrue();
|
||||
assertThat(CompiledPublicationBinding.class.isRecord()).isFalse();
|
||||
assertThat(CompiledMessagingDescriptor.class.isRecord()).isFalse();
|
||||
assertThat(CompiledIntegrationEventContract.class.isRecord()).isFalse();
|
||||
assertThat(CompiledPublicationBinding.class.getConstructors()).isEmpty();
|
||||
assertThat(CompiledMessagingDescriptor.class.getConstructors()).isEmpty();
|
||||
assertThat(CompiledIntegrationEventContract.class.getConstructors()).isEmpty();
|
||||
assertThat(CompiledPublicationBinding.class.getDeclaredConstructors())
|
||||
.allSatisfy(
|
||||
constructor -> assertThat(Modifier.isPublic(constructor.getModifiers())).isFalse());
|
||||
assertThat(CompiledMessagingDescriptor.class.getDeclaredConstructors())
|
||||
.allSatisfy(
|
||||
constructor -> assertThat(Modifier.isPrivate(constructor.getModifiers())).isTrue());
|
||||
assertPackagePrivateInstanceMethod(
|
||||
DestinationBindingCompiler.class,
|
||||
"compile",
|
||||
List.class,
|
||||
DestinationBindingSettings.class,
|
||||
MessagingCapabilityCardRegistry.class);
|
||||
assertPublicStaticMethod(
|
||||
DestinationBindingCompiler.class,
|
||||
"compileExact",
|
||||
List.class,
|
||||
DestinationBindingSettings.class,
|
||||
MessagingCapabilityCardRegistry.class);
|
||||
assertPackagePrivateInstanceMethod(CompiledMessagingDescriptor.class, "contracts");
|
||||
assertPackagePrivateInstanceMethod(CompiledMessagingDescriptor.class, "bindings");
|
||||
assertPublicStaticMethod(
|
||||
CompiledMessagingDescriptor.class, "contractsOf", CompiledMessagingDescriptor.class);
|
||||
assertPublicStaticMethod(
|
||||
CompiledMessagingDescriptor.class, "bindingsOf", CompiledMessagingDescriptor.class);
|
||||
assertPackagePrivateInstanceMethod(MessagingCapabilityCardRegistry.class, "cards");
|
||||
assertPackagePrivateInstanceMethod(DestinationBindingSettings.class, "destinations");
|
||||
assertPackagePrivateInstanceMethod(DestinationBindingSettings.class, "legacyAliases");
|
||||
assertPackagePrivateInstanceMethod(
|
||||
DestinationBindingSettings.DestinationBinding.class, "contracts");
|
||||
|
||||
BindingContribution<BindingPayload> firstContribution =
|
||||
contribution(
|
||||
"fixture.event.created",
|
||||
BindingPayload.class,
|
||||
"contracts/messaging/fixture.event.created/v1.schema.json");
|
||||
BindingContribution<AlternateBindingPayload> duplicateSchemaContribution =
|
||||
contribution(
|
||||
"fixture.other.created",
|
||||
AlternateBindingPayload.class,
|
||||
"contracts/messaging/fixture.event.created/v1.schema.json");
|
||||
BindingContribution<BindingPayload> duplicateExactTypeContribution =
|
||||
contribution(
|
||||
"fixture.other.created",
|
||||
BindingPayload.class,
|
||||
"contracts/messaging/fixture.other.created/v1.schema.json");
|
||||
|
||||
assertThatThrownBy(
|
||||
() ->
|
||||
ContractCatalogCompiler.compileExact(
|
||||
List.of(firstContribution, duplicateSchemaContribution)))
|
||||
.isInstanceOf(IllegalArgumentException.class)
|
||||
.hasMessageContaining("schema");
|
||||
assertThatThrownBy(
|
||||
() ->
|
||||
ContractCatalogCompiler.compileExact(
|
||||
List.of(firstContribution, duplicateExactTypeContribution)))
|
||||
.isInstanceOf(IllegalArgumentException.class)
|
||||
.hasMessageContaining("payload");
|
||||
|
||||
CompiledIntegrationEventContract first =
|
||||
ContractCatalogCompiler.compileExact(List.of(firstContribution)).getFirst();
|
||||
DestinationBindingSettings settings =
|
||||
settings(
|
||||
List.of(binding("fixture-events", "fixture.events.v1", 512, ref(first))),
|
||||
MessagingCapabilityCardRegistry.exactFirstR2Selection(),
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
Optional.empty());
|
||||
|
||||
CompiledMessagingDescriptor active =
|
||||
CompiledMessagingDescriptor.compile(
|
||||
CompiledMessagingDescriptor.ActivationMode.ACTIVE, List.of(first), settings, CARDS);
|
||||
assertThat(CompiledMessagingDescriptor.contractsOf(active).getFirst()).isSameAs(first);
|
||||
assertThat(CompiledMessagingDescriptor.bindingsOf(active).getFirst().contract())
|
||||
.isSameAs(first);
|
||||
assertThatThrownBy(
|
||||
() ->
|
||||
new DestinationBindingSettings.DestinationBinding(
|
||||
new LogicalDestinationId("fixture-events"),
|
||||
List.of(ref(first)),
|
||||
" ",
|
||||
List.of("broker-a:9093"),
|
||||
512))
|
||||
.isInstanceOf(IllegalArgumentException.class);
|
||||
assertThatThrownBy(
|
||||
() ->
|
||||
new DestinationBindingSettings.DestinationBinding(
|
||||
new LogicalDestinationId("fixture-events"),
|
||||
List.of(ref(first)),
|
||||
"fixture.events.v1",
|
||||
List.of("user:secret@broker-a:9093"),
|
||||
512))
|
||||
.isInstanceOf(IllegalArgumentException.class);
|
||||
Sha256 digest = ContractCatalogDigest.compute(List.of(first));
|
||||
assertThatThrownBy(
|
||||
() ->
|
||||
new CompiledPublicationBinding(
|
||||
first,
|
||||
new LogicalDestinationId("other-events"),
|
||||
"fixture.events.v1",
|
||||
List.of("broker-a:9093"),
|
||||
256,
|
||||
MessagingCapabilityCardRegistry.exactFirstR2Selection(),
|
||||
digest,
|
||||
digest))
|
||||
.isInstanceOf(IllegalArgumentException.class);
|
||||
assertThatThrownBy(
|
||||
() ->
|
||||
new CompiledPublicationBinding(
|
||||
first,
|
||||
first.descriptor().logicalDestination(),
|
||||
"fixture.events.v1",
|
||||
List.of("broker-a:9093"),
|
||||
257,
|
||||
MessagingCapabilityCardRegistry.exactFirstR2Selection(),
|
||||
digest,
|
||||
digest))
|
||||
.isInstanceOf(IllegalArgumentException.class);
|
||||
}
|
||||
|
||||
private static void assertPackagePrivateInstanceMethod(
|
||||
Class<?> owner, String methodName, Class<?>... parameterTypes) throws NoSuchMethodException {
|
||||
int modifiers = owner.getDeclaredMethod(methodName, parameterTypes).getModifiers();
|
||||
assertThat(Modifier.isPublic(modifiers)).isFalse();
|
||||
assertThat(Modifier.isStatic(modifiers)).isFalse();
|
||||
}
|
||||
|
||||
private static void assertPublicStaticMethod(
|
||||
Class<?> owner, String methodName, Class<?>... parameterTypes) throws NoSuchMethodException {
|
||||
int modifiers = owner.getDeclaredMethod(methodName, parameterTypes).getModifiers();
|
||||
assertThat(Modifier.isPublic(modifiers)).isTrue();
|
||||
assertThat(Modifier.isStatic(modifiers)).isTrue();
|
||||
}
|
||||
|
||||
private static void assertRejected(
|
||||
CompiledIntegrationEventContract contract,
|
||||
DestinationBindingSettings settings,
|
||||
String message) {
|
||||
assertThatThrownBy(() -> COMPILER.compile(List.of(contract), settings, CARDS))
|
||||
.isInstanceOf(IllegalArgumentException.class)
|
||||
.hasMessageContaining(message);
|
||||
}
|
||||
|
||||
private static DestinationBindingSettings settings(
|
||||
List<DestinationBindingSettings.DestinationBinding> bindings,
|
||||
MessagingCapabilityCardRegistry.ExactSelection selection,
|
||||
boolean ordering,
|
||||
boolean schema,
|
||||
boolean security,
|
||||
Optional<DestinationBindingSettings.LegacyAliases> legacy) {
|
||||
return new DestinationBindingSettings(bindings, selection, ordering, schema, security, legacy);
|
||||
}
|
||||
|
||||
private static DestinationBindingSettings.DestinationBinding binding(
|
||||
String logicalDestination,
|
||||
String physicalTopic,
|
||||
int maximumRecordBytes,
|
||||
DestinationBindingSettings.ContractVersion... contracts) {
|
||||
return binding(
|
||||
logicalDestination,
|
||||
physicalTopic,
|
||||
maximumRecordBytes,
|
||||
List.of("broker-a:9093", "broker-b:9093"),
|
||||
contracts);
|
||||
}
|
||||
|
||||
private static DestinationBindingSettings.DestinationBinding binding(
|
||||
String logicalDestination,
|
||||
String physicalTopic,
|
||||
int maximumRecordBytes,
|
||||
List<String> bootstrapServers,
|
||||
DestinationBindingSettings.ContractVersion... contracts) {
|
||||
return new DestinationBindingSettings.DestinationBinding(
|
||||
new LogicalDestinationId(logicalDestination),
|
||||
List.of(contracts),
|
||||
physicalTopic,
|
||||
bootstrapServers,
|
||||
maximumRecordBytes);
|
||||
}
|
||||
|
||||
private static DestinationBindingSettings.ContractVersion ref(
|
||||
CompiledIntegrationEventContract contract) {
|
||||
return new DestinationBindingSettings.ContractVersion(
|
||||
contract.contractId(), contract.payloadVersion());
|
||||
}
|
||||
|
||||
private static MessagingCapabilityCardRegistry.ExactSelection selectionWithProducer(
|
||||
String producer) {
|
||||
MessagingCapabilityCardRegistry.ExactSelection selected =
|
||||
MessagingCapabilityCardRegistry.exactFirstR2Selection();
|
||||
return new MessagingCapabilityCardRegistry.ExactSelection(
|
||||
selected.semanticCardId(),
|
||||
producer,
|
||||
selected.dispatchCardId(),
|
||||
selected.claimCardId(),
|
||||
selected.serializationCardId(),
|
||||
selected.topicCardId(),
|
||||
selected.securityCardId(),
|
||||
selected.compressionCardId(),
|
||||
selected.orderingCardId(),
|
||||
selected.transactionCardId(),
|
||||
selected.operatorControlCardId());
|
||||
}
|
||||
|
||||
private static CompiledIntegrationEventContract contract(
|
||||
String contractId, int version, int maximumEnvelopeBytes) {
|
||||
BindingContribution<BindingPayload> contribution =
|
||||
new BindingContribution<>(
|
||||
new ContractId(contractId),
|
||||
version,
|
||||
BindingPayload.class,
|
||||
List.of("eventId"),
|
||||
new SchemaResourceId(
|
||||
"contracts/messaging/" + contractId + "/v" + version + ".schema.json"),
|
||||
new Sha256(HexFormat.of().parseHex(String.format("%064x", version))),
|
||||
descriptor("fixture-events", maximumEnvelopeBytes));
|
||||
return ContractCatalogCompiler.compileExact(List.of(contribution)).getFirst();
|
||||
}
|
||||
|
||||
private static <P extends IntegrationPayload> BindingContribution<P> contribution(
|
||||
String contractId, Class<P> payloadType, String schemaResource) {
|
||||
return new BindingContribution<>(
|
||||
new ContractId(contractId),
|
||||
1,
|
||||
payloadType,
|
||||
List.of("eventId"),
|
||||
new SchemaResourceId(schemaResource),
|
||||
new Sha256(HexFormat.of().parseHex(String.format("%064x", contractId.hashCode()))),
|
||||
descriptor("fixture-events", 256));
|
||||
}
|
||||
|
||||
private static ContractDescriptor descriptor(String destination, int maximumEnvelopeBytes) {
|
||||
return new ContractDescriptor(
|
||||
"adapter-outbound-messaging",
|
||||
new LogicalDestinationId(destination),
|
||||
"json-schema-envelope-v1",
|
||||
true,
|
||||
128,
|
||||
maximumEnvelopeBytes,
|
||||
ContractDescriptor.SensitivityClassification.INTERNAL,
|
||||
Duration.ofDays(7));
|
||||
}
|
||||
|
||||
private record BindingPayload(String eventId) implements IntegrationPayload {}
|
||||
|
||||
private record AlternateBindingPayload(String eventId) implements IntegrationPayload {}
|
||||
|
||||
private record BindingContribution<P extends IntegrationPayload>(
|
||||
ContractId contractId,
|
||||
int payloadVersion,
|
||||
Class<P> exactPayloadRecordType,
|
||||
List<String> canonicalRecordComponentOrder,
|
||||
SchemaResourceId payloadSchemaResource,
|
||||
Sha256 payloadSchemaHash,
|
||||
ContractDescriptor descriptor)
|
||||
implements IntegrationEventContractContribution<P> {}
|
||||
}
|
||||
+104
@@ -0,0 +1,104 @@
|
||||
package dev.caskeleton.adapter.outbound.messaging.destination;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
|
||||
import dev.caskeleton.application.messaging.contract.LogicalDestinationId;
|
||||
import dev.caskeleton.application.messaging.event.AggregateIdentity;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
class PartitionKeyV1Test {
|
||||
|
||||
private static final LogicalDestinationId DESTINATION =
|
||||
new LogicalDestinationId("portfolio-domain-events");
|
||||
|
||||
@Test
|
||||
void freezesTenantScopedGoldenVectorAsLowercaseHexAndExactAsciiBytes() {
|
||||
PartitionKeyV1.Value key =
|
||||
PartitionKeyV1.derive(
|
||||
DESTINATION, new AggregateIdentity("tenant-a", "worklog", "worklog-42"));
|
||||
|
||||
assertThat(key.text())
|
||||
.isEqualTo("0e5feab14824293a301c3e8509f23363f38a585cbcba5895c8f3a3655ca1550a")
|
||||
.matches("[0-9a-f]{64}");
|
||||
assertThat(key.bytes()).hasSize(64);
|
||||
assertThat(key.bytes()).isEqualTo(key.text().getBytes(StandardCharsets.US_ASCII));
|
||||
byte[] callerCopy = key.bytes();
|
||||
callerCopy[0] = 'f';
|
||||
assertThat(key.bytes()).isEqualTo(key.text().getBytes(StandardCharsets.US_ASCII));
|
||||
}
|
||||
|
||||
@Test
|
||||
void tenantDisabledCallerMustSupplyTheCanonicalNonNullSystemScope() {
|
||||
assertThat(
|
||||
PartitionKeyV1.derive(
|
||||
DESTINATION, new AggregateIdentity("system", "worklog", "worklog-42"))
|
||||
.text())
|
||||
.isEqualTo("449f3054afd8ba5234c160fdeca359497645ef198b166c9a380e20702748b3a6");
|
||||
|
||||
assertThatThrownBy(() -> PartitionKeyV1.derive(DESTINATION, null))
|
||||
.isInstanceOf(IllegalArgumentException.class);
|
||||
assertThatThrownBy(
|
||||
() ->
|
||||
PartitionKeyV1.deriveCanonicalComponents(
|
||||
null, DESTINATION, "worklog", "worklog-42"))
|
||||
.isInstanceOf(IllegalArgumentException.class);
|
||||
assertThatThrownBy(
|
||||
() ->
|
||||
PartitionKeyV1.deriveCanonicalComponents(" ", DESTINATION, "worklog", "worklog-42"))
|
||||
.isInstanceOf(IllegalArgumentException.class);
|
||||
}
|
||||
|
||||
@Test
|
||||
void nonAsciiAggregateIdGoldenVectorUsesUtf8ByteLengthNotCharacterCount() {
|
||||
assertThat(
|
||||
PartitionKeyV1.deriveCanonicalComponents("tenant-a", DESTINATION, "worklog", "작업-42")
|
||||
.text())
|
||||
.isEqualTo("367eee4ab006e7f16324abdd026009c257e81ece048abb62f69ccbbfe18631df");
|
||||
}
|
||||
|
||||
@Test
|
||||
void malformedUtf8SurrogatesAreRejectedInsteadOfCollidingWithLiteralQuestionMark() {
|
||||
PartitionKeyV1.Value literalQuestionMark =
|
||||
PartitionKeyV1.deriveCanonicalComponents("tenant-a", DESTINATION, "worklog", "?");
|
||||
|
||||
assertThat(literalQuestionMark.text()).matches("[0-9a-f]{64}");
|
||||
assertThatThrownBy(
|
||||
() -> PartitionKeyV1.deriveCanonicalComponents("\uD800", DESTINATION, "worklog", "?"))
|
||||
.isInstanceOf(IllegalArgumentException.class)
|
||||
.hasMessageContaining("UTF-8");
|
||||
assertThatThrownBy(
|
||||
() -> PartitionKeyV1.deriveCanonicalComponents("tenant-a", DESTINATION, "\uDC00", "?"))
|
||||
.isInstanceOf(IllegalArgumentException.class)
|
||||
.hasMessageContaining("UTF-8");
|
||||
assertThatThrownBy(
|
||||
() ->
|
||||
PartitionKeyV1.deriveCanonicalComponents(
|
||||
"tenant-a", DESTINATION, "worklog", "\uD800"))
|
||||
.isInstanceOf(IllegalArgumentException.class)
|
||||
.hasMessageContaining("UTF-8");
|
||||
}
|
||||
|
||||
@Test
|
||||
void canonicalComponentsApplyTask3BoundsBeforeEncoding() {
|
||||
assertThatThrownBy(
|
||||
() ->
|
||||
PartitionKeyV1.deriveCanonicalComponents(
|
||||
"t".repeat(97), DESTINATION, "worklog", "worklog-42"))
|
||||
.isInstanceOf(IllegalArgumentException.class)
|
||||
.hasMessageContaining("tenantScope");
|
||||
assertThatThrownBy(
|
||||
() ->
|
||||
PartitionKeyV1.deriveCanonicalComponents(
|
||||
"tenant-a", DESTINATION, "w".repeat(65), "worklog-42"))
|
||||
.isInstanceOf(IllegalArgumentException.class)
|
||||
.hasMessageContaining("aggregateType");
|
||||
assertThatThrownBy(
|
||||
() ->
|
||||
PartitionKeyV1.deriveCanonicalComponents(
|
||||
"tenant-a", DESTINATION, "worklog", "i".repeat(161)))
|
||||
.isInstanceOf(IllegalArgumentException.class)
|
||||
.hasMessageContaining("aggregateId");
|
||||
}
|
||||
}
|
||||
+334
@@ -0,0 +1,334 @@
|
||||
package dev.caskeleton.adapter.outbound.messaging.envelope;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
|
||||
import dev.caskeleton.adapter.outbound.messaging.contract.CompiledIntegrationEventContract;
|
||||
import dev.caskeleton.adapter.outbound.messaging.contract.ContractCatalogCompiler;
|
||||
import dev.caskeleton.application.messaging.contract.ContractDescriptor;
|
||||
import dev.caskeleton.application.messaging.contract.ContractId;
|
||||
import dev.caskeleton.application.messaging.contract.IntegrationEventContractContribution;
|
||||
import dev.caskeleton.application.messaging.contract.IntegrationPayload;
|
||||
import dev.caskeleton.application.messaging.contract.LogicalDestinationId;
|
||||
import dev.caskeleton.application.messaging.contract.SchemaResourceId;
|
||||
import dev.caskeleton.application.messaging.contract.Sha256;
|
||||
import dev.caskeleton.application.messaging.event.AggregateIdentity;
|
||||
import dev.caskeleton.application.messaging.event.AggregateOrder;
|
||||
import dev.caskeleton.application.messaging.event.EventId;
|
||||
import dev.caskeleton.application.messaging.event.IntegrationEventDraft;
|
||||
import java.math.BigDecimal;
|
||||
import java.math.BigInteger;
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
import java.util.AbstractList;
|
||||
import java.util.ConcurrentModificationException;
|
||||
import java.util.HexFormat;
|
||||
import java.util.Iterator;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
/**
|
||||
* Small adopted corpus: JSON-Schema-Test-Suite draft2020-12 type/required/additionalProperties and
|
||||
* Bowtie-style malformed-instance scenarios. This is bounded repository evidence, not a full
|
||||
* implementation compatibility claim.
|
||||
*/
|
||||
class EnvelopeAdversarialCorpusTest {
|
||||
|
||||
private static final EnvelopeAdmissionLimits LIMITS =
|
||||
new EnvelopeAdmissionLimits(8, 64, 128, 4, 10, 8, 1024, 2048, 16, 64, 4);
|
||||
private static final DeterministicEnvelopeWriter WRITER = new DeterministicEnvelopeWriter(LIMITS);
|
||||
|
||||
@Test
|
||||
void limitsRejectZeroNegativeAndUnlimitedSentinels() {
|
||||
assertThatThrownBy(() -> new EnvelopeAdmissionLimits(0, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1))
|
||||
.isInstanceOf(IllegalArgumentException.class);
|
||||
assertThatThrownBy(
|
||||
() -> new EnvelopeAdmissionLimits(Integer.MAX_VALUE, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1))
|
||||
.isInstanceOf(IllegalArgumentException.class)
|
||||
.hasMessageContaining("finite");
|
||||
}
|
||||
|
||||
@Test
|
||||
void writerRejectsMapRawTreeUnsupportedClassNonfiniteAndUnpairedSurrogate() {
|
||||
assertRejected(new MapPayload(Map.of("key", "value")), "unsupported");
|
||||
assertRejected(new ObjectPayload(new Object()), "unsupported");
|
||||
assertRejected(new DoublePayload(Double.NaN), "finite");
|
||||
assertRejected(new DoublePayload(Double.POSITIVE_INFINITY), "finite");
|
||||
assertRejected(new StringPayload("\ud800"), "surrogate");
|
||||
}
|
||||
|
||||
@Test
|
||||
void writerEnforcesStringUtf8ArrayObjectNumberAndDepthBudgets() {
|
||||
assertRejected(new StringPayload("a".repeat(65)), "string");
|
||||
assertRejected(new StringPayload("가".repeat(50)), "UTF-8");
|
||||
assertRejected(new IntegerListPayload(List.of(1, 2, 3, 4, 5)), "array");
|
||||
assertRejected(
|
||||
new ManyPropertiesPayload(
|
||||
new ManyProperties("1", "2", "3", "4", "5", "6", "7", "8", "9", "10", "11")),
|
||||
"properties");
|
||||
assertRejected(new BigIntegerPayload(new BigInteger("123456789")), "digits");
|
||||
assertRejected(
|
||||
new DepthPayload(
|
||||
new Depth1(
|
||||
new Depth2(
|
||||
new Depth3(new Depth4(new Depth5(new Depth6(new Depth7(new Depth8("x"))))))))),
|
||||
"depth");
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsExtremePositiveDecimalScaleBeforePlainStringAllocation() {
|
||||
assertRejected(
|
||||
new BigDecimalPayload(new BigDecimal(BigInteger.ONE, Integer.MAX_VALUE)), "digits");
|
||||
}
|
||||
|
||||
@Test
|
||||
void boundsJsonOutputDuringWritesInsteadOfOnlyInspectingTheCompletedBuffer() {
|
||||
assertThat(
|
||||
java.util.Arrays.stream(DeterministicEnvelopeWriter.class.getDeclaredClasses())
|
||||
.map(Class::getSimpleName))
|
||||
.contains("BoundedByteArrayOutputStream");
|
||||
EnvelopeAdmissionLimits tinyOutput =
|
||||
new EnvelopeAdmissionLimits(8, 64, 128, 4, 10, 8, 16, 64, 16, 64, 4);
|
||||
DeterministicEnvelopeWriter tinyWriter = new DeterministicEnvelopeWriter(tinyOutput);
|
||||
|
||||
assertThatThrownBy(
|
||||
() ->
|
||||
tinyWriter.write(
|
||||
draft(new StringPayload("1234567890")), contract(StringPayload.class)))
|
||||
.isInstanceOf(IllegalArgumentException.class)
|
||||
.hasMessageContaining("byte admission");
|
||||
}
|
||||
|
||||
@Test
|
||||
void checksListSizeBeforeIterationAndFailsClosedOnMutationOrConcurrency() {
|
||||
assertRejected(new IntegerListPayload(new OversizedNoArrayList()), "array");
|
||||
assertRejected(new IntegerListPayload(new SizeDriftingList()), "mutated");
|
||||
assertRejected(new IntegerListPayload(new ConcurrentFailureList()), "mutated");
|
||||
}
|
||||
|
||||
@Test
|
||||
void writerUsesExactRecordOrderUtf8AndCanonicalScalarRendering() {
|
||||
OrderedPayload payload =
|
||||
new OrderedPayload(
|
||||
"한글", new BigDecimal("12.5000"), 7, true, Optional.empty(), List.of("β", "alpha"));
|
||||
DeterministicEnvelopeWriter.EncodedEnvelope encoded =
|
||||
WRITER.write(draft(payload), contract(OrderedPayload.class));
|
||||
|
||||
String json = new String(encoded.envelopeBytes(), java.nio.charset.StandardCharsets.UTF_8);
|
||||
assertThat(json)
|
||||
.contains(
|
||||
"\"payload\":{\"text\":\"한글\",\"decimal\":12.5,\"integral\":7,\"flag\":true,\"optional\":null,\"list\":[\"β\",\"alpha\"]}");
|
||||
assertThat(encoded.payloadBytes())
|
||||
.isEqualTo(
|
||||
"{\"text\":\"한글\",\"decimal\":12.5,\"integral\":7,\"flag\":true,\"optional\":null,\"list\":[\"β\",\"alpha\"]}"
|
||||
.getBytes(java.nio.charset.StandardCharsets.UTF_8));
|
||||
}
|
||||
|
||||
@Test
|
||||
void exactPayloadClassIsRequiredAndNoAssignableTypeSearchOccurs() {
|
||||
CompiledIntegrationEventContract exact = contract(OrderedPayload.class);
|
||||
StringPayload other = new StringPayload("value");
|
||||
|
||||
assertThatThrownBy(() -> WRITER.write(draft(other), exact))
|
||||
.isInstanceOf(IllegalArgumentException.class)
|
||||
.hasMessageContaining("exact");
|
||||
}
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
private static <P extends IntegrationPayload> void assertRejected(P payload, String message) {
|
||||
Class<P> exactType = (Class<P>) payload.getClass();
|
||||
assertThatThrownBy(() -> WRITER.write(draft(payload), contract(exactType)))
|
||||
.isInstanceOf(IllegalArgumentException.class)
|
||||
.satisfies(exception -> assertThat(exception.getMessage()).containsIgnoringCase(message));
|
||||
}
|
||||
|
||||
private static <P extends IntegrationPayload> IntegrationEventDraft<P> draft(P payload) {
|
||||
return new IntegrationEventDraft<>(
|
||||
new EventId("event-adversarial"),
|
||||
new ContractId("test.adversarial"),
|
||||
1,
|
||||
new LogicalDestinationId("test-events"),
|
||||
new AggregateIdentity("tenant-a", "worklog", "W-1"),
|
||||
new AggregateOrder(1, 0),
|
||||
Instant.parse("2026-07-29T00:00:00Z"),
|
||||
"corr-adversarial",
|
||||
Optional.empty(),
|
||||
payload);
|
||||
}
|
||||
|
||||
private static <P extends IntegrationPayload> CompiledIntegrationEventContract contract(
|
||||
Class<P> type) {
|
||||
List<String> order =
|
||||
java.util.Arrays.stream(type.getRecordComponents())
|
||||
.map(java.lang.reflect.RecordComponent::getName)
|
||||
.toList();
|
||||
IntegrationEventContractContribution<P> contribution =
|
||||
new IntegrationEventContractContribution<>() {
|
||||
@Override
|
||||
public ContractId contractId() {
|
||||
return new ContractId("test.adversarial");
|
||||
}
|
||||
|
||||
@Override
|
||||
public int payloadVersion() {
|
||||
return 1;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Class<P> exactPayloadRecordType() {
|
||||
return type;
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<String> canonicalRecordComponentOrder() {
|
||||
return order;
|
||||
}
|
||||
|
||||
@Override
|
||||
public SchemaResourceId payloadSchemaResource() {
|
||||
return new SchemaResourceId("contracts/messaging/test.adversarial/v1.schema.json");
|
||||
}
|
||||
|
||||
@Override
|
||||
public Sha256 payloadSchemaHash() {
|
||||
return new Sha256(HexFormat.of().parseHex("11".repeat(32)));
|
||||
}
|
||||
|
||||
@Override
|
||||
public ContractDescriptor descriptor() {
|
||||
return new ContractDescriptor(
|
||||
"adapter-outbound-messaging",
|
||||
new LogicalDestinationId("test-events"),
|
||||
"json-schema-envelope-v1",
|
||||
true,
|
||||
1024,
|
||||
2048,
|
||||
ContractDescriptor.SensitivityClassification.INTERNAL,
|
||||
Duration.ofDays(1));
|
||||
}
|
||||
};
|
||||
return ContractCatalogCompiler.compileExact(List.of(contribution)).getFirst();
|
||||
}
|
||||
|
||||
private record ObjectPayload(Object value) implements IntegrationPayload {}
|
||||
|
||||
private record MapPayload(Map<String, String> value) implements IntegrationPayload {}
|
||||
|
||||
private record DoublePayload(double value) implements IntegrationPayload {}
|
||||
|
||||
private record StringPayload(String value) implements IntegrationPayload {}
|
||||
|
||||
private record IntegerListPayload(List<Integer> value) implements IntegrationPayload {}
|
||||
|
||||
private record ManyPropertiesPayload(ManyProperties value) implements IntegrationPayload {}
|
||||
|
||||
private record BigIntegerPayload(BigInteger value) implements IntegrationPayload {}
|
||||
|
||||
private record BigDecimalPayload(BigDecimal value) implements IntegrationPayload {}
|
||||
|
||||
private record DepthPayload(Depth1 value) implements IntegrationPayload {}
|
||||
|
||||
private record OrderedPayload(
|
||||
String text,
|
||||
BigDecimal decimal,
|
||||
long integral,
|
||||
boolean flag,
|
||||
Optional<String> optional,
|
||||
List<String> list)
|
||||
implements IntegrationPayload {}
|
||||
|
||||
private record ManyProperties(
|
||||
String a,
|
||||
String b,
|
||||
String c,
|
||||
String d,
|
||||
String e,
|
||||
String f,
|
||||
String g,
|
||||
String h,
|
||||
String i,
|
||||
String j,
|
||||
String k) {}
|
||||
|
||||
private record Depth1(Depth2 value) {}
|
||||
|
||||
private record Depth2(Depth3 value) {}
|
||||
|
||||
private record Depth3(Depth4 value) {}
|
||||
|
||||
private record Depth4(Depth5 value) {}
|
||||
|
||||
private record Depth5(Depth6 value) {}
|
||||
|
||||
private record Depth6(Depth7 value) {}
|
||||
|
||||
private record Depth7(Depth8 value) {}
|
||||
|
||||
private record Depth8(String value) {}
|
||||
|
||||
private static final class OversizedNoArrayList extends AbstractList<Integer> {
|
||||
|
||||
@Override
|
||||
public Integer get(int index) {
|
||||
throw new AssertionError("oversized list must be rejected before element access");
|
||||
}
|
||||
|
||||
@Override
|
||||
public int size() {
|
||||
return 5;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Object[] toArray() {
|
||||
throw new AssertionError("writer must not duplicate the list through toArray");
|
||||
}
|
||||
}
|
||||
|
||||
private static final class SizeDriftingList extends AbstractList<Integer> {
|
||||
|
||||
@Override
|
||||
public Integer get(int index) {
|
||||
return List.of(1, 2).get(index);
|
||||
}
|
||||
|
||||
@Override
|
||||
public int size() {
|
||||
return 1;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Iterator<Integer> iterator() {
|
||||
return List.of(1, 2).iterator();
|
||||
}
|
||||
}
|
||||
|
||||
private static final class ConcurrentFailureList extends AbstractList<Integer> {
|
||||
|
||||
@Override
|
||||
public Integer get(int index) {
|
||||
return 1;
|
||||
}
|
||||
|
||||
@Override
|
||||
public int size() {
|
||||
return 1;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Iterator<Integer> iterator() {
|
||||
return new Iterator<>() {
|
||||
@Override
|
||||
public boolean hasNext() {
|
||||
throw new ConcurrentModificationException("fixture");
|
||||
}
|
||||
|
||||
@Override
|
||||
public Integer next() {
|
||||
return 1;
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
+413
@@ -0,0 +1,413 @@
|
||||
package dev.caskeleton.adapter.outbound.messaging.envelope;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
|
||||
import dev.caskeleton.adapter.outbound.messaging.config.MessagingCapabilityCardRegistry;
|
||||
import dev.caskeleton.adapter.outbound.messaging.contract.CompiledIntegrationEventContract;
|
||||
import dev.caskeleton.adapter.outbound.messaging.contract.ContractCatalogCompiler;
|
||||
import dev.caskeleton.adapter.outbound.messaging.destination.CompiledPublicationBinding;
|
||||
import dev.caskeleton.adapter.outbound.messaging.destination.DestinationBindingCompiler;
|
||||
import dev.caskeleton.adapter.outbound.messaging.destination.DestinationBindingSettings;
|
||||
import dev.caskeleton.application.messaging.contract.ContractDescriptor;
|
||||
import dev.caskeleton.application.messaging.contract.ContractId;
|
||||
import dev.caskeleton.application.messaging.contract.IntegrationEventContractContribution;
|
||||
import dev.caskeleton.application.messaging.contract.IntegrationPayload;
|
||||
import dev.caskeleton.application.messaging.contract.LogicalDestinationId;
|
||||
import dev.caskeleton.application.messaging.contract.SchemaResourceId;
|
||||
import dev.caskeleton.application.messaging.contract.Sha256;
|
||||
import dev.caskeleton.application.messaging.event.AggregateIdentity;
|
||||
import dev.caskeleton.application.messaging.event.AggregateOrder;
|
||||
import dev.caskeleton.application.messaging.event.EventId;
|
||||
import dev.caskeleton.application.messaging.event.IntegrationEventDraft;
|
||||
import dev.caskeleton.application.messaging.event.ValidatedIntegrationEvent;
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.math.BigDecimal;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.security.MessageDigest;
|
||||
import java.security.NoSuchAlgorithmException;
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
import java.util.concurrent.atomic.AtomicInteger;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
class JsonSchemaIntegrationEventEncoderTest {
|
||||
|
||||
private static final String ENVELOPE_RESOURCE = "contracts/messaging/envelope/v1.schema.json";
|
||||
private static final String PAYLOAD_RESOURCE = "contracts/messaging/test.event/v1.schema.json";
|
||||
private static final EnvelopeAdmissionLimits LIMITS =
|
||||
new EnvelopeAdmissionLimits(16, 256, 1024, 16, 64, 64, 4096, 8192, 32, 256, 8);
|
||||
|
||||
@Test
|
||||
void writesAndValidatesExactCanonicalUtf8EnvelopeWithStableHashesAndRevisions() {
|
||||
Fixture fixture = fixture();
|
||||
IntegrationEventDraft<TestPayload> draft = fixture.validDraft();
|
||||
|
||||
ValidatedIntegrationEvent first = fixture.encoder().encode(draft);
|
||||
ValidatedIntegrationEvent second = fixture.encoder().encode(draft);
|
||||
String expected =
|
||||
"""
|
||||
{"envelopeVersion":1,"eventId":"event-1","contractId":"test.event","payloadVersion":1,"logicalDestination":"test-events","aggregate":{"type":"worklog","id":"W-1","sequence":3,"eventIndex":0},"occurredAt":"2026-07-29T01:02:03.123Z","correlationId":"corr-1","contentType":"application/json","payload":{"name":"정확한-UTF8","count":7,"enabled":true,"amount":12.5,"status":"READY","note":null,"tags":["alpha","β"],"nested":{"code":"N1"}}}\
|
||||
""";
|
||||
|
||||
assertThat(first.envelopeBytes()).isEqualTo(expected.getBytes(StandardCharsets.UTF_8));
|
||||
assertThat(second).isEqualTo(first);
|
||||
assertThat(first.partitionKeyText()).matches("[0-9a-f]{64}");
|
||||
assertThat(first.schemaSetHash().toString()).matches("[0-9a-f]{64}");
|
||||
assertThat(first.envelopeSha256()).isEqualTo(EnvelopeHashV1.compute(first.envelopeBytes()));
|
||||
assertThat(first.envelopeSchemaHash())
|
||||
.isEqualTo(fixture.registry().schemaHash(ENVELOPE_RESOURCE));
|
||||
assertThat(first.payloadSchemaHash())
|
||||
.isEqualTo(fixture.registry().schemaHash(PAYLOAD_RESOURCE));
|
||||
assertThat(first.contractCatalogRevision()).matches("[0-9a-f]{64}");
|
||||
assertThat(first.destinationBindingRevision()).matches("[0-9a-f]{64}");
|
||||
byte[] returned = first.envelopeBytes();
|
||||
returned[0] = 0;
|
||||
assertThat(first.envelopeBytes()[0]).isEqualTo((byte) '{');
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsUnknownVersionDestinationExactTypeAndSchemaInvalidPayload() {
|
||||
Fixture fixture = fixture();
|
||||
IntegrationEventDraft<TestPayload> valid = fixture.validDraft();
|
||||
|
||||
assertThatThrownBy(
|
||||
() ->
|
||||
fixture
|
||||
.encoder()
|
||||
.encode(
|
||||
new IntegrationEventDraft<>(
|
||||
valid.eventId(),
|
||||
valid.contractId(),
|
||||
2,
|
||||
valid.destinationId(),
|
||||
valid.aggregate(),
|
||||
valid.order(),
|
||||
valid.occurredAt(),
|
||||
valid.correlationId(),
|
||||
valid.causationId(),
|
||||
valid.featurePayload())))
|
||||
.isInstanceOf(IllegalArgumentException.class)
|
||||
.hasMessageContaining("contract");
|
||||
assertThatThrownBy(
|
||||
() ->
|
||||
fixture
|
||||
.encoder()
|
||||
.encode(
|
||||
new IntegrationEventDraft<>(
|
||||
valid.eventId(),
|
||||
valid.contractId(),
|
||||
1,
|
||||
new LogicalDestinationId("other-events"),
|
||||
valid.aggregate(),
|
||||
valid.order(),
|
||||
valid.occurredAt(),
|
||||
valid.correlationId(),
|
||||
valid.causationId(),
|
||||
valid.featurePayload())))
|
||||
.isInstanceOf(IllegalArgumentException.class)
|
||||
.hasMessageContaining("destination");
|
||||
assertThatThrownBy(
|
||||
() ->
|
||||
fixture
|
||||
.encoder()
|
||||
.encode(
|
||||
new IntegrationEventDraft<>(
|
||||
valid.eventId(),
|
||||
valid.contractId(),
|
||||
1,
|
||||
valid.destinationId(),
|
||||
valid.aggregate(),
|
||||
valid.order(),
|
||||
valid.occurredAt(),
|
||||
valid.correlationId(),
|
||||
valid.causationId(),
|
||||
new OtherPayload("wrong"))))
|
||||
.isInstanceOf(IllegalArgumentException.class)
|
||||
.hasMessageContaining("exact");
|
||||
assertThatThrownBy(
|
||||
() ->
|
||||
fixture
|
||||
.encoder()
|
||||
.encode(
|
||||
new IntegrationEventDraft<>(
|
||||
valid.eventId(),
|
||||
valid.contractId(),
|
||||
1,
|
||||
valid.destinationId(),
|
||||
valid.aggregate(),
|
||||
valid.order(),
|
||||
valid.occurredAt(),
|
||||
valid.correlationId(),
|
||||
valid.causationId(),
|
||||
new TestPayload(
|
||||
"",
|
||||
7,
|
||||
true,
|
||||
new BigDecimal("12.50"),
|
||||
Status.READY,
|
||||
Optional.empty(),
|
||||
List.of(),
|
||||
new NestedPayload("N1")))))
|
||||
.isInstanceOf(IllegalArgumentException.class)
|
||||
.hasMessageContaining("payload schema");
|
||||
}
|
||||
|
||||
@Test
|
||||
void exactEnvelopeHashHasDomainSeparatedGoldenVectorAndDefensiveShaValue() {
|
||||
byte[] bytes = "abc".getBytes(StandardCharsets.UTF_8);
|
||||
|
||||
Sha256 hash = EnvelopeHashV1.compute(bytes);
|
||||
|
||||
assertThat(hash.toString())
|
||||
.isEqualTo("34c6935b92898e650d376a5180b6f14eeaf3c5231277629d29688d2da5caf45b");
|
||||
bytes[0] = 'z';
|
||||
assertThat(hash.toString())
|
||||
.isEqualTo("34c6935b92898e650d376a5180b6f14eeaf3c5231277629d29688d2da5caf45b");
|
||||
}
|
||||
|
||||
@Test
|
||||
void snapshotsStatefulMutablePayloadAccessorsOnceAndEmbedsThoseExactBytes() {
|
||||
StatefulPayload.reset();
|
||||
byte[] schemaBytes = resource(PAYLOAD_RESOURCE);
|
||||
StatefulContribution contribution = new StatefulContribution(sha256(schemaBytes));
|
||||
CompiledIntegrationEventContract contract =
|
||||
ContractCatalogCompiler.compileExact(List.of(contribution)).getFirst();
|
||||
IntegrationEventDraft<StatefulPayload> draft =
|
||||
new IntegrationEventDraft<>(
|
||||
new EventId("event-1"),
|
||||
new ContractId("test.event"),
|
||||
1,
|
||||
new LogicalDestinationId("test-events"),
|
||||
new AggregateIdentity("tenant-a", "worklog", "W-1"),
|
||||
new AggregateOrder(3, 0),
|
||||
Instant.parse("2026-07-29T01:02:03.123Z"),
|
||||
"corr-1",
|
||||
Optional.empty(),
|
||||
new StatefulPayload(List.of("first")));
|
||||
|
||||
DeterministicEnvelopeWriter.EncodedEnvelope encoded =
|
||||
new DeterministicEnvelopeWriter(LIMITS).write(draft, contract);
|
||||
String payload = new String(encoded.payloadBytes(), StandardCharsets.UTF_8);
|
||||
String envelope = new String(encoded.envelopeBytes(), StandardCharsets.UTF_8);
|
||||
|
||||
assertThat(StatefulPayload.accessorCalls()).isEqualTo(1);
|
||||
assertThat(payload).isEqualTo("{\"tags\":[\"first\"]}");
|
||||
assertThat(envelope).endsWith("\"payload\":" + payload + "}");
|
||||
}
|
||||
|
||||
private static Fixture fixture() {
|
||||
byte[] payloadSchema = resource(PAYLOAD_RESOURCE);
|
||||
TestContribution contribution = new TestContribution(sha256(payloadSchema));
|
||||
CompiledIntegrationEventContract contract =
|
||||
ContractCatalogCompiler.compileExact(List.of(contribution)).getFirst();
|
||||
DestinationBindingSettings settings =
|
||||
new DestinationBindingSettings(
|
||||
List.of(
|
||||
new DestinationBindingSettings.DestinationBinding(
|
||||
new LogicalDestinationId("test-events"),
|
||||
List.of(
|
||||
new DestinationBindingSettings.ContractVersion(
|
||||
new ContractId("test.event"), 1)),
|
||||
"test.events.v1",
|
||||
List.of("broker-a:9093"),
|
||||
8192)),
|
||||
MessagingCapabilityCardRegistry.exactFirstR2Selection(),
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
Optional.empty());
|
||||
CompiledPublicationBinding binding =
|
||||
DestinationBindingCompiler.compileExact(
|
||||
List.of(contract), settings, MessagingCapabilityCardRegistry.exactFirstR2())
|
||||
.getFirst();
|
||||
LocalJsonSchemaRegistry registry =
|
||||
new LocalJsonSchemaRegistry(
|
||||
Map.of(
|
||||
ENVELOPE_RESOURCE, source(ENVELOPE_RESOURCE),
|
||||
PAYLOAD_RESOURCE, source(PAYLOAD_RESOURCE)),
|
||||
LIMITS);
|
||||
return new Fixture(
|
||||
registry,
|
||||
new JsonSchemaIntegrationEventEncoder(
|
||||
List.of(contract), List.of(binding), registry, LIMITS));
|
||||
}
|
||||
|
||||
private static LocalJsonSchemaRegistry.SchemaSource source(String path) {
|
||||
byte[] bytes = resource(path);
|
||||
return new LocalJsonSchemaRegistry.SchemaSource(path, bytes, sha256(bytes));
|
||||
}
|
||||
|
||||
private static byte[] resource(String path) {
|
||||
try (InputStream input =
|
||||
JsonSchemaIntegrationEventEncoderTest.class.getClassLoader().getResourceAsStream(path)) {
|
||||
if (input == null) {
|
||||
throw new IllegalStateException("missing test resource " + path);
|
||||
}
|
||||
return input.readAllBytes();
|
||||
} catch (IOException exception) {
|
||||
throw new IllegalStateException(exception);
|
||||
}
|
||||
}
|
||||
|
||||
private static Sha256 sha256(byte[] bytes) {
|
||||
try {
|
||||
return new Sha256(MessageDigest.getInstance("SHA-256").digest(bytes));
|
||||
} catch (NoSuchAlgorithmException exception) {
|
||||
throw new IllegalStateException(exception);
|
||||
}
|
||||
}
|
||||
|
||||
private record Fixture(
|
||||
LocalJsonSchemaRegistry registry, JsonSchemaIntegrationEventEncoder encoder) {
|
||||
|
||||
private IntegrationEventDraft<TestPayload> validDraft() {
|
||||
return new IntegrationEventDraft<>(
|
||||
new EventId("event-1"),
|
||||
new ContractId("test.event"),
|
||||
1,
|
||||
new LogicalDestinationId("test-events"),
|
||||
new AggregateIdentity("tenant-a", "worklog", "W-1"),
|
||||
new AggregateOrder(3, 0),
|
||||
Instant.parse("2026-07-29T01:02:03.123Z"),
|
||||
"corr-1",
|
||||
Optional.of("cause-1"),
|
||||
new TestPayload(
|
||||
"정확한-UTF8",
|
||||
7,
|
||||
true,
|
||||
new BigDecimal("12.50"),
|
||||
Status.READY,
|
||||
Optional.empty(),
|
||||
List.of("alpha", "β"),
|
||||
new NestedPayload("N1")));
|
||||
}
|
||||
}
|
||||
|
||||
private record TestContribution(Sha256 payloadSchemaHash)
|
||||
implements IntegrationEventContractContribution<TestPayload> {
|
||||
|
||||
@Override
|
||||
public ContractId contractId() {
|
||||
return new ContractId("test.event");
|
||||
}
|
||||
|
||||
@Override
|
||||
public int payloadVersion() {
|
||||
return 1;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Class<TestPayload> exactPayloadRecordType() {
|
||||
return TestPayload.class;
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<String> canonicalRecordComponentOrder() {
|
||||
return List.of("name", "count", "enabled", "amount", "status", "note", "tags", "nested");
|
||||
}
|
||||
|
||||
@Override
|
||||
public SchemaResourceId payloadSchemaResource() {
|
||||
return new SchemaResourceId(PAYLOAD_RESOURCE);
|
||||
}
|
||||
|
||||
@Override
|
||||
public ContractDescriptor descriptor() {
|
||||
return new ContractDescriptor(
|
||||
"adapter-outbound-messaging",
|
||||
new LogicalDestinationId("test-events"),
|
||||
"json-schema-envelope-v1",
|
||||
true,
|
||||
4096,
|
||||
8192,
|
||||
ContractDescriptor.SensitivityClassification.INTERNAL,
|
||||
Duration.ofDays(7));
|
||||
}
|
||||
}
|
||||
|
||||
private record TestPayload(
|
||||
String name,
|
||||
int count,
|
||||
boolean enabled,
|
||||
BigDecimal amount,
|
||||
Status status,
|
||||
Optional<String> note,
|
||||
List<String> tags,
|
||||
NestedPayload nested)
|
||||
implements IntegrationPayload {}
|
||||
|
||||
private record OtherPayload(String value) implements IntegrationPayload {}
|
||||
|
||||
private record StatefulContribution(Sha256 payloadSchemaHash)
|
||||
implements IntegrationEventContractContribution<StatefulPayload> {
|
||||
|
||||
@Override
|
||||
public ContractId contractId() {
|
||||
return new ContractId("test.event");
|
||||
}
|
||||
|
||||
@Override
|
||||
public int payloadVersion() {
|
||||
return 1;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Class<StatefulPayload> exactPayloadRecordType() {
|
||||
return StatefulPayload.class;
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<String> canonicalRecordComponentOrder() {
|
||||
return List.of("tags");
|
||||
}
|
||||
|
||||
@Override
|
||||
public SchemaResourceId payloadSchemaResource() {
|
||||
return new SchemaResourceId(PAYLOAD_RESOURCE);
|
||||
}
|
||||
|
||||
@Override
|
||||
public ContractDescriptor descriptor() {
|
||||
return new ContractDescriptor(
|
||||
"adapter-outbound-messaging",
|
||||
new LogicalDestinationId("test-events"),
|
||||
"json-schema-envelope-v1",
|
||||
true,
|
||||
4096,
|
||||
8192,
|
||||
ContractDescriptor.SensitivityClassification.INTERNAL,
|
||||
Duration.ofDays(7));
|
||||
}
|
||||
}
|
||||
|
||||
private record StatefulPayload(List<String> tags) implements IntegrationPayload {
|
||||
|
||||
private static final AtomicInteger CALLS = new AtomicInteger();
|
||||
|
||||
@Override
|
||||
@SuppressWarnings("UnusedMethod")
|
||||
public List<String> tags() {
|
||||
return CALLS.incrementAndGet() == 1 ? tags : List.of("mutated");
|
||||
}
|
||||
|
||||
private static void reset() {
|
||||
CALLS.set(0);
|
||||
}
|
||||
|
||||
private static int accessorCalls() {
|
||||
return CALLS.get();
|
||||
}
|
||||
}
|
||||
|
||||
private record NestedPayload(String code) {}
|
||||
|
||||
private enum Status {
|
||||
READY,
|
||||
DONE
|
||||
}
|
||||
}
|
||||
+378
@@ -0,0 +1,378 @@
|
||||
package dev.caskeleton.adapter.outbound.messaging.envelope;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
|
||||
import dev.caskeleton.application.messaging.contract.Sha256;
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.security.MessageDigest;
|
||||
import java.security.NoSuchAlgorithmException;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
class LocalJsonSchemaRegistryTest {
|
||||
|
||||
private static final String ENVELOPE_RESOURCE = "contracts/messaging/envelope/v1.schema.json";
|
||||
private static final String PAYLOAD_RESOURCE = "contracts/messaging/test.event/v1.schema.json";
|
||||
private static final String PAYLOAD_ID = "urn:dev-caskeleton:contracts:messaging:test.event:v1";
|
||||
private static final String ENVELOPE_ID = "urn:dev-caskeleton:contracts:messaging:envelope:v1";
|
||||
private static final EnvelopeAdmissionLimits LIMITS =
|
||||
new EnvelopeAdmissionLimits(16, 256, 1024, 16, 64, 64, 4096, 8192, 32, 256, 8);
|
||||
|
||||
@Test
|
||||
void precompilesOnlyCheckedDraft202012ResourcesAndValidatesGoldenVectors() {
|
||||
LocalJsonSchemaRegistry registry = registry();
|
||||
|
||||
assertThat(
|
||||
registry.validate(PAYLOAD_ID, resource(PAYLOAD_RESOURCE.replace(".schema", ".valid"))))
|
||||
.isEmpty();
|
||||
assertThat(
|
||||
registry.validate(
|
||||
PAYLOAD_ID, resource(PAYLOAD_RESOURCE.replace(".schema", ".invalid"))))
|
||||
.isNotEmpty();
|
||||
assertThat(registry.schemaId(PAYLOAD_RESOURCE)).isEqualTo(PAYLOAD_ID);
|
||||
assertThat(registry.schemaHash(PAYLOAD_RESOURCE)).isEqualTo(sha256(resource(PAYLOAD_RESOURCE)));
|
||||
assertThat(registry.exactSchemaBytes(PAYLOAD_RESOURCE))
|
||||
.containsExactly(resource(PAYLOAD_RESOURCE));
|
||||
assertThat(registry.pinnedDraft202012AuthorityHash().toString())
|
||||
.isEqualTo("8a9c3b75ebf53edb639da470d4f213a5210a1f62015f4a705d428e8ba3649efb");
|
||||
}
|
||||
|
||||
@Test
|
||||
void startupAuthorityDoesNotDependOnARegularNetworkNtCodeSourceJar() {
|
||||
byte[] implementation = classBytes(LocalJsonSchemaRegistry.class);
|
||||
String constantPool = new String(implementation, StandardCharsets.ISO_8859_1);
|
||||
|
||||
assertThat(constantPool)
|
||||
.doesNotContain("getProtectionDomain")
|
||||
.doesNotContain("java/util/jar/JarFile")
|
||||
.doesNotContain("networknt-3.0.2.jar.sha256");
|
||||
assertThat(
|
||||
LocalJsonSchemaRegistryTest.class
|
||||
.getClassLoader()
|
||||
.getResource("contracts/messaging/meta/draft-2020-12/networknt-3.0.2.jar.sha256"))
|
||||
.isNull();
|
||||
assertThat(registry().pinnedDraft202012AuthorityHash().toString())
|
||||
.isEqualTo("8a9c3b75ebf53edb639da470d4f213a5210a1f62015f4a705d428e8ba3649efb");
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsMissingRequiredNullForbiddenAndInvalidEnvelopeInstances() {
|
||||
LocalJsonSchemaRegistry registry = registry();
|
||||
byte[] nullForbidden =
|
||||
"""
|
||||
{"name":null,"count":0,"enabled":true,"amount":1,"status":"READY",
|
||||
"note":null,"tags":[],"nested":{"code":"N1"}}
|
||||
"""
|
||||
.getBytes(StandardCharsets.UTF_8);
|
||||
|
||||
assertThat(registry.validate(PAYLOAD_ID, "{}".getBytes(StandardCharsets.UTF_8)))
|
||||
.anyMatch(error -> error.contains("required"));
|
||||
assertThat(registry.validate(PAYLOAD_ID, nullForbidden))
|
||||
.anyMatch(error -> error.contains("type"));
|
||||
assertThat(registry.validate(ENVELOPE_ID, "{}".getBytes(StandardCharsets.UTF_8)))
|
||||
.anyMatch(error -> error.contains("required"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void parserRejectsUnpairedSurrogateNonfiniteDepthAndNumberBoundsBeforeValidation() {
|
||||
LocalJsonSchemaRegistry registry = registry();
|
||||
String tooDeep = "[".repeat(17) + "0" + "]".repeat(17);
|
||||
String tooLongNumber = "1".repeat(65);
|
||||
|
||||
assertThatThrownBy(
|
||||
() -> registry.validate(PAYLOAD_ID, "\"\\uD800\"".getBytes(StandardCharsets.UTF_8)))
|
||||
.isInstanceOf(IllegalArgumentException.class)
|
||||
.hasMessageContaining("surrogate");
|
||||
assertThatThrownBy(() -> registry.validate(PAYLOAD_ID, "NaN".getBytes(StandardCharsets.UTF_8)))
|
||||
.isInstanceOf(IllegalArgumentException.class)
|
||||
.hasMessageContaining("JSON");
|
||||
assertThatThrownBy(
|
||||
() -> registry.validate(PAYLOAD_ID, tooDeep.getBytes(StandardCharsets.UTF_8)))
|
||||
.isInstanceOf(IllegalArgumentException.class)
|
||||
.hasMessageContaining("JSON");
|
||||
assertThatThrownBy(
|
||||
() -> registry.validate(PAYLOAD_ID, tooLongNumber.getBytes(StandardCharsets.UTF_8)))
|
||||
.isInstanceOf(IllegalArgumentException.class)
|
||||
.hasMessageContaining("JSON");
|
||||
}
|
||||
|
||||
@Test
|
||||
void exposesOnlyTheExactByteValidationBoundary() {
|
||||
assertThat(
|
||||
java.util.Arrays.stream(LocalJsonSchemaRegistry.class.getMethods())
|
||||
.filter(method -> method.getName().equals("validate"))
|
||||
.map(method -> List.of(method.getParameterTypes()))
|
||||
.toList())
|
||||
.containsExactly(List.of(String.class, byte[].class));
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsChecksumMismatchDuplicateIdUnknownDialectAndRequiredVocabulary() {
|
||||
byte[] payload = resource(PAYLOAD_RESOURCE);
|
||||
assertThatThrownBy(
|
||||
() ->
|
||||
new LocalJsonSchemaRegistry(
|
||||
Map.of(
|
||||
PAYLOAD_RESOURCE,
|
||||
new LocalJsonSchemaRegistry.SchemaSource(
|
||||
PAYLOAD_RESOURCE,
|
||||
payload,
|
||||
sha256("different".getBytes(StandardCharsets.UTF_8)))),
|
||||
LIMITS))
|
||||
.isInstanceOf(IllegalArgumentException.class)
|
||||
.hasMessageContaining("checksum");
|
||||
|
||||
Map<String, LocalJsonSchemaRegistry.SchemaSource> duplicate = new LinkedHashMap<>();
|
||||
duplicate.put(PAYLOAD_RESOURCE, source(PAYLOAD_RESOURCE));
|
||||
duplicate.put(
|
||||
"contracts/messaging/duplicate/v1.schema.json",
|
||||
new LocalJsonSchemaRegistry.SchemaSource(
|
||||
"contracts/messaging/duplicate/v1.schema.json", payload, sha256(payload)));
|
||||
assertThatThrownBy(() -> new LocalJsonSchemaRegistry(duplicate, LIMITS))
|
||||
.isInstanceOf(IllegalArgumentException.class)
|
||||
.hasMessageContaining("duplicate");
|
||||
|
||||
assertRejectedSchema(
|
||||
"""
|
||||
{"$schema":"https://example.invalid/draft","$id":"urn:test:unknown","type":"object"}
|
||||
""",
|
||||
"dialect");
|
||||
assertRejectedSchema(
|
||||
"""
|
||||
{"$schema":"https://json-schema.org/draft/2020-12/schema",
|
||||
"$id":"urn:test:vocabulary",
|
||||
"$vocabulary":{"https://example.invalid/required":true},
|
||||
"type":"object"}
|
||||
""",
|
||||
"vocabulary");
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsInvalidSchemaRemoteReferenceAndReferenceGraphBeyondConfiguredDepth() {
|
||||
assertRejectedSchema(
|
||||
"""
|
||||
{"$schema":"https://json-schema.org/draft/2020-12/schema",
|
||||
"$id":"urn:test:invalid","type":37}
|
||||
""",
|
||||
"schema");
|
||||
assertRejectedSchema(
|
||||
"""
|
||||
{"$schema":"https://json-schema.org/draft/2020-12/schema",
|
||||
"$id":"urn:test:remote","$ref":"https://example.invalid/secret-schema"}
|
||||
""",
|
||||
"reference");
|
||||
assertRejectedSchema(
|
||||
"""
|
||||
{"$schema":"https://json-schema.org/draft/2020-12/schema",
|
||||
"$id":"urn:test:cycle","$defs":{"a":{"$ref":"#/$defs/b"},"b":{"$ref":"#/$defs/a"}},
|
||||
"$ref":"#/$defs/a"}
|
||||
""",
|
||||
"depth");
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsRelativeAndAbsoluteNestedSchemaIdentifiersInTheClosedSubset() {
|
||||
assertRejectedSchema(
|
||||
"""
|
||||
{"$schema":"https://json-schema.org/draft/2020-12/schema",
|
||||
"$id":"urn:test:nested-relative",
|
||||
"$defs":{"nested":{"$id":"child","type":"string"}},
|
||||
"type":"object"}
|
||||
""",
|
||||
"nested $id");
|
||||
assertRejectedSchema(
|
||||
"""
|
||||
{"$schema":"https://json-schema.org/draft/2020-12/schema",
|
||||
"$id":"urn:test:nested-absolute",
|
||||
"$defs":{"nested":{"$id":"urn:test:nested-child","type":"string"}},
|
||||
"type":"object"}
|
||||
""",
|
||||
"nested $id");
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsNestedSchemaIdentifierKeysRegardlessOfValueType() {
|
||||
for (String nonTextIdentifier : List.of("37", "null", "{}", "false")) {
|
||||
assertRejectedSchema(
|
||||
"""
|
||||
{"$schema":"https://json-schema.org/draft/2020-12/schema",
|
||||
"$id":"urn:test:nested-non-text",
|
||||
"$defs":{"nested":{"$id":%s,"type":"string"}},
|
||||
"type":"object"}
|
||||
"""
|
||||
.formatted(nonTextIdentifier),
|
||||
"nested $id");
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsDynamicRecursiveAndAnchorKeywordsEverywhereInTheClosedSubset() {
|
||||
for (String keyword :
|
||||
List.of("$dynamicRef", "$dynamicAnchor", "$recursiveRef", "$recursiveAnchor", "$anchor")) {
|
||||
assertRejectedSchema(
|
||||
"""
|
||||
{"$schema":"https://json-schema.org/draft/2020-12/schema",
|
||||
"$id":"urn:test:unsupported-keyword",
|
||||
"$defs":{"nested":{"%s":"resource:external","type":"string"}},
|
||||
"type":"object"}
|
||||
"""
|
||||
.formatted(keyword),
|
||||
keyword);
|
||||
}
|
||||
for (String externalTarget : List.of("classpath:external", "resource:external")) {
|
||||
assertRejectedSchema(
|
||||
"""
|
||||
{"$schema":"https://json-schema.org/draft/2020-12/schema",
|
||||
"$id":"urn:test:dynamic-ref",
|
||||
"$dynamicRef":"%s",
|
||||
"type":"object"}
|
||||
"""
|
||||
.formatted(externalTarget),
|
||||
"$dynamicRef");
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void acceptsOnlyExactUrnSchemeForRootIdentifiersAndAbsoluteReferences() {
|
||||
for (String identifier :
|
||||
List.of("classpath:root", "resource:root", "jar:file:test", "URN:test")) {
|
||||
assertRejectedSchema(
|
||||
"""
|
||||
{"$schema":"https://json-schema.org/draft/2020-12/schema",
|
||||
"$id":"%s","type":"object"}
|
||||
"""
|
||||
.formatted(identifier),
|
||||
"exact urn");
|
||||
}
|
||||
for (String reference :
|
||||
List.of(
|
||||
"classpath:external",
|
||||
"resource:external",
|
||||
"jar:file:test",
|
||||
"https://example.invalid/schema",
|
||||
"file:/tmp/schema",
|
||||
"unknown:external")) {
|
||||
assertRejectedSchema(
|
||||
"""
|
||||
{"$schema":"https://json-schema.org/draft/2020-12/schema",
|
||||
"$id":"urn:test:absolute-reference",
|
||||
"$ref":"%s"}
|
||||
"""
|
||||
.formatted(reference),
|
||||
"exact urn");
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void enablesFormatAssertionsAndReturnsDeterministicPayloadFreeErrors() {
|
||||
String schema =
|
||||
"""
|
||||
{"$schema":"https://json-schema.org/draft/2020-12/schema",
|
||||
"$id":"urn:test:format","type":"object","required":["when"],
|
||||
"properties":{"when":{"type":"string","format":"date-time"}},
|
||||
"unevaluatedProperties":false}
|
||||
""";
|
||||
LocalJsonSchemaRegistry registry =
|
||||
registryWith("contracts/messaging/format/v1.schema.json", schema);
|
||||
|
||||
List<String> first =
|
||||
registry.validate(
|
||||
"urn:test:format",
|
||||
"{\"when\":\"not-a-time\",\"secret\":\"do-not-log\"}".getBytes(StandardCharsets.UTF_8));
|
||||
List<String> second =
|
||||
registry.validate(
|
||||
"urn:test:format",
|
||||
"{\"when\":\"not-a-time\",\"secret\":\"do-not-log\"}".getBytes(StandardCharsets.UTF_8));
|
||||
|
||||
assertThat(first).isEqualTo(second).isNotEmpty();
|
||||
assertThat(String.join(" ", first)).doesNotContain("do-not-log");
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsDuplicateKeysMalformedUtf8TrailingGarbageAndOverBudgetRegexInputs() {
|
||||
LocalJsonSchemaRegistry registry = registry();
|
||||
|
||||
assertThatThrownBy(
|
||||
() ->
|
||||
registry.validate(
|
||||
PAYLOAD_ID, "{\"name\":\"a\",\"name\":\"b\"}".getBytes(StandardCharsets.UTF_8)))
|
||||
.isInstanceOf(IllegalArgumentException.class)
|
||||
.hasMessageContaining("JSON");
|
||||
assertThatThrownBy(() -> registry.validate(PAYLOAD_ID, new byte[] {(byte) 0xc3, 0x28}))
|
||||
.isInstanceOf(IllegalArgumentException.class)
|
||||
.hasMessageContaining("UTF-8");
|
||||
assertThatThrownBy(
|
||||
() -> registry.validate(PAYLOAD_ID, "{} trailing".getBytes(StandardCharsets.UTF_8)))
|
||||
.isInstanceOf(IllegalArgumentException.class)
|
||||
.hasMessageContaining("JSON");
|
||||
|
||||
String regexSchema =
|
||||
"""
|
||||
{"$schema":"https://json-schema.org/draft/2020-12/schema",
|
||||
"$id":"urn:test:regex","type":"string","pattern":"^(a+)+$"}
|
||||
""";
|
||||
LocalJsonSchemaRegistry bounded =
|
||||
registryWith("contracts/messaging/regex/v1.schema.json", regexSchema);
|
||||
assertThatThrownBy(
|
||||
() ->
|
||||
bounded.validate(
|
||||
"urn:test:regex",
|
||||
("\"" + "a".repeat(300) + "!\"").getBytes(StandardCharsets.UTF_8)))
|
||||
.isInstanceOf(IllegalArgumentException.class)
|
||||
.hasMessageContaining("string");
|
||||
}
|
||||
|
||||
private static LocalJsonSchemaRegistry registry() {
|
||||
return new LocalJsonSchemaRegistry(
|
||||
Map.of(
|
||||
ENVELOPE_RESOURCE, source(ENVELOPE_RESOURCE),
|
||||
PAYLOAD_RESOURCE, source(PAYLOAD_RESOURCE)),
|
||||
LIMITS);
|
||||
}
|
||||
|
||||
private static LocalJsonSchemaRegistry registryWith(String path, String schema) {
|
||||
byte[] bytes = schema.getBytes(StandardCharsets.UTF_8);
|
||||
return new LocalJsonSchemaRegistry(
|
||||
Map.of(path, new LocalJsonSchemaRegistry.SchemaSource(path, bytes, sha256(bytes))), LIMITS);
|
||||
}
|
||||
|
||||
private static void assertRejectedSchema(String schema, String message) {
|
||||
assertThatThrownBy(() -> registryWith("contracts/messaging/rejected/v1.schema.json", schema))
|
||||
.isInstanceOf(IllegalArgumentException.class)
|
||||
.hasMessageContaining(message);
|
||||
}
|
||||
|
||||
private static LocalJsonSchemaRegistry.SchemaSource source(String path) {
|
||||
byte[] bytes = resource(path);
|
||||
return new LocalJsonSchemaRegistry.SchemaSource(path, bytes, sha256(bytes));
|
||||
}
|
||||
|
||||
private static byte[] resource(String path) {
|
||||
try (InputStream input =
|
||||
LocalJsonSchemaRegistryTest.class.getClassLoader().getResourceAsStream(path)) {
|
||||
if (input == null) {
|
||||
throw new IllegalStateException("missing test resource " + path);
|
||||
}
|
||||
return input.readAllBytes();
|
||||
} catch (IOException exception) {
|
||||
throw new IllegalStateException(exception);
|
||||
}
|
||||
}
|
||||
|
||||
private static byte[] classBytes(Class<?> type) {
|
||||
return resource(type.getName().replace('.', '/') + ".class");
|
||||
}
|
||||
|
||||
private static Sha256 sha256(byte[] bytes) {
|
||||
try {
|
||||
return new Sha256(MessageDigest.getInstance("SHA-256").digest(bytes));
|
||||
} catch (NoSuchAlgorithmException exception) {
|
||||
throw new IllegalStateException(exception);
|
||||
}
|
||||
}
|
||||
}
|
||||
+2
-2
@@ -77,7 +77,7 @@ class OutboxMessagePublishAdapterTest {
|
||||
class SuccessPath {
|
||||
|
||||
@Test
|
||||
void publishSendsMessageWithCorrectTopicKeyAndEnvelopePayload() {
|
||||
void legacyVoidSenderNormalReturnCompletesWithoutBrokerConfirmationCharacterization() {
|
||||
FakeBroker broker = new FakeBroker();
|
||||
OutboxMessagePublishAdapter adapter = new OutboxMessagePublishAdapter(broker);
|
||||
|
||||
@@ -128,7 +128,7 @@ class OutboxMessagePublishAdapterTest {
|
||||
class FailClosedPath {
|
||||
|
||||
@Test
|
||||
void publishFailurePropagatesAsRuntimeException() {
|
||||
void senderExceptionPropagatesAsRuntimeExceptionCharacterization() {
|
||||
FakeBroker broker = new FakeBroker(new IllegalStateException("broker down"));
|
||||
OutboxMessagePublishAdapter adapter = new OutboxMessagePublishAdapter(broker);
|
||||
|
||||
|
||||
+68
@@ -0,0 +1,68 @@
|
||||
package dev.caskeleton.adapter.outbound.messaging.qualification;
|
||||
|
||||
import dev.caskeleton.adapter.outbound.messaging.envelope.EnvelopeAdmissionLimits;
|
||||
import dev.caskeleton.adapter.outbound.messaging.envelope.LocalJsonSchemaRegistry;
|
||||
import dev.caskeleton.application.messaging.contract.Sha256;
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.security.MessageDigest;
|
||||
import java.security.NoSuchAlgorithmException;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* Build/test-only validator for the common Messaging evidence manifest.
|
||||
*
|
||||
* <p>This class deliberately owns the file reads outside production runtime code. Validation uses
|
||||
* the same pinned Draft 2020-12 registry as the Messaging adapter and consumes the exact schema and
|
||||
* manifest bytes supplied by the build.
|
||||
*/
|
||||
public final class MessagingEvidenceManifestSchemaValidator {
|
||||
|
||||
private static final String SCHEMA_ID = "urn:dev.caskeleton:messaging:build-evidence-manifest:v1";
|
||||
private static final String SCHEMA_RESOURCE =
|
||||
"config/messaging/evidence/build-evidence-manifest-v1.schema.json";
|
||||
private static final EnvelopeAdmissionLimits LIMITS =
|
||||
new EnvelopeAdmissionLimits(
|
||||
64, 4_096, 16_384, 10_000, 1_000, 1_000, 1_000_000, 1_000_000, 100, 4_096, 32);
|
||||
|
||||
private MessagingEvidenceManifestSchemaValidator() {}
|
||||
|
||||
public static void main(String[] arguments) {
|
||||
if (arguments.length != 2) {
|
||||
throw new IllegalArgumentException(
|
||||
"expected exact common-schema path and generated-manifest path");
|
||||
}
|
||||
try {
|
||||
validate(
|
||||
Files.readAllBytes(Path.of(arguments[0])), Files.readAllBytes(Path.of(arguments[1])));
|
||||
} catch (IOException exception) {
|
||||
throw new IllegalStateException(
|
||||
"cannot read exact Messaging evidence qualification bytes", exception);
|
||||
}
|
||||
}
|
||||
|
||||
static void validate(byte[] exactSchemaBytes, byte[] exactManifestBytes) {
|
||||
LocalJsonSchemaRegistry registry =
|
||||
new LocalJsonSchemaRegistry(
|
||||
Map.of(
|
||||
SCHEMA_RESOURCE,
|
||||
new LocalJsonSchemaRegistry.SchemaSource(
|
||||
SCHEMA_RESOURCE, exactSchemaBytes, sha256(exactSchemaBytes))),
|
||||
LIMITS);
|
||||
List<String> errors = registry.validate(SCHEMA_ID, exactManifestBytes);
|
||||
if (!errors.isEmpty()) {
|
||||
throw new IllegalArgumentException(
|
||||
"generated Messaging evidence fails the common Draft 2020-12 schema: " + errors);
|
||||
}
|
||||
}
|
||||
|
||||
private static Sha256 sha256(byte[] bytes) {
|
||||
try {
|
||||
return new Sha256(MessageDigest.getInstance("SHA-256").digest(bytes));
|
||||
} catch (NoSuchAlgorithmException exception) {
|
||||
throw new IllegalStateException("SHA-256 is unavailable", exception);
|
||||
}
|
||||
}
|
||||
}
|
||||
+81
@@ -0,0 +1,81 @@
|
||||
package dev.caskeleton.adapter.outbound.messaging.qualification;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThatCode;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
class MessagingEvidenceManifestSchemaValidatorTest {
|
||||
|
||||
private static final String SHA256 = "sha256:" + "0".repeat(64);
|
||||
private static final String VALID_MANIFEST =
|
||||
"""
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"sourceDigest": "%1$s",
|
||||
"artifactDigest": "%1$s",
|
||||
"producerTask": "verifyMessagingContracts",
|
||||
"scenarioIds": ["scenario.one"],
|
||||
"counts": {"executed": 1, "passed": 1, "failed": 0, "skipped": 0},
|
||||
"command": "./gradlew verifyMessagingContracts",
|
||||
"generatedAt": "2026-07-29T00:00:00Z",
|
||||
"hashes": {
|
||||
"profile": "%1$s",
|
||||
"catalog": "%1$s",
|
||||
"schema": "%1$s",
|
||||
"settings": "%1$s"
|
||||
},
|
||||
"failures": [],
|
||||
"skips": [],
|
||||
"unsupportedClaims": []
|
||||
}
|
||||
"""
|
||||
.formatted(SHA256);
|
||||
|
||||
@Test
|
||||
void validatesAConformingManifestAgainstTheExactCommonSchema() throws IOException {
|
||||
assertThatCode(
|
||||
() ->
|
||||
MessagingEvidenceManifestSchemaValidator.validate(
|
||||
commonSchemaBytes(), VALID_MANIFEST.getBytes(StandardCharsets.UTF_8)))
|
||||
.doesNotThrowAnyException();
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsManifestDriftThatTheCommonSchemaForbids() throws IOException {
|
||||
String drifted = VALID_MANIFEST.replace("\n}", ",\n \"unexpected\": true\n}");
|
||||
|
||||
assertThatThrownBy(
|
||||
() ->
|
||||
MessagingEvidenceManifestSchemaValidator.validate(
|
||||
commonSchemaBytes(), drifted.getBytes(StandardCharsets.UTF_8)))
|
||||
.isInstanceOf(IllegalArgumentException.class)
|
||||
.hasMessageContaining("common Draft 2020-12 schema");
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsCommonSchemaDriftThatFailsDraft202012MetaValidation() throws IOException {
|
||||
String invalidSchema =
|
||||
new String(commonSchemaBytes(), StandardCharsets.UTF_8)
|
||||
.replaceFirst("\"type\"\\s*:\\s*\"object\"", "\"type\": 37");
|
||||
|
||||
assertThatThrownBy(
|
||||
() ->
|
||||
MessagingEvidenceManifestSchemaValidator.validate(
|
||||
invalidSchema.getBytes(StandardCharsets.UTF_8),
|
||||
VALID_MANIFEST.getBytes(StandardCharsets.UTF_8)))
|
||||
.isInstanceOf(IllegalArgumentException.class);
|
||||
}
|
||||
|
||||
private static byte[] commonSchemaBytes() throws IOException {
|
||||
String configured = System.getProperty("messaging.commonEvidenceSchema");
|
||||
if (configured == null || configured.isBlank()) {
|
||||
throw new IllegalStateException("messaging.commonEvidenceSchema test path is required");
|
||||
}
|
||||
return Files.readAllBytes(Path.of(configured));
|
||||
}
|
||||
}
|
||||
+13
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"name": "",
|
||||
"count": -1,
|
||||
"enabled": true,
|
||||
"amount": 12.50,
|
||||
"status": "UNKNOWN",
|
||||
"note": null,
|
||||
"tags": [],
|
||||
"nested": {
|
||||
"code": "N1"
|
||||
},
|
||||
"unknown": "closed-schema"
|
||||
}
|
||||
+70
@@ -0,0 +1,70 @@
|
||||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"$id": "urn:dev-caskeleton:contracts:messaging:test.event:v1",
|
||||
"title": "Task 6 deterministic encoder test payload v1",
|
||||
"type": "object",
|
||||
"required": [
|
||||
"name",
|
||||
"count",
|
||||
"enabled",
|
||||
"amount",
|
||||
"status",
|
||||
"note",
|
||||
"tags",
|
||||
"nested"
|
||||
],
|
||||
"properties": {
|
||||
"name": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"maxLength": 64
|
||||
},
|
||||
"count": {
|
||||
"type": "integer",
|
||||
"minimum": 0,
|
||||
"maximum": 999999
|
||||
},
|
||||
"enabled": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"amount": {
|
||||
"type": "number"
|
||||
},
|
||||
"status": {
|
||||
"enum": [
|
||||
"READY",
|
||||
"DONE"
|
||||
]
|
||||
},
|
||||
"note": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
],
|
||||
"maxLength": 64
|
||||
},
|
||||
"tags": {
|
||||
"type": "array",
|
||||
"maxItems": 8,
|
||||
"items": {
|
||||
"type": "string",
|
||||
"maxLength": 32
|
||||
}
|
||||
},
|
||||
"nested": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"code"
|
||||
],
|
||||
"properties": {
|
||||
"code": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"maxLength": 16
|
||||
}
|
||||
},
|
||||
"unevaluatedProperties": false
|
||||
}
|
||||
},
|
||||
"unevaluatedProperties": false
|
||||
}
|
||||
+15
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"name": "정확한-UTF8",
|
||||
"count": 7,
|
||||
"enabled": true,
|
||||
"amount": 12.50,
|
||||
"status": "READY",
|
||||
"note": null,
|
||||
"tags": [
|
||||
"alpha",
|
||||
"β"
|
||||
],
|
||||
"nested": {
|
||||
"code": "N1"
|
||||
}
|
||||
}
|
||||
@@ -834,6 +834,23 @@ app-bootstrap 은 합성 루트라 "왜 이 의존성이, 왜 이 scope 로" 결
|
||||
BOM 이 관리.
|
||||
- **`spring-security-test`** — `@WithMockUser`로 actuator 보안 인가(permit-all 프로브 / authenticated
|
||||
loggers / loggers 쓰기 거부)를 검증한다.
|
||||
- **`snakeyaml`** — `config/messaging/*.yaml`의 first polling-producer tuple, closed maturity,
|
||||
wildcard-free compatibility, evidence task/scenario/runbook 선언을
|
||||
`MessagingCapabilityRegistryContractTest`가 읽어 검증한다. 이 레지스트리는 런타임 설정이 아니며
|
||||
모든 card가 `not-implemented`인 동안 R2나 release-ready를 뜻하지 않는다.
|
||||
|
||||
### Messaging qualification scaffold
|
||||
|
||||
`config/messaging/`은 first polling-producer tuple의 machine-readable 계획 truth만 보관한다.
|
||||
현재 card 11개는 모두 `maturity: not-implemented`, `evidenceFingerprint: ""`이고 consumer,
|
||||
CDC, EOS, schema-registry 확장 row는 없다. Task 6의 `verifyMessagingJsonSchemaV1`과
|
||||
`verifyMessagingContracts`는 exact qualification test와 test/build 전용 Draft 2020-12 manifest
|
||||
validator를 실행한다. combined task는 JSON-only task/validator에 명시적으로 의존하므로 CLI 순서와
|
||||
무관하게 shared `contracts-schema/manifest.json`의 최종 소유자가 된다. 나머지 `verifyMessaging*`
|
||||
root task는 후속 owner test와 payload-free evidence validator가 구현되기 전까지 공통 fail-closed
|
||||
guard에서 반드시 non-zero로 종료한다. 파일 존재, SKIP, 오래된 evidence, 다른 source digest 또는
|
||||
다른 profile hash를 PASS로 취급하지 않는다. 이 scaffold는 broker client, scheduler, thread,
|
||||
network 또는 다른 런타임 resource를 생성하지 않는다.
|
||||
|
||||
### ArchUnit "violation-as-data" fixture 의존성 (test 컴파일러 전용)
|
||||
ArchUnit 규칙이 **금지**하는 타입을 fixture 가 일부러 import 해서, 규칙이 실제로 그 위반을 잡는지
|
||||
|
||||
@@ -106,6 +106,8 @@ dependencies {
|
||||
testImplementation 'com.tngtech.archunit:archunit-junit5:1.3.0'
|
||||
// test-only: jackson-databind for the deserialization-policy boundary test. See README.
|
||||
testImplementation 'org.springframework.boot:spring-boot-starter-json'
|
||||
// test-only: parses checked-in Messaging capability registries for the fail-closed drift gate.
|
||||
testImplementation 'org.yaml:snakeyaml'
|
||||
// sample-on only: ArchUnit analyses the reference impl. sampleOffTest intentionally omits it.
|
||||
sampleFixture project(':sample-portfolio')
|
||||
// test-only: ArchUnit violation fixtures intentionally import forbidden types. See README.
|
||||
|
||||
@@ -9,6 +9,7 @@ ch.qos.logback:logback-core:1.5.21=compileClasspath,productionRuntimeClasspath,r
|
||||
ch.qos.logback:logback-core:1.5.34=sampleFixture
|
||||
com.approvaltests:approvaltests-util:31.0.0=redisCompositionTestCompileClasspath,redisCompositionTestRuntimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
com.approvaltests:approvaltests:31.0.0=redisCompositionTestCompileClasspath,redisCompositionTestRuntimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
com.ethlo.time:itu:1.14.0=productionRuntimeClasspath,redisCompositionTestRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath
|
||||
com.fasterxml.jackson.core:jackson-annotations:2.20=compileClasspath,productionRuntimeClasspath,redisCompositionTestCompileClasspath,redisCompositionTestRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
com.fasterxml.jackson.core:jackson-annotations:2.21=sampleFixture
|
||||
com.fasterxml.jackson.core:jackson-core:2.20.1=compileClasspath,productionRuntimeClasspath,redisCompositionTestCompileClasspath,redisCompositionTestRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
@@ -57,6 +58,7 @@ com.google.j2objc:j2objc-annotations:3.1=annotationProcessor,checkstyle,redisCom
|
||||
com.google.protobuf:protobuf-java:4.33.2=annotationProcessor,redisCompositionTestAnnotationProcessor,sampleOffTestAnnotationProcessor,testAnnotationProcessor
|
||||
com.h3xstream.findsecbugs:findsecbugs-plugin:1.14.0=spotbugsPlugins
|
||||
com.jayway.jsonpath:json-path:2.9.0=redisCompositionTestCompileClasspath,redisCompositionTestRuntimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
com.networknt:json-schema-validator:3.0.2=productionRuntimeClasspath,redisCompositionTestRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath
|
||||
com.nimbusds:nimbus-jose-jwt:10.4=productionRuntimeClasspath,redisCompositionTestRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath
|
||||
com.nimbusds:nimbus-jose-jwt:9.37.4=sampleFixture
|
||||
com.puppycrawl.tools:checkstyle:13.5.0=checkstyle
|
||||
@@ -346,7 +348,7 @@ org.springframework.boot:spring-boot-starter-jackson:4.0.0=productionRuntimeClas
|
||||
org.springframework.boot:spring-boot-starter-jdbc:3.5.16=sampleFixture
|
||||
org.springframework.boot:spring-boot-starter-jdbc:4.0.0=productionRuntimeClasspath,redisCompositionTestCompileClasspath,redisCompositionTestRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-starter-json:3.5.16=sampleFixture
|
||||
org.springframework.boot:spring-boot-starter-json:4.0.0=redisCompositionTestCompileClasspath,redisCompositionTestRuntimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-starter-json:4.0.0=productionRuntimeClasspath,redisCompositionTestCompileClasspath,redisCompositionTestRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-starter-logging:3.5.16=sampleFixture
|
||||
org.springframework.boot:spring-boot-starter-logging:4.0.0=compileClasspath,productionRuntimeClasspath,redisCompositionTestCompileClasspath,redisCompositionTestRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-starter-micrometer-metrics:4.0.0=compileClasspath,productionRuntimeClasspath,redisCompositionTestCompileClasspath,redisCompositionTestRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
|
||||
+20
@@ -8,6 +8,8 @@ import com.tngtech.archunit.lang.EvaluationResult;
|
||||
import dev.caskeleton.application.architecture.violations.ApplicationDiagnosticFrameworkViolation;
|
||||
import dev.caskeleton.bootstrap.architecture.allowed.application.CleanProjectionQueryPort;
|
||||
import dev.caskeleton.bootstrap.architecture.fixtures.application.RootWriteTransactionBoundaryUseCase;
|
||||
import dev.caskeleton.bootstrap.architecture.violations.adapter.outbound.RawExternalResponseFixture;
|
||||
import dev.caskeleton.bootstrap.architecture.violations.adapter.outbound.messaging.GenericTypeLeakingAdapterFixture;
|
||||
import dev.caskeleton.bootstrap.architecture.violations.application.BulkWriteWithoutWriteAccessUseCase;
|
||||
import dev.caskeleton.bootstrap.architecture.violations.application.FixtureRepository;
|
||||
import dev.caskeleton.bootstrap.architecture.violations.application.GenericLeakQueryPort;
|
||||
@@ -96,6 +98,10 @@ class ArchitectureViolationFixtureTest {
|
||||
private static final JavaClasses CLEAN_PROJECTION_QUERY_PORT_ONLY =
|
||||
new ClassFileImporter().importClasses(CleanProjectionQueryPort.class);
|
||||
|
||||
private static final JavaClasses GENERIC_ADAPTER_LEAK_ONLY =
|
||||
new ClassFileImporter()
|
||||
.importClasses(GenericTypeLeakingAdapterFixture.class, RawExternalResponseFixture.class);
|
||||
|
||||
// feature-domain-modeling-guardrails: the @ValueObject rule is an OR of an annotation
|
||||
// branch and a "..domain.vo.." package branch — each is imported in ISOLATION so a
|
||||
// silently broken branch cannot pass vacuously via the other one in the shared pool.
|
||||
@@ -445,6 +451,20 @@ class ArchitectureViolationFixtureTest {
|
||||
.isTrue();
|
||||
}
|
||||
|
||||
@Test
|
||||
void outboundAdapterMethodReturnsOnlyDomainCatchesGenericAdapterLeakInIsolation() {
|
||||
EvaluationResult result =
|
||||
CleanArchitectureTest
|
||||
.MESSAGING_OUTBOUND_PUBLIC_INSTANCE_METHODS_DO_NOT_LEAK_ADAPTER_TYPES_THROUGH_GENERICS
|
||||
.evaluate(GENERIC_ADAPTER_LEAK_ONLY);
|
||||
|
||||
assertThat(result.hasViolation())
|
||||
.as(
|
||||
"MESSAGING_OUTBOUND_PUBLIC_INSTANCE_METHODS_DO_NOT_LEAK_ADAPTER_TYPES_THROUGH_GENERICS "
|
||||
+ "must recursively catch List<RawExternalResponseFixture> (B7)")
|
||||
.isTrue();
|
||||
}
|
||||
|
||||
@Test
|
||||
void controllerRequestMappingsFollowAip122CatchesKebabPathFixture() {
|
||||
EvaluationResult result =
|
||||
|
||||
+56
@@ -1497,6 +1497,62 @@ class CleanArchitectureTest {
|
||||
}
|
||||
}
|
||||
|
||||
@ArchTest
|
||||
static final ArchRule
|
||||
MESSAGING_OUTBOUND_PUBLIC_INSTANCE_METHODS_DO_NOT_LEAK_ADAPTER_TYPES_THROUGH_GENERICS =
|
||||
methods()
|
||||
.that()
|
||||
.areDeclaredInClassesThat()
|
||||
.resideInAPackage("..adapter.outbound.messaging..")
|
||||
.and()
|
||||
.areDeclaredInClassesThat()
|
||||
.areNotAnnotatedWith("org.springframework.context.annotation.Configuration")
|
||||
.and()
|
||||
.areDeclaredInClassesThat()
|
||||
.areNotAnnotatedWith(
|
||||
"org.springframework.boot.context.properties.ConfigurationProperties")
|
||||
.and()
|
||||
.arePublic()
|
||||
.and()
|
||||
.areNotStatic()
|
||||
.should(
|
||||
notReturnAdapterTypesIncludingGenericArguments(
|
||||
"..adapter.outbound..",
|
||||
"..adapter.inbound.web..",
|
||||
"..adapter.outbound.persistence.."))
|
||||
.as(
|
||||
"B7 messaging hardening: every public non-static messaging method must keep "
|
||||
+ "adapter-local types out of direct and recursive generic return positions; "
|
||||
+ "narrow public static composition bridges are the only allowed adapter-local "
|
||||
+ "return seam")
|
||||
.allowEmptyShould(true);
|
||||
|
||||
private static ArchCondition<JavaMethod> notReturnAdapterTypesIncludingGenericArguments(
|
||||
String... forbiddenPackages) {
|
||||
DescribedPredicate<JavaClass> forbidden =
|
||||
JavaClass.Predicates.resideInAnyPackage(forbiddenPackages);
|
||||
return new ArchCondition<>(
|
||||
"not return adapter-local types directly or through generic arguments") {
|
||||
@Override
|
||||
public void check(JavaMethod method, ConditionEvents events) {
|
||||
for (JavaClass involved : method.getReturnType().getAllInvolvedRawTypes()) {
|
||||
if (forbidden.test(involved)) {
|
||||
events.add(
|
||||
SimpleConditionEvent.violated(
|
||||
method,
|
||||
"Method "
|
||||
+ method.getFullName()
|
||||
+ " leaks "
|
||||
+ involved.getName()
|
||||
+ " through its return type (directly or as a generic argument)"
|
||||
+ " — B7 permits only narrow static composition bridges for "
|
||||
+ "adapter-local types"));
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
@ArchTest
|
||||
static final ArchRule VALID_CASCADE_DEPTH_AT_MOST_THREE =
|
||||
classes()
|
||||
|
||||
+1
@@ -121,6 +121,7 @@ class DisabledAdapterArchitectureTest {
|
||||
"org.springframework.beans.factory.ObjectProvider",
|
||||
"org.springframework.beans.factory.ObjectProvider",
|
||||
"org.springframework.beans.factory.ObjectProvider",
|
||||
"org.springframework.beans.factory.ObjectProvider",
|
||||
"dev.caskeleton.adapter.outbound.cache.redis.RedisCapabilityObservationPort"));
|
||||
}
|
||||
|
||||
|
||||
+14
@@ -0,0 +1,14 @@
|
||||
package dev.caskeleton.bootstrap.architecture.violations.adapter.outbound.messaging;
|
||||
|
||||
import dev.caskeleton.bootstrap.architecture.violations.adapter.outbound.RawExternalResponseFixture;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* Negative B7 fixture proving adapter-local types cannot escape through generic return arguments.
|
||||
*/
|
||||
public class GenericTypeLeakingAdapterFixture {
|
||||
|
||||
public List<RawExternalResponseFixture> leakGeneric() {
|
||||
return List.of(new RawExternalResponseFixture());
|
||||
}
|
||||
}
|
||||
+510
@@ -0,0 +1,510 @@
|
||||
package dev.caskeleton.bootstrap.contract.messaging;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
|
||||
import com.fasterxml.jackson.core.type.TypeReference;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import java.io.InputStream;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashSet;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.yaml.snakeyaml.LoaderOptions;
|
||||
import org.yaml.snakeyaml.Yaml;
|
||||
import org.yaml.snakeyaml.constructor.SafeConstructor;
|
||||
import org.yaml.snakeyaml.error.YAMLException;
|
||||
|
||||
class MessagingCapabilityRegistryContractTest {
|
||||
|
||||
private static final Set<String> FIRST_TUPLE_CARD_IDS =
|
||||
Set.of(
|
||||
"messaging-outbox-publish.v1",
|
||||
"kafka-spring-acknowledged-idempotent.v1",
|
||||
"postgresql-polling-outbox.v2",
|
||||
"postgresql-per-record-jit-claim.v1",
|
||||
"json-schema-envelope.v1",
|
||||
"external-topic-validated.v1",
|
||||
"kafka-sasl-ssl-scram-sha-512.v1",
|
||||
"kafka-compression-none.v1",
|
||||
"per-key-normal-path-sequence-detectable.v1",
|
||||
"same-postgresql-transaction-resource.v1",
|
||||
"authenticated-internal-web-disposition.v1");
|
||||
|
||||
private static final Set<String> LEGAL_MATURITY =
|
||||
Set.of("not-implemented", "implemented-candidate", "release-eligible");
|
||||
|
||||
private static final Set<String> REQUIRED_VERIFICATION_TASKS =
|
||||
Set.of(
|
||||
"verifyMessagingContracts",
|
||||
"verifyMessagingJsonSchemaV1",
|
||||
"verifyMessagingPollingOutboxR2",
|
||||
"verifyMessagingKafkaProducerR2",
|
||||
"verifyMessagingSecurityR2",
|
||||
"verifyMessagingReleaseProfile",
|
||||
"verifyMessagingTargetBindingPreflight",
|
||||
"verifyMessagingTargetBinding",
|
||||
"verifyMessagingDeploymentCutover",
|
||||
"verifyMessagingCleanupTargetBinding",
|
||||
"verifyMessagingFinalR2Profile");
|
||||
|
||||
private static final Set<String> FORBIDDEN_EXTENSION_TOKENS =
|
||||
Set.of(
|
||||
"consumer",
|
||||
"inbox",
|
||||
"cdc",
|
||||
"debezium",
|
||||
"connect",
|
||||
"eos",
|
||||
"exactly-once",
|
||||
"schema-registry",
|
||||
"schema_registry",
|
||||
"avro",
|
||||
"protobuf");
|
||||
|
||||
private static final List<String> COMPATIBILITY_SELECTOR_FIELDS =
|
||||
List.of(
|
||||
"semanticContractId",
|
||||
"dispatchProfile",
|
||||
"claimProfile",
|
||||
"serializationProfile",
|
||||
"producerProfile",
|
||||
"securityProfile",
|
||||
"topicProfile",
|
||||
"compressionProfile",
|
||||
"orderingProfile",
|
||||
"transactionProfile",
|
||||
"operatorControlProfile");
|
||||
|
||||
@Test
|
||||
void yamlLoaderRejectsDuplicateKeys() {
|
||||
String duplicateKeyFixture =
|
||||
"""
|
||||
schemaVersion: 1
|
||||
cards:
|
||||
- cardId: first.v1
|
||||
maturity: not-implemented
|
||||
maturity: release-eligible
|
||||
""";
|
||||
|
||||
assertThatThrownBy(() -> readYaml(duplicateKeyFixture))
|
||||
.isInstanceOf(YAMLException.class)
|
||||
.hasMessageContaining("duplicate");
|
||||
}
|
||||
|
||||
@Test
|
||||
void readinessCardsContainExactlyTheFirstPollingProducerTuple() throws Exception {
|
||||
Map<String, Object> registry = readYaml(requiredConfig("readiness-cards.yaml"));
|
||||
List<Map<String, Object>> cards = rows(registry, "cards");
|
||||
Set<String> cardIds = stringValues(cards, "cardId");
|
||||
|
||||
assertThat(cardIds).containsExactlyInAnyOrderElementsOf(FIRST_TUPLE_CARD_IDS);
|
||||
assertThat(cards).hasSize(FIRST_TUPLE_CARD_IDS.size());
|
||||
assertThat(cardIds).hasSameSizeAs(cards);
|
||||
}
|
||||
|
||||
@Test
|
||||
void onlyTheQualifiedJsonSchemaCardIsAnImplementedCandidate() throws Exception {
|
||||
List<Map<String, Object>> cards =
|
||||
rows(readYaml(requiredConfig("readiness-cards.yaml")), "cards");
|
||||
|
||||
for (Map<String, Object> card : cards) {
|
||||
assertThat(card.get("maturity")).isIn(LEGAL_MATURITY);
|
||||
assertThat(card.get("evidenceFingerprint")).isEqualTo("");
|
||||
if ("json-schema-envelope.v1".equals(card.get("cardId"))) {
|
||||
assertThat(card.get("maturity")).isEqualTo("implemented-candidate");
|
||||
assertThat(card.get("schemaSetHash"))
|
||||
.isEqualTo("sha256:42040504d5c204f9ee0e01bfa17fd9a03182db13f8f99e31b63ec79d5ecc40d0");
|
||||
assertThat(card.get("settingsDigest"))
|
||||
.isEqualTo("sha256:fcd849322d43a8d88c926a43160296339cbc6e945db81e46c513ab45d185b924");
|
||||
} else {
|
||||
assertThat(card.get("maturity")).isEqualTo("not-implemented");
|
||||
assertThat(card.get("schemaSetHash")).isEqualTo("");
|
||||
assertThat(card.get("settingsDigest")).isEqualTo("");
|
||||
}
|
||||
assertNonEmptyStringList(card, "evidenceTasks");
|
||||
assertThat(stringList(card, "evidenceTasks"))
|
||||
.as("evidenceTasks on %s must reference declared root tasks", card.get("cardId"))
|
||||
.allMatch(REQUIRED_VERIFICATION_TASKS::contains);
|
||||
assertNonEmptyStringList(card, "requiredScenarios");
|
||||
assertNonEmptyStringList(card, "runbookIds");
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void compatibilityProfilesAreExactUniqueAndWildcardFree() throws Exception {
|
||||
Map<String, Object> registry = readYaml(requiredConfig("profile-compatibility.yaml"));
|
||||
List<Map<String, Object>> profiles = rows(registry, "profiles");
|
||||
|
||||
assertThat(profiles).isNotEmpty();
|
||||
assertUnique(profiles, "profileId");
|
||||
assertThat(flattenStrings(registry))
|
||||
.allSatisfy(
|
||||
value -> {
|
||||
assertThat(value).doesNotContain("*");
|
||||
assertThat(value).doesNotContain("?");
|
||||
});
|
||||
|
||||
for (Map<String, Object> profile : profiles) {
|
||||
List<String> selectedCardIds = stringList(profile, "selectedCardIds");
|
||||
assertListIntegrity(selectedCardIds, "selectedCardIds", profile.get("profileId"));
|
||||
assertThat(selectedCardIds).containsExactlyInAnyOrderElementsOf(FIRST_TUPLE_CARD_IDS);
|
||||
assertThat(selectedCardIds).allMatch(FIRST_TUPLE_CARD_IDS::contains);
|
||||
for (String selectorField : COMPATIBILITY_SELECTOR_FIELDS) {
|
||||
assertThat(profile.get(selectorField))
|
||||
.as("%s must select one of selectedCardIds", selectorField)
|
||||
.isIn(selectedCardIds);
|
||||
}
|
||||
assertNonEmptyStringList(profile, "requiredScenarios");
|
||||
assertListIntegrityIfPresent(profile, "runbookIds");
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void releaseAssertionsDeclareUniqueTasksScenariosRunbooksAndFailClosedEvidencePolicy()
|
||||
throws Exception {
|
||||
Map<String, Object> registry = readYaml(requiredConfig("release-profile-assertions.yaml"));
|
||||
List<Map<String, Object>> profiles = rows(registry, "releaseProfiles");
|
||||
|
||||
assertThat(profiles).isNotEmpty();
|
||||
assertUnique(profiles, "releaseProfileId");
|
||||
Set<String> compatibilityProfileIds =
|
||||
stringValues(
|
||||
rows(readYaml(requiredConfig("profile-compatibility.yaml")), "profiles"), "profileId");
|
||||
for (Map<String, Object> profile : profiles) {
|
||||
List<String> selectedCardIds = stringList(profile, "selectedCardIds");
|
||||
assertListIntegrity(selectedCardIds, "selectedCardIds", profile.get("releaseProfileId"));
|
||||
assertThat(selectedCardIds).containsExactlyInAnyOrderElementsOf(FIRST_TUPLE_CARD_IDS);
|
||||
assertThat(profile.get("compatibilityProfileId")).isIn(compatibilityProfileIds);
|
||||
assertThat(profile.get("requiredCardMaturity")).isEqualTo("release-eligible");
|
||||
|
||||
List<String> requiredEvidenceTasks = stringList(profile, "requiredEvidenceTasks");
|
||||
assertListIntegrity(
|
||||
requiredEvidenceTasks, "requiredEvidenceTasks", profile.get("releaseProfileId"));
|
||||
assertThat(requiredEvidenceTasks)
|
||||
.containsExactlyInAnyOrderElementsOf(REQUIRED_VERIFICATION_TASKS);
|
||||
assertNonEmptyStringList(profile, "requiredScenarios");
|
||||
assertNonEmptyStringList(profile, "runbookIds");
|
||||
|
||||
Map<String, Object> evidencePolicy = map(profile, "evidencePolicy");
|
||||
assertThat(evidencePolicy)
|
||||
.containsEntry("rejectMissing", true)
|
||||
.containsEntry("rejectSkipped", true)
|
||||
.containsEntry("rejectStale", true)
|
||||
.containsEntry("rejectWrongSource", true)
|
||||
.containsEntry("rejectMismatchedProfile", true);
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void registriesDoNotPredeclareFutureConsumerCdcEosOrSchemaRegistryRows() throws Exception {
|
||||
Map<String, Object> readiness = readYaml(requiredConfig("readiness-cards.yaml"));
|
||||
Map<String, Object> compatibility = readYaml(requiredConfig("profile-compatibility.yaml"));
|
||||
Map<String, Object> releases = readYaml(requiredConfig("release-profile-assertions.yaml"));
|
||||
|
||||
List<String> rowIdentities = new ArrayList<>();
|
||||
rowIdentities.addAll(stringValues(rows(readiness, "cards"), "cardId"));
|
||||
rowIdentities.addAll(stringValues(rows(compatibility, "profiles"), "profileId"));
|
||||
rowIdentities.addAll(stringValues(rows(releases, "releaseProfiles"), "releaseProfileId"));
|
||||
for (Map<String, Object> profile : rows(compatibility, "profiles")) {
|
||||
rowIdentities.addAll(stringList(profile, "selectedCardIds"));
|
||||
}
|
||||
for (Map<String, Object> profile : rows(releases, "releaseProfiles")) {
|
||||
rowIdentities.addAll(stringList(profile, "selectedCardIds"));
|
||||
}
|
||||
|
||||
for (String identity : rowIdentities) {
|
||||
String normalized = identity.toLowerCase(java.util.Locale.ROOT);
|
||||
assertThat(FORBIDDEN_EXTENSION_TOKENS)
|
||||
.noneSatisfy(
|
||||
token ->
|
||||
assertThat(normalized)
|
||||
.as(
|
||||
"machine row '%s' must not predeclare future extension token '%s'",
|
||||
identity, token)
|
||||
.contains(token));
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void commonEvidenceSchemaIsPayloadFreeAndKeepsAllFailClosedFields() throws Exception {
|
||||
Path schema = requiredConfig("evidence/build-evidence-manifest-v1.schema.json");
|
||||
Map<String, Object> root =
|
||||
new ObjectMapper().readValue(schema.toFile(), new TypeReference<Map<String, Object>>() {});
|
||||
|
||||
assertThat(root.get("$schema")).isEqualTo("https://json-schema.org/draft/2020-12/schema");
|
||||
assertThat(root).containsEntry("type", "object").containsEntry("additionalProperties", false);
|
||||
assertThat(stringList(root, "required"))
|
||||
.containsExactlyInAnyOrder(
|
||||
"schemaVersion",
|
||||
"sourceDigest",
|
||||
"artifactDigest",
|
||||
"producerTask",
|
||||
"scenarioIds",
|
||||
"counts",
|
||||
"command",
|
||||
"generatedAt",
|
||||
"hashes",
|
||||
"failures",
|
||||
"skips",
|
||||
"unsupportedClaims");
|
||||
|
||||
String schemaText = Files.readString(schema).toLowerCase(java.util.Locale.ROOT);
|
||||
assertThat(schemaText)
|
||||
.doesNotContain("\"payload\"")
|
||||
.contains("\"profile\"")
|
||||
.contains("\"catalog\"")
|
||||
.contains("\"schema\"")
|
||||
.contains("\"settings\"");
|
||||
}
|
||||
|
||||
@Test
|
||||
void commonEvidenceSchemaClosesNestedObjectsAndPreservesCoreFieldConstraints() throws Exception {
|
||||
Map<String, Object> root = readJsonSchema();
|
||||
Map<String, Object> properties = map(root, "properties");
|
||||
Map<String, Object> definitions = map(root, "$defs");
|
||||
|
||||
assertThat(map(properties, "schemaVersion"))
|
||||
.containsEntry("type", "integer")
|
||||
.containsEntry("const", 1);
|
||||
assertThat(map(properties, "sourceDigest")).containsEntry("$ref", "#/$defs/sha256");
|
||||
assertThat(map(properties, "artifactDigest")).containsEntry("$ref", "#/$defs/sha256");
|
||||
|
||||
Map<String, Object> producerTask = map(properties, "producerTask");
|
||||
assertThat(producerTask).containsEntry("type", "string");
|
||||
assertThat(stringList(producerTask, "enum"))
|
||||
.containsExactlyInAnyOrderElementsOf(REQUIRED_VERIFICATION_TASKS);
|
||||
|
||||
assertArrayOfReference(
|
||||
map(properties, "scenarioIds"), "#/$defs/identifier", true, Integer.valueOf(1));
|
||||
assertClosedObject(
|
||||
map(properties, "counts"), Set.of("executed", "passed", "failed", "skipped"));
|
||||
Map<String, Object> countProperties = map(map(properties, "counts"), "properties");
|
||||
assertIntegerMinimum(countProperties, "executed", 1);
|
||||
assertIntegerMinimum(countProperties, "passed", 0);
|
||||
assertIntegerMinimum(countProperties, "failed", 0);
|
||||
assertIntegerMinimum(countProperties, "skipped", 0);
|
||||
|
||||
assertThat(map(properties, "command")).containsEntry("type", "string");
|
||||
assertThat(map(properties, "generatedAt"))
|
||||
.containsEntry("type", "string")
|
||||
.containsEntry("format", "date-time");
|
||||
|
||||
Map<String, Object> hashes = map(properties, "hashes");
|
||||
assertClosedObject(hashes, Set.of("profile", "catalog", "schema", "settings"));
|
||||
Map<String, Object> hashProperties = map(hashes, "properties");
|
||||
for (String hashName : List.of("profile", "catalog", "schema", "settings")) {
|
||||
assertThat(map(hashProperties, hashName)).containsEntry("$ref", "#/$defs/sha256");
|
||||
}
|
||||
|
||||
assertArrayOfReference(map(properties, "failures"), "#/$defs/result", false, null);
|
||||
assertArrayOfReference(map(properties, "skips"), "#/$defs/result", false, null);
|
||||
assertArrayOfReference(map(properties, "unsupportedClaims"), "#/$defs/identifier", true, null);
|
||||
|
||||
assertThat(map(definitions, "sha256"))
|
||||
.containsEntry("type", "string")
|
||||
.containsEntry("pattern", "^sha256:[a-f0-9]{64}$");
|
||||
assertThat(map(definitions, "identifier"))
|
||||
.containsEntry("type", "string")
|
||||
.containsEntry("pattern", "^[A-Za-z0-9][A-Za-z0-9._:-]*$");
|
||||
|
||||
Map<String, Object> result = map(definitions, "result");
|
||||
assertClosedObject(result, Set.of("scenarioId", "reason"));
|
||||
Map<String, Object> resultProperties = map(result, "properties");
|
||||
assertThat(map(resultProperties, "scenarioId")).containsEntry("$ref", "#/$defs/identifier");
|
||||
assertThat(map(resultProperties, "reason"))
|
||||
.containsEntry("type", "string")
|
||||
.containsEntry("minLength", 1);
|
||||
}
|
||||
|
||||
@Test
|
||||
void rootBuildDeclaresEveryFailClosedVerificationTaskThroughTheSharedGuard() throws Exception {
|
||||
String build = Files.readString(repositorySrcRoot().resolve("build.gradle"));
|
||||
|
||||
assertThat(build).contains("messagingFailClosedEvidenceGuard");
|
||||
assertThat(build)
|
||||
.contains(
|
||||
"messagingVerificationSkeletons.each",
|
||||
"tasks.register(taskName)",
|
||||
"messagingFailClosedEvidenceGuard(taskName, evidencePaths)",
|
||||
"qualification producer/tests and common-schema validator are not implemented",
|
||||
"missing evidence",
|
||||
"contains skipped evidence",
|
||||
"is stale or future-dated",
|
||||
"has wrong source digest",
|
||||
"has mismatched profile hash");
|
||||
for (String taskName : REQUIRED_VERIFICATION_TASKS) {
|
||||
assertThat(build).contains("'" + taskName + "'");
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void rootBuildSchemaValidatesEvidenceAndDeterministicallyLeavesCombinedEvidenceLast()
|
||||
throws Exception {
|
||||
String build = Files.readString(repositorySrcRoot().resolve("build.gradle"));
|
||||
|
||||
assertThat(build)
|
||||
.contains(
|
||||
"MessagingEvidenceManifestSchemaValidator",
|
||||
"validateMessagingJsonSchemaV1EvidenceManifestSchema",
|
||||
"validateMessagingContractsEvidenceManifestSchema",
|
||||
"verifyMessagingJsonSchemaV1.configure",
|
||||
"finalizedBy validateMessagingJsonSchemaV1EvidenceManifestSchema",
|
||||
"dependsOn validateMessagingJsonSchemaV1EvidenceManifestSchema",
|
||||
"verifyMessagingContracts.configure",
|
||||
"finalizedBy validateMessagingContractsEvidenceManifestSchema");
|
||||
}
|
||||
|
||||
private static Path requiredConfig(String relativePath) {
|
||||
Path path = repositorySrcRoot().resolve("config/messaging").resolve(relativePath);
|
||||
assertThat(path).as("required Messaging configuration %s", path).isRegularFile();
|
||||
return path;
|
||||
}
|
||||
|
||||
private static Path repositorySrcRoot() {
|
||||
Path directory = Path.of("").toAbsolutePath();
|
||||
for (int depth = 0; depth < 8 && directory != null; depth++) {
|
||||
if (Files.isRegularFile(directory.resolve("settings.gradle"))
|
||||
&& Files.isRegularFile(directory.resolve("config/architecture/modules.json"))) {
|
||||
return directory;
|
||||
}
|
||||
directory = directory.getParent();
|
||||
}
|
||||
throw new IllegalStateException(
|
||||
"Could not locate repository src root from test working directory");
|
||||
}
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
private static Map<String, Object> readYaml(Path path) throws Exception {
|
||||
try (InputStream input = Files.newInputStream(path)) {
|
||||
return readYaml(input);
|
||||
}
|
||||
}
|
||||
|
||||
private static Map<String, Object> readYaml(String yaml) {
|
||||
return readYaml((Object) yaml);
|
||||
}
|
||||
|
||||
private static Map<String, Object> readYaml(InputStream yaml) {
|
||||
return readYaml((Object) yaml);
|
||||
}
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
private static Map<String, Object> readYaml(Object yamlInput) {
|
||||
LoaderOptions loaderOptions = new LoaderOptions();
|
||||
loaderOptions.setAllowDuplicateKeys(false);
|
||||
Yaml yaml = new Yaml(new SafeConstructor(loaderOptions));
|
||||
Object value =
|
||||
yamlInput instanceof InputStream input ? yaml.load(input) : yaml.load((String) yamlInput);
|
||||
assertThat(value).isInstanceOf(Map.class);
|
||||
return (Map<String, Object>) value;
|
||||
}
|
||||
|
||||
private static Map<String, Object> readJsonSchema() throws Exception {
|
||||
Path schema = requiredConfig("evidence/build-evidence-manifest-v1.schema.json");
|
||||
return new ObjectMapper()
|
||||
.readValue(schema.toFile(), new TypeReference<Map<String, Object>>() {});
|
||||
}
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
private static List<Map<String, Object>> rows(Map<String, Object> root, String key) {
|
||||
assertThat(root.get(key)).as("top-level '%s' rows", key).isInstanceOf(List.class);
|
||||
return (List<Map<String, Object>>) root.get(key);
|
||||
}
|
||||
|
||||
private static Set<String> stringValues(List<Map<String, Object>> rows, String key) {
|
||||
Set<String> values = new LinkedHashSet<>();
|
||||
for (Map<String, Object> row : rows) {
|
||||
assertThat(row.get(key)).isInstanceOf(String.class);
|
||||
values.add((String) row.get(key));
|
||||
}
|
||||
return values;
|
||||
}
|
||||
|
||||
private static void assertUnique(List<Map<String, Object>> rows, String key) {
|
||||
Set<String> values = stringValues(rows, key);
|
||||
assertThat(values).hasSameSizeAs(rows);
|
||||
}
|
||||
|
||||
private static void assertNonEmptyStringList(Map<String, Object> row, String key) {
|
||||
assertListIntegrity(stringList(row, key), key, row);
|
||||
}
|
||||
|
||||
private static void assertListIntegrityIfPresent(Map<String, Object> row, String key) {
|
||||
if (row.containsKey(key)) {
|
||||
assertListIntegrity(stringList(row, key), key, row);
|
||||
}
|
||||
}
|
||||
|
||||
private static void assertListIntegrity(List<String> values, String key, Object owner) {
|
||||
assertThat(values)
|
||||
.as("%s on %s", key, owner)
|
||||
.isNotEmpty()
|
||||
.doesNotHaveDuplicates()
|
||||
.allSatisfy(value -> assertThat(value).isNotBlank());
|
||||
}
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
private static List<String> stringList(Map<String, Object> row, String key) {
|
||||
assertThat(row.get(key)).as("required list '%s' on %s", key, row).isInstanceOf(List.class);
|
||||
List<Object> values = (List<Object>) row.get(key);
|
||||
assertThat(values).allMatch(String.class::isInstance);
|
||||
return values.stream().map(String.class::cast).toList();
|
||||
}
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
private static Map<String, Object> map(Map<String, Object> row, String key) {
|
||||
assertThat(row.get(key)).as("required map '%s' on %s", key, row).isInstanceOf(Map.class);
|
||||
return (Map<String, Object>) row.get(key);
|
||||
}
|
||||
|
||||
private static void assertClosedObject(Map<String, Object> schema, Set<String> required) {
|
||||
assertThat(schema).containsEntry("type", "object").containsEntry("additionalProperties", false);
|
||||
assertThat(stringList(schema, "required")).containsExactlyInAnyOrderElementsOf(required);
|
||||
}
|
||||
|
||||
private static void assertArrayOfReference(
|
||||
Map<String, Object> schema, String reference, boolean unique, Integer minimumItems) {
|
||||
assertThat(schema).containsEntry("type", "array");
|
||||
assertThat(map(schema, "items")).containsEntry("$ref", reference);
|
||||
if (unique) {
|
||||
assertThat(schema).containsEntry("uniqueItems", true);
|
||||
}
|
||||
if (minimumItems != null) {
|
||||
assertThat(schema).containsEntry("minItems", minimumItems);
|
||||
}
|
||||
}
|
||||
|
||||
private static void assertIntegerMinimum(
|
||||
Map<String, Object> properties, String field, int minimum) {
|
||||
assertThat(map(properties, field))
|
||||
.containsEntry("type", "integer")
|
||||
.containsEntry("minimum", minimum);
|
||||
}
|
||||
|
||||
private static List<String> flattenStrings(Object value) {
|
||||
List<String> strings = new ArrayList<>();
|
||||
flattenStrings(value, strings, new HashSet<>());
|
||||
return strings;
|
||||
}
|
||||
|
||||
private static void flattenStrings(
|
||||
Object value, List<String> destination, Set<Object> visitedContainers) {
|
||||
if (value instanceof String string) {
|
||||
destination.add(string);
|
||||
} else if (value instanceof Map<?, ?> map && visitedContainers.add(value)) {
|
||||
map.forEach(
|
||||
(key, nested) -> {
|
||||
destination.add(String.valueOf(key));
|
||||
flattenStrings(nested, destination, visitedContainers);
|
||||
});
|
||||
} else if (value instanceof Iterable<?> iterable && visitedContainers.add(value)) {
|
||||
iterable.forEach(nested -> flattenStrings(nested, destination, visitedContainers));
|
||||
}
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -53,7 +53,7 @@ class OutboxAppendTransactionalContractTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
void outboxRowIsAbsentWhenEnclosingTransactionRollsBack() {
|
||||
void sameTransactionAppendRollbackRemovesOutboxRowCharacterization() {
|
||||
Clock clock = Clock.fixed(Instant.now(), ZoneOffset.UTC);
|
||||
|
||||
try (AnnotationConfigApplicationContext ctx =
|
||||
|
||||
+37
@@ -318,6 +318,43 @@ class OutboxRowLifecycleContractTest {
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void equalTimestampRowsAreClaimedTogetherLegacyFifoLimitationCharacterization() {
|
||||
Instant occurredAt = Instant.now();
|
||||
Clock clock = Clock.fixed(occurredAt.plusSeconds(1), ZoneOffset.UTC);
|
||||
|
||||
try (AnnotationConfigApplicationContext ctx =
|
||||
OutboxContainerTestSupport.buildContext(sharedDataSource, e -> {}, clock)) {
|
||||
|
||||
TransactionPort tx = ctx.getBean(TransactionPort.class);
|
||||
OutboxAppendPort append = ctx.getBean(OutboxAppendPort.class);
|
||||
|
||||
String aggregateId = "agg-fifo-tie-" + System.nanoTime();
|
||||
String firstId = "fifo-tie-first-" + System.nanoTime();
|
||||
String secondId = "fifo-tie-second-" + System.nanoTime();
|
||||
|
||||
tx.inWrite(
|
||||
() -> {
|
||||
append.append(newEventAt(firstId, aggregateId, occurredAt));
|
||||
append.append(newEventAt(secondId, aggregateId, occurredAt));
|
||||
return null;
|
||||
});
|
||||
|
||||
OutboxRelayResult cycle =
|
||||
OutboxContainerTestSupport.relayUseCase(ctx)
|
||||
.handle(PublishPendingOutboxEventsCommand.INSTANCE);
|
||||
|
||||
assertThat(cycle.outcomes())
|
||||
.as(
|
||||
"legacy FIFO compares only occurred_at with '<', so equal timestamps do not gate"
|
||||
+ " either row")
|
||||
.filteredOn(
|
||||
outcome -> outcome.eventId().equals(firstId) || outcome.eventId().equals(secondId))
|
||||
.extracting(OutboxRelayResult.EventOutcome::eventId)
|
||||
.containsExactlyInAnyOrder(firstId, secondId);
|
||||
}
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// FIFO gate blocking scenarios (plan Task E)
|
||||
//
|
||||
|
||||
@@ -21,6 +21,8 @@ Package root: `dev.caskeleton.application`.
|
||||
- Own application transaction boundaries through the `TransactionPort` abstraction.
|
||||
- Expose framework-free invocation context through ports such as `CorrelationIdPort`; adapters own
|
||||
MDC or other concrete storage.
|
||||
- Own the framework-free semantic integration-event draft, validated-event value contract and
|
||||
exact typed payload contribution SPI. This is the messaging semantic contract R1 boundary only.
|
||||
|
||||
## Allowed
|
||||
|
||||
@@ -46,6 +48,8 @@ Package root: `dev.caskeleton.application`.
|
||||
- Persistence-layer transaction annotations of any kind inside this module.
|
||||
- Diagnostic frameworks (`org.slf4j`, `java.util.logging`, Logback, Log4j, Micrometer). Express
|
||||
diagnostic intent through a specific outbound `*Port`; adapters own rendering.
|
||||
- Messaging provider/runtime types: physical topic, Kafka record or metadata, JSON tree/raw JSON
|
||||
payload, serializer/schema-validator implementation, security topology and publication epoch.
|
||||
|
||||
## Contract types
|
||||
|
||||
@@ -63,6 +67,10 @@ Package root: `dev.caskeleton.application`.
|
||||
| `capability.UseCaseCapability` | Mandatory annotation on every concrete use case: declares `transactionMode`, `idempotency`, `repositoryAccess`, `externalOutboundAllowed`. |
|
||||
| `capability.Idempotency` | `IDEMPOTENT` / `KEYED` / `NOT_IDEMPOTENT`. |
|
||||
| `capability.RepositoryAccess` | `NONE` / `READ_REPOSITORY` / `WRITE_REPOSITORY`. |
|
||||
| `messaging.contract.IntegrationEventContractContribution<P>` | Closed exact-record payload type, canonical component order, local schema identity/hash and provider-neutral descriptor contribution. |
|
||||
| `messaging.event.IntegrationEventDraft<P>` | Typed semantic event before local encoding; never JSON, Kafka or persistence state. |
|
||||
| `messaging.event.ValidatedIntegrationEvent` | Stable semantic identities plus immutable exact encoded bytes and hashes, ready for a later durable append boundary. |
|
||||
| `messaging.event.IntegrationEventEncoderPort` | Framework-free local draft-to-validated-event boundary implemented by an outbound adapter. |
|
||||
|
||||
## Notification R1 application boundary
|
||||
|
||||
@@ -218,3 +226,6 @@ cd src
|
||||
./gradlew :application-core:test
|
||||
./gradlew :app-bootstrap:test --tests '*CleanArchitectureTest'
|
||||
```
|
||||
|
||||
The messaging types above establish semantic contract R1 only. They do not claim JSON Schema
|
||||
qualification, Kafka publication, durable outbox persistence or any messaging R2 capability.
|
||||
|
||||
@@ -22,6 +22,34 @@ Micrometer meter/tag 렌더링은 Redis adapter가 소유한다. 관측 실패
|
||||
|
||||
---
|
||||
|
||||
## 메시징 semantic contract R1
|
||||
|
||||
`messaging.contract`와 `messaging.event`는 feature가 integration event를 동적 JSON이나 provider
|
||||
타입으로 넘기지 않게 만드는 application 경계다.
|
||||
|
||||
- `IntegrationPayload` 구현은 feature가 소유한 불변 typed record다.
|
||||
- `IntegrationEventContractContribution`은 contract ID와 payload version을 분리하고, exact final
|
||||
record type token, 실제 record component 순서, repository-local schema resource/hash와
|
||||
provider-neutral `ContractDescriptor`만 기여한다. assignable-type 탐색, `Class.forName`, Java
|
||||
class-name routing, `Map`, raw JSON string/tree는 이 SPI에 들어오지 않는다.
|
||||
- `IntegrationEventDraft`는 canonical event/aggregate/order/correlation identity와 typed payload를
|
||||
보유한다. tenant가 없는 모드도 null 대신 canonical system tenant scope를
|
||||
`AggregateIdentity`에 넣어 dedupe/order identity가 PostgreSQL nullable uniqueness에 기대지
|
||||
않게 한다.
|
||||
- `IntegrationEventEncoderPort` 뒤의 adapter가 deterministic encoding과 schema validation을
|
||||
수행하고 `ValidatedIntegrationEvent`를 돌려준다. 결과는 logical destination, exact US-ASCII
|
||||
partition key, exact encoded envelope bytes, schema/envelope hash와 catalog/binding revision을
|
||||
defensive copy로 보존한다.
|
||||
- `ContractDescriptor`는 owner module, logical destination, serializer ID, ordering requirement,
|
||||
payload/envelope byte limit, sensitivity classification, same-event requeue horizon만 표현한다.
|
||||
physical topic, Kafka cluster/security topology는 deployment binding의 책임이다.
|
||||
|
||||
이 단계의 완성 범위는 **framework-free semantic contract R1**이다. JSON Schema validator와
|
||||
deterministic writer, Kafka ACK producer, PostgreSQL outbox append/relay는 후속 R2 작업이며 여기서
|
||||
구현되었거나 검증됐다고 주장하지 않는다.
|
||||
|
||||
---
|
||||
|
||||
## 유스케이스 계약 (usecase / command / query / capability)
|
||||
|
||||
### UseCase / CommandUseCase / QueryUseCase
|
||||
|
||||
+57
@@ -0,0 +1,57 @@
|
||||
package dev.caskeleton.application.messaging.contract;
|
||||
|
||||
import java.time.Duration;
|
||||
|
||||
/** Provider-neutral semantic metadata for one integration-event contract. */
|
||||
public record ContractDescriptor(
|
||||
String ownerModule,
|
||||
LogicalDestinationId logicalDestination,
|
||||
String serializerId,
|
||||
boolean orderingRequired,
|
||||
int maximumPayloadBytes,
|
||||
int maximumEnvelopeBytes,
|
||||
SensitivityClassification sensitivityClassification,
|
||||
Duration sameEventRequeueHorizon) {
|
||||
|
||||
private static final String SEMANTIC_ID = "[a-z][a-z0-9]*(?:[.-][a-z0-9]+)*";
|
||||
private static final Duration MAXIMUM_REQUEUE_HORIZON = Duration.ofDays(365);
|
||||
|
||||
public ContractDescriptor {
|
||||
if (ownerModule == null
|
||||
|| ownerModule.length() > 96
|
||||
|| !ownerModule.matches("[a-z][a-z0-9]*(?:-[a-z0-9]+)*")) {
|
||||
throw new IllegalArgumentException("ownerModule must be a canonical module identifier");
|
||||
}
|
||||
if (logicalDestination == null) {
|
||||
throw new IllegalArgumentException("logicalDestination must not be null");
|
||||
}
|
||||
if (serializerId == null || serializerId.length() > 96 || !serializerId.matches(SEMANTIC_ID)) {
|
||||
throw new IllegalArgumentException("serializerId must be a canonical semantic identifier");
|
||||
}
|
||||
if (maximumPayloadBytes <= 0) {
|
||||
throw new IllegalArgumentException("maximumPayloadBytes must be positive");
|
||||
}
|
||||
if (maximumEnvelopeBytes <= 0 || maximumEnvelopeBytes < maximumPayloadBytes) {
|
||||
throw new IllegalArgumentException(
|
||||
"maximumEnvelopeBytes must be positive and at least maximumPayloadBytes");
|
||||
}
|
||||
if (sensitivityClassification == null) {
|
||||
throw new IllegalArgumentException("sensitivityClassification must not be null");
|
||||
}
|
||||
if (sameEventRequeueHorizon == null
|
||||
|| sameEventRequeueHorizon.isZero()
|
||||
|| sameEventRequeueHorizon.isNegative()
|
||||
|| sameEventRequeueHorizon.compareTo(MAXIMUM_REQUEUE_HORIZON) > 0) {
|
||||
throw new IllegalArgumentException(
|
||||
"sameEventRequeueHorizon must be positive and at most 365 days");
|
||||
}
|
||||
}
|
||||
|
||||
/** Closed vocabulary for payload handling policy. */
|
||||
public enum SensitivityClassification {
|
||||
PUBLIC,
|
||||
INTERNAL,
|
||||
CONFIDENTIAL,
|
||||
RESTRICTED
|
||||
}
|
||||
}
|
||||
+19
@@ -0,0 +1,19 @@
|
||||
package dev.caskeleton.application.messaging.contract;
|
||||
|
||||
/** Stable semantic contract identity. Payload versions are represented separately. */
|
||||
public record ContractId(String value) {
|
||||
|
||||
private static final int MAXIMUM_LENGTH = 160;
|
||||
private static final String SEGMENT = "[a-z][a-z0-9]*(?:-[a-z0-9]+)*";
|
||||
private static final String GRAMMAR = SEGMENT + "(?:\\." + SEGMENT + ")+";
|
||||
|
||||
public ContractId {
|
||||
if (value == null
|
||||
|| value.length() > MAXIMUM_LENGTH
|
||||
|| !value.matches(GRAMMAR)
|
||||
|| value.matches(".*(?:\\.|-)v[0-9]+$")) {
|
||||
throw new IllegalArgumentException(
|
||||
"contractId must be a version-free canonical lower-case semantic identifier");
|
||||
}
|
||||
}
|
||||
}
|
||||
+25
@@ -0,0 +1,25 @@
|
||||
package dev.caskeleton.application.messaging.contract;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* Framework-free contribution to the closed integration-event contract catalog.
|
||||
*
|
||||
* @param <P> exact feature-owned payload record type
|
||||
*/
|
||||
public interface IntegrationEventContractContribution<P extends IntegrationPayload> {
|
||||
|
||||
ContractId contractId();
|
||||
|
||||
int payloadVersion();
|
||||
|
||||
Class<P> exactPayloadRecordType();
|
||||
|
||||
List<String> canonicalRecordComponentOrder();
|
||||
|
||||
SchemaResourceId payloadSchemaResource();
|
||||
|
||||
Sha256 payloadSchemaHash();
|
||||
|
||||
ContractDescriptor descriptor();
|
||||
}
|
||||
+4
@@ -0,0 +1,4 @@
|
||||
package dev.caskeleton.application.messaging.contract;
|
||||
|
||||
/** Marker for a feature-owned, immutable integration-event payload record. */
|
||||
public interface IntegrationPayload {}
|
||||
+17
@@ -0,0 +1,17 @@
|
||||
package dev.caskeleton.application.messaging.contract;
|
||||
|
||||
/** Logical delivery-class identity; never a physical topic, cluster or Java class name. */
|
||||
public record LogicalDestinationId(String value) {
|
||||
|
||||
private static final String GRAMMAR = "[a-z][a-z0-9]*(?:-[a-z0-9]+)*";
|
||||
|
||||
public LogicalDestinationId {
|
||||
if (value == null
|
||||
|| value.length() > 96
|
||||
|| !value.matches(GRAMMAR)
|
||||
|| value.matches(".*-v[0-9]+$")) {
|
||||
throw new IllegalArgumentException(
|
||||
"logicalDestinationId must be a version-free canonical lower-case semantic identifier");
|
||||
}
|
||||
}
|
||||
}
|
||||
+15
@@ -0,0 +1,15 @@
|
||||
package dev.caskeleton.application.messaging.contract;
|
||||
|
||||
/** Canonical repository-local JSON Schema resource identity. */
|
||||
public record SchemaResourceId(String value) {
|
||||
|
||||
private static final String GRAMMAR =
|
||||
"contracts/messaging/[a-z][a-z0-9]*(?:[-.][a-z0-9]+)*/v[1-9][0-9]*\\.schema\\.json";
|
||||
|
||||
public SchemaResourceId {
|
||||
if (value == null || value.length() > 256 || !value.matches(GRAMMAR)) {
|
||||
throw new IllegalArgumentException(
|
||||
"schemaResourceId must identify a versioned local contracts/messaging JSON Schema");
|
||||
}
|
||||
}
|
||||
}
|
||||
+38
@@ -0,0 +1,38 @@
|
||||
package dev.caskeleton.application.messaging.contract;
|
||||
|
||||
import java.util.Arrays;
|
||||
import java.util.HexFormat;
|
||||
|
||||
/** Immutable SHA-256 digest value. */
|
||||
@SuppressWarnings("ArrayRecordComponent")
|
||||
public record Sha256(byte[] bytes) {
|
||||
|
||||
public static final int BYTE_LENGTH = 32;
|
||||
|
||||
public Sha256 {
|
||||
if (bytes == null || bytes.length != BYTE_LENGTH) {
|
||||
throw new IllegalArgumentException("SHA-256 digest must contain exactly 32 bytes");
|
||||
}
|
||||
bytes = bytes.clone();
|
||||
}
|
||||
|
||||
@Override
|
||||
public byte[] bytes() {
|
||||
return bytes.clone();
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean equals(Object other) {
|
||||
return this == other || (other instanceof Sha256 sha256 && Arrays.equals(bytes, sha256.bytes));
|
||||
}
|
||||
|
||||
@Override
|
||||
public int hashCode() {
|
||||
return Arrays.hashCode(bytes);
|
||||
}
|
||||
|
||||
@Override
|
||||
public String toString() {
|
||||
return HexFormat.of().formatHex(bytes);
|
||||
}
|
||||
}
|
||||
+24
@@ -0,0 +1,24 @@
|
||||
package dev.caskeleton.application.messaging.event;
|
||||
|
||||
/** Canonical non-null tenant and aggregate ordering identity. */
|
||||
public record AggregateIdentity(String tenantScope, String aggregateType, String aggregateId) {
|
||||
|
||||
public AggregateIdentity {
|
||||
if (tenantScope == null
|
||||
|| tenantScope.length() > 96
|
||||
|| !tenantScope.matches("[a-z0-9]+(?:[._:-][a-z0-9]+)*")) {
|
||||
throw new IllegalArgumentException(
|
||||
"tenantScope must be a canonical non-null lower-case identifier");
|
||||
}
|
||||
if (aggregateType == null
|
||||
|| aggregateType.length() > 64
|
||||
|| !aggregateType.matches("[a-z][a-z0-9]*(?:[.-][a-z0-9]+)*")) {
|
||||
throw new IllegalArgumentException("aggregateType must be a canonical lower-case identifier");
|
||||
}
|
||||
if (aggregateId == null
|
||||
|| aggregateId.length() > 160
|
||||
|| !aggregateId.matches("[A-Za-z0-9][A-Za-z0-9._:-]*")) {
|
||||
throw new IllegalArgumentException("aggregateId must be a canonical bounded identifier");
|
||||
}
|
||||
}
|
||||
}
|
||||
+14
@@ -0,0 +1,14 @@
|
||||
package dev.caskeleton.application.messaging.event;
|
||||
|
||||
/** Total order within one aggregate identity. */
|
||||
public record AggregateOrder(long sequence, int eventIndex) {
|
||||
|
||||
public AggregateOrder {
|
||||
if (sequence <= 0) {
|
||||
throw new IllegalArgumentException("aggregate sequence must be positive");
|
||||
}
|
||||
if (eventIndex < 0) {
|
||||
throw new IllegalArgumentException("eventIndex must be non-negative");
|
||||
}
|
||||
}
|
||||
}
|
||||
+14
@@ -0,0 +1,14 @@
|
||||
package dev.caskeleton.application.messaging.event;
|
||||
|
||||
/** Canonical integration-event identity. */
|
||||
public record EventId(String value) {
|
||||
|
||||
private static final String GRAMMAR = "[A-Za-z0-9][A-Za-z0-9._:-]*";
|
||||
|
||||
public EventId {
|
||||
if (value == null || value.length() > 96 || !value.matches(GRAMMAR)) {
|
||||
throw new IllegalArgumentException(
|
||||
"eventId must be 1-96 US-ASCII characters matching " + "[A-Za-z0-9][A-Za-z0-9._:-]*");
|
||||
}
|
||||
}
|
||||
}
|
||||
+53
@@ -0,0 +1,53 @@
|
||||
package dev.caskeleton.application.messaging.event;
|
||||
|
||||
import dev.caskeleton.application.messaging.contract.ContractId;
|
||||
import dev.caskeleton.application.messaging.contract.IntegrationPayload;
|
||||
import dev.caskeleton.application.messaging.contract.LogicalDestinationId;
|
||||
import java.time.Instant;
|
||||
import java.util.Optional;
|
||||
|
||||
/** Feature-owned semantic event before local encoding and schema validation. */
|
||||
public record IntegrationEventDraft<P extends IntegrationPayload>(
|
||||
EventId eventId,
|
||||
ContractId contractId,
|
||||
int payloadVersion,
|
||||
LogicalDestinationId destinationId,
|
||||
AggregateIdentity aggregate,
|
||||
AggregateOrder order,
|
||||
Instant occurredAt,
|
||||
String correlationId,
|
||||
Optional<String> causationId,
|
||||
P featurePayload) {
|
||||
|
||||
private static final String CORRELATION_GRAMMAR = "[A-Za-z0-9][A-Za-z0-9._:-]*";
|
||||
|
||||
public IntegrationEventDraft {
|
||||
if (eventId == null
|
||||
|| contractId == null
|
||||
|| destinationId == null
|
||||
|| aggregate == null
|
||||
|| order == null) {
|
||||
throw new IllegalArgumentException("event identities and order must not be null");
|
||||
}
|
||||
if (payloadVersion <= 0) {
|
||||
throw new IllegalArgumentException("payloadVersion must be positive");
|
||||
}
|
||||
if (occurredAt == null) {
|
||||
throw new IllegalArgumentException("occurredAt must not be null");
|
||||
}
|
||||
requireCanonicalCorrelationIdentity("correlationId", correlationId);
|
||||
if (causationId == null) {
|
||||
throw new IllegalArgumentException("causationId Optional must not be null");
|
||||
}
|
||||
causationId.ifPresent(value -> requireCanonicalCorrelationIdentity("causationId", value));
|
||||
if (featurePayload == null) {
|
||||
throw new IllegalArgumentException("featurePayload must not be null");
|
||||
}
|
||||
}
|
||||
|
||||
private static void requireCanonicalCorrelationIdentity(String field, String value) {
|
||||
if (value == null || value.length() > 96 || !value.matches(CORRELATION_GRAMMAR)) {
|
||||
throw new IllegalArgumentException(field + " must be a canonical bounded US-ASCII identity");
|
||||
}
|
||||
}
|
||||
}
|
||||
+7
@@ -0,0 +1,7 @@
|
||||
package dev.caskeleton.application.messaging.event;
|
||||
|
||||
/** Local, framework-free boundary for deterministic encoding and contract validation. */
|
||||
public interface IntegrationEventEncoderPort {
|
||||
|
||||
ValidatedIntegrationEvent encode(IntegrationEventDraft<?> draft);
|
||||
}
|
||||
+162
@@ -0,0 +1,162 @@
|
||||
package dev.caskeleton.application.messaging.event;
|
||||
|
||||
import dev.caskeleton.application.messaging.contract.ContractId;
|
||||
import dev.caskeleton.application.messaging.contract.LogicalDestinationId;
|
||||
import dev.caskeleton.application.messaging.contract.Sha256;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.time.Instant;
|
||||
import java.util.Arrays;
|
||||
import java.util.Objects;
|
||||
import java.util.Optional;
|
||||
|
||||
/** Immutable, locally encoded integration event ready for durable append. */
|
||||
@SuppressWarnings("ArrayRecordComponent")
|
||||
public record ValidatedIntegrationEvent(
|
||||
EventId eventId,
|
||||
ContractId contractId,
|
||||
int envelopeVersion,
|
||||
int payloadVersion,
|
||||
LogicalDestinationId logicalDestinationId,
|
||||
AggregateIdentity aggregate,
|
||||
AggregateOrder order,
|
||||
Instant occurredAt,
|
||||
String correlationId,
|
||||
Optional<String> causationId,
|
||||
String partitionKeyText,
|
||||
byte[] partitionKeyBytes,
|
||||
byte[] envelopeBytes,
|
||||
String contentType,
|
||||
Sha256 schemaSetHash,
|
||||
Sha256 envelopeSha256,
|
||||
Sha256 envelopeSchemaHash,
|
||||
Sha256 payloadSchemaHash,
|
||||
String contractCatalogRevision,
|
||||
String destinationBindingRevision) {
|
||||
|
||||
private static final String CORRELATION_GRAMMAR = "[A-Za-z0-9][A-Za-z0-9._:-]*";
|
||||
private static final String REVISION_GRAMMAR = "[a-z0-9][a-z0-9._:-]{0,95}";
|
||||
private static final String CONTENT_TYPE_GRAMMAR = "[a-z0-9!#$&^_.+-]+/[a-z0-9!#$&^_.+-]+";
|
||||
|
||||
public ValidatedIntegrationEvent {
|
||||
if (eventId == null
|
||||
|| contractId == null
|
||||
|| logicalDestinationId == null
|
||||
|| aggregate == null
|
||||
|| order == null) {
|
||||
throw new IllegalArgumentException("stable event identities and order must not be null");
|
||||
}
|
||||
if (envelopeVersion <= 0 || payloadVersion <= 0) {
|
||||
throw new IllegalArgumentException("envelopeVersion and payloadVersion must be positive");
|
||||
}
|
||||
if (occurredAt == null) {
|
||||
throw new IllegalArgumentException("occurredAt must not be null");
|
||||
}
|
||||
requireCanonicalIdentity("correlationId", correlationId, CORRELATION_GRAMMAR);
|
||||
if (causationId == null) {
|
||||
throw new IllegalArgumentException("causationId Optional must not be null");
|
||||
}
|
||||
causationId.ifPresent(
|
||||
value -> requireCanonicalIdentity("causationId", value, CORRELATION_GRAMMAR));
|
||||
if (partitionKeyText == null || !partitionKeyText.matches("[0-9a-f]{64}")) {
|
||||
throw new IllegalArgumentException(
|
||||
"partitionKeyText must contain exactly 64 lower-case hexadecimal characters");
|
||||
}
|
||||
if (partitionKeyBytes == null
|
||||
|| !Arrays.equals(
|
||||
partitionKeyBytes, partitionKeyText.getBytes(StandardCharsets.US_ASCII))) {
|
||||
throw new IllegalArgumentException(
|
||||
"partitionKeyBytes must exactly equal the US-ASCII partitionKeyText bytes");
|
||||
}
|
||||
if (envelopeBytes == null || envelopeBytes.length == 0) {
|
||||
throw new IllegalArgumentException("envelopeBytes must not be null or empty");
|
||||
}
|
||||
if (contentType == null
|
||||
|| contentType.length() > 96
|
||||
|| !contentType.matches(CONTENT_TYPE_GRAMMAR)) {
|
||||
throw new IllegalArgumentException("contentType must be a canonical bounded media type");
|
||||
}
|
||||
if (schemaSetHash == null
|
||||
|| envelopeSha256 == null
|
||||
|| envelopeSchemaHash == null
|
||||
|| payloadSchemaHash == null) {
|
||||
throw new IllegalArgumentException("validated schema and envelope hashes must not be null");
|
||||
}
|
||||
requireCanonicalIdentity("contractCatalogRevision", contractCatalogRevision, REVISION_GRAMMAR);
|
||||
requireCanonicalIdentity(
|
||||
"destinationBindingRevision", destinationBindingRevision, REVISION_GRAMMAR);
|
||||
partitionKeyBytes = partitionKeyBytes.clone();
|
||||
envelopeBytes = envelopeBytes.clone();
|
||||
}
|
||||
|
||||
@Override
|
||||
public byte[] partitionKeyBytes() {
|
||||
return partitionKeyBytes.clone();
|
||||
}
|
||||
|
||||
@Override
|
||||
public byte[] envelopeBytes() {
|
||||
return envelopeBytes.clone();
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean equals(Object other) {
|
||||
if (this == other) {
|
||||
return true;
|
||||
}
|
||||
if (!(other instanceof ValidatedIntegrationEvent that)) {
|
||||
return false;
|
||||
}
|
||||
return envelopeVersion == that.envelopeVersion
|
||||
&& payloadVersion == that.payloadVersion
|
||||
&& eventId.equals(that.eventId)
|
||||
&& contractId.equals(that.contractId)
|
||||
&& logicalDestinationId.equals(that.logicalDestinationId)
|
||||
&& aggregate.equals(that.aggregate)
|
||||
&& order.equals(that.order)
|
||||
&& occurredAt.equals(that.occurredAt)
|
||||
&& correlationId.equals(that.correlationId)
|
||||
&& causationId.equals(that.causationId)
|
||||
&& partitionKeyText.equals(that.partitionKeyText)
|
||||
&& Arrays.equals(partitionKeyBytes, that.partitionKeyBytes)
|
||||
&& Arrays.equals(envelopeBytes, that.envelopeBytes)
|
||||
&& contentType.equals(that.contentType)
|
||||
&& schemaSetHash.equals(that.schemaSetHash)
|
||||
&& envelopeSha256.equals(that.envelopeSha256)
|
||||
&& envelopeSchemaHash.equals(that.envelopeSchemaHash)
|
||||
&& payloadSchemaHash.equals(that.payloadSchemaHash)
|
||||
&& contractCatalogRevision.equals(that.contractCatalogRevision)
|
||||
&& destinationBindingRevision.equals(that.destinationBindingRevision);
|
||||
}
|
||||
|
||||
@Override
|
||||
public int hashCode() {
|
||||
int result =
|
||||
Objects.hash(
|
||||
eventId,
|
||||
contractId,
|
||||
envelopeVersion,
|
||||
payloadVersion,
|
||||
logicalDestinationId,
|
||||
aggregate,
|
||||
order,
|
||||
occurredAt,
|
||||
correlationId,
|
||||
causationId,
|
||||
partitionKeyText,
|
||||
contentType,
|
||||
schemaSetHash,
|
||||
envelopeSha256,
|
||||
envelopeSchemaHash,
|
||||
payloadSchemaHash,
|
||||
contractCatalogRevision,
|
||||
destinationBindingRevision);
|
||||
result = 31 * result + Arrays.hashCode(partitionKeyBytes);
|
||||
return 31 * result + Arrays.hashCode(envelopeBytes);
|
||||
}
|
||||
|
||||
private static void requireCanonicalIdentity(String field, String value, String grammar) {
|
||||
if (value == null || !value.matches(grammar)) {
|
||||
throw new IllegalArgumentException(field + " must be a canonical bounded US-ASCII identity");
|
||||
}
|
||||
}
|
||||
}
|
||||
+217
@@ -0,0 +1,217 @@
|
||||
package dev.caskeleton.application.messaging.contract;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
|
||||
import java.lang.reflect.Modifier;
|
||||
import java.time.Duration;
|
||||
import java.util.Arrays;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.stream.Collectors;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
class IntegrationEventContractContributionTest {
|
||||
|
||||
@Test
|
||||
void contributionUsesAnExactFinalRecordAndCanonicalComponentOrder() {
|
||||
TestContribution contribution = new TestContribution();
|
||||
|
||||
assertThat(contribution.exactPayloadRecordType()).isEqualTo(TestPayload.class);
|
||||
assertThat(contribution.exactPayloadRecordType().isRecord()).isTrue();
|
||||
assertThat(Modifier.isFinal(contribution.exactPayloadRecordType().getModifiers())).isTrue();
|
||||
assertThat(contribution.canonicalRecordComponentOrder())
|
||||
.containsExactlyElementsOf(
|
||||
Arrays.stream(TestPayload.class.getRecordComponents())
|
||||
.map(component -> component.getName())
|
||||
.toList());
|
||||
assertThat(contribution.contractId()).isEqualTo(new ContractId("test.event.created"));
|
||||
assertThat(contribution.payloadVersion()).isEqualTo(1);
|
||||
assertThat(contribution.payloadSchemaResource())
|
||||
.isEqualTo(new SchemaResourceId("contracts/messaging/test.event.created/v1.schema.json"));
|
||||
assertThat(contribution.payloadSchemaHash()).isEqualTo(new Sha256(new byte[32]));
|
||||
}
|
||||
|
||||
@Test
|
||||
void contributionSpiIsClosedAndDoesNotExposeDynamicPayloadOrRoutingApis() {
|
||||
Set<String> methods =
|
||||
Arrays.stream(IntegrationEventContractContribution.class.getDeclaredMethods())
|
||||
.map(method -> method.getName())
|
||||
.collect(Collectors.toSet());
|
||||
|
||||
assertThat(methods)
|
||||
.containsExactlyInAnyOrder(
|
||||
"contractId",
|
||||
"payloadVersion",
|
||||
"exactPayloadRecordType",
|
||||
"canonicalRecordComponentOrder",
|
||||
"payloadSchemaResource",
|
||||
"payloadSchemaHash",
|
||||
"descriptor");
|
||||
assertThat(
|
||||
Arrays.stream(IntegrationEventContractContribution.class.getDeclaredMethods())
|
||||
.filter(method -> method.isDefault()))
|
||||
.isEmpty();
|
||||
assertThat(
|
||||
Arrays.stream(IntegrationEventContractContribution.class.getDeclaredMethods())
|
||||
.flatMap(
|
||||
method ->
|
||||
java.util.stream.Stream.concat(
|
||||
java.util.stream.Stream.of(method.getReturnType()),
|
||||
Arrays.stream(method.getParameterTypes()))))
|
||||
.doesNotContain(Map.class, String.class);
|
||||
assertThat(methods)
|
||||
.noneMatch(
|
||||
name ->
|
||||
name.contains("assignable")
|
||||
|| name.contains("className")
|
||||
|| name.contains("json")
|
||||
|| name.contains("tree"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void contractAndDestinationIdentifiersAreStableSemanticIdsWithSeparateVersions() {
|
||||
assertThat(new ContractId("portfolio.worklog.reserved").value())
|
||||
.isEqualTo("portfolio.worklog.reserved");
|
||||
assertThat(new LogicalDestinationId("portfolio-domain-events").value())
|
||||
.isEqualTo("portfolio-domain-events");
|
||||
|
||||
assertThatThrownBy(() -> new ContractId("portfolio.worklog.reserved.v1"))
|
||||
.isInstanceOf(IllegalArgumentException.class);
|
||||
assertThatThrownBy(() -> new ContractId("dev.caskeleton.WorkLogReservedPayload"))
|
||||
.isInstanceOf(IllegalArgumentException.class);
|
||||
assertThatThrownBy(() -> new ContractId("portfolio-.worklog.reserved"))
|
||||
.isInstanceOf(IllegalArgumentException.class);
|
||||
assertThatThrownBy(() -> new ContractId("kafka://portfolio.domain-events.v1"))
|
||||
.isInstanceOf(IllegalArgumentException.class);
|
||||
assertThatThrownBy(() -> new LogicalDestinationId("portfolio.domain-events.v1"))
|
||||
.isInstanceOf(IllegalArgumentException.class);
|
||||
assertThatThrownBy(() -> new LogicalDestinationId("WorkLogReservedPayload"))
|
||||
.isInstanceOf(IllegalArgumentException.class);
|
||||
assertThatThrownBy(() -> new LogicalDestinationId("portfolio-domain-events-v1"))
|
||||
.isInstanceOf(IllegalArgumentException.class);
|
||||
}
|
||||
|
||||
@Test
|
||||
void schemaResourceIsAClosedLocalContractResource() {
|
||||
assertThat(
|
||||
new SchemaResourceId("contracts/messaging/test.event.created/v1.schema.json").value())
|
||||
.isEqualTo("contracts/messaging/test.event.created/v1.schema.json");
|
||||
|
||||
assertThatThrownBy(() -> new SchemaResourceId("https://schemas.example/test.schema.json"))
|
||||
.isInstanceOf(IllegalArgumentException.class);
|
||||
assertThatThrownBy(() -> new SchemaResourceId("../test.schema.json"))
|
||||
.isInstanceOf(IllegalArgumentException.class);
|
||||
assertThatThrownBy(() -> new SchemaResourceId("contracts/messaging/test..event/v1.schema.json"))
|
||||
.isInstanceOf(IllegalArgumentException.class);
|
||||
assertThatThrownBy(() -> new SchemaResourceId("dev.caskeleton.TestPayload"))
|
||||
.isInstanceOf(IllegalArgumentException.class);
|
||||
}
|
||||
|
||||
@Test
|
||||
void sha256HasFixedLengthContentEqualityAndDefensiveCopies() {
|
||||
byte[] bytes = new byte[32];
|
||||
bytes[0] = 42;
|
||||
Sha256 hash = new Sha256(bytes);
|
||||
Sha256 equalHash = new Sha256(bytes.clone());
|
||||
|
||||
bytes[0] = 0;
|
||||
byte[] exposed = hash.bytes();
|
||||
exposed[0] = 0;
|
||||
|
||||
assertThat(hash).isEqualTo(equalHash);
|
||||
assertThat(hash.hashCode()).isEqualTo(equalHash.hashCode());
|
||||
assertThat(hash.bytes()[0]).isEqualTo((byte) 42);
|
||||
assertThatThrownBy(() -> new Sha256(new byte[31])).isInstanceOf(IllegalArgumentException.class);
|
||||
assertThatThrownBy(() -> new Sha256(null)).isInstanceOf(IllegalArgumentException.class);
|
||||
}
|
||||
|
||||
@Test
|
||||
void descriptorContainsOnlyBoundedProviderNeutralContractMetadata() {
|
||||
ContractDescriptor descriptor = descriptor();
|
||||
|
||||
assertThat(descriptor.ownerModule()).isEqualTo("sample-portfolio");
|
||||
assertThat(descriptor.logicalDestination())
|
||||
.isEqualTo(new LogicalDestinationId("portfolio-domain-events"));
|
||||
assertThat(descriptor.maximumPayloadBytes()).isEqualTo(64 * 1024);
|
||||
assertThat(descriptor.maximumEnvelopeBytes()).isEqualTo(128 * 1024);
|
||||
assertThat(descriptor.sameEventRequeueHorizon()).isEqualTo(Duration.ofDays(7));
|
||||
assertThat(
|
||||
Arrays.stream(ContractDescriptor.class.getRecordComponents())
|
||||
.map(component -> component.getName()))
|
||||
.noneMatch(
|
||||
name ->
|
||||
name.toLowerCase(Locale.ROOT).contains("topic")
|
||||
|| name.toLowerCase(Locale.ROOT).contains("kafka")
|
||||
|| name.toLowerCase(Locale.ROOT).contains("bootstrap")
|
||||
|| name.toLowerCase(Locale.ROOT).contains("security"));
|
||||
|
||||
assertThatThrownBy(
|
||||
() ->
|
||||
new ContractDescriptor(
|
||||
"sample-portfolio",
|
||||
new LogicalDestinationId("portfolio-domain-events"),
|
||||
"json-schema-envelope-v1",
|
||||
true,
|
||||
0,
|
||||
128 * 1024,
|
||||
ContractDescriptor.SensitivityClassification.INTERNAL,
|
||||
Duration.ofDays(7)))
|
||||
.isInstanceOf(IllegalArgumentException.class);
|
||||
}
|
||||
|
||||
private static ContractDescriptor descriptor() {
|
||||
return new ContractDescriptor(
|
||||
"sample-portfolio",
|
||||
new LogicalDestinationId("portfolio-domain-events"),
|
||||
"json-schema-envelope-v1",
|
||||
true,
|
||||
64 * 1024,
|
||||
128 * 1024,
|
||||
ContractDescriptor.SensitivityClassification.INTERNAL,
|
||||
Duration.ofDays(7));
|
||||
}
|
||||
|
||||
private record TestPayload(String workLogId, long revision) implements IntegrationPayload {}
|
||||
|
||||
private static final class TestContribution
|
||||
implements IntegrationEventContractContribution<TestPayload> {
|
||||
|
||||
@Override
|
||||
public ContractId contractId() {
|
||||
return new ContractId("test.event.created");
|
||||
}
|
||||
|
||||
@Override
|
||||
public int payloadVersion() {
|
||||
return 1;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Class<TestPayload> exactPayloadRecordType() {
|
||||
return TestPayload.class;
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<String> canonicalRecordComponentOrder() {
|
||||
return List.of("workLogId", "revision");
|
||||
}
|
||||
|
||||
@Override
|
||||
public SchemaResourceId payloadSchemaResource() {
|
||||
return new SchemaResourceId("contracts/messaging/test.event.created/v1.schema.json");
|
||||
}
|
||||
|
||||
@Override
|
||||
public Sha256 payloadSchemaHash() {
|
||||
return new Sha256(new byte[32]);
|
||||
}
|
||||
|
||||
@Override
|
||||
public ContractDescriptor descriptor() {
|
||||
return IntegrationEventContractContributionTest.descriptor();
|
||||
}
|
||||
}
|
||||
}
|
||||
+119
@@ -0,0 +1,119 @@
|
||||
package dev.caskeleton.application.messaging.event;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
|
||||
import dev.caskeleton.application.messaging.contract.ContractId;
|
||||
import dev.caskeleton.application.messaging.contract.IntegrationPayload;
|
||||
import dev.caskeleton.application.messaging.contract.LogicalDestinationId;
|
||||
import java.time.Instant;
|
||||
import java.util.Optional;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
class IntegrationEventDraftTest {
|
||||
|
||||
@Test
|
||||
void eventIdAcceptsOnlyBoundedCanonicalUsAscii() {
|
||||
assertThat(new EventId("A0:event.id-1").value()).isEqualTo("A0:event.id-1");
|
||||
|
||||
assertThatThrownBy(() -> new EventId("")).isInstanceOf(IllegalArgumentException.class);
|
||||
assertThatThrownBy(() -> new EventId("-event")).isInstanceOf(IllegalArgumentException.class);
|
||||
assertThatThrownBy(() -> new EventId("event 한글")).isInstanceOf(IllegalArgumentException.class);
|
||||
assertThatThrownBy(() -> new EventId("e".repeat(97)))
|
||||
.isInstanceOf(IllegalArgumentException.class);
|
||||
}
|
||||
|
||||
@Test
|
||||
void aggregateIdentityRequiresCanonicalNonNullTenantAndAggregateValues() {
|
||||
AggregateIdentity identity = new AggregateIdentity("tenant-a", "worklog", "worklog-42");
|
||||
|
||||
assertThat(identity.tenantScope()).isEqualTo("tenant-a");
|
||||
assertThatThrownBy(() -> new AggregateIdentity(null, "worklog", "worklog-42"))
|
||||
.isInstanceOf(IllegalArgumentException.class);
|
||||
assertThatThrownBy(() -> new AggregateIdentity(" ", "worklog", "worklog-42"))
|
||||
.isInstanceOf(IllegalArgumentException.class);
|
||||
assertThatThrownBy(() -> new AggregateIdentity("Tenant A", "worklog", "worklog-42"))
|
||||
.isInstanceOf(IllegalArgumentException.class);
|
||||
assertThatThrownBy(() -> new AggregateIdentity("tenant-", "worklog", "worklog-42"))
|
||||
.isInstanceOf(IllegalArgumentException.class);
|
||||
assertThatThrownBy(() -> new AggregateIdentity("tenant-a", "WorkLog", "worklog-42"))
|
||||
.isInstanceOf(IllegalArgumentException.class);
|
||||
assertThatThrownBy(() -> new AggregateIdentity("tenant-a", "worklog", " worklog-42"))
|
||||
.isInstanceOf(IllegalArgumentException.class);
|
||||
}
|
||||
|
||||
@Test
|
||||
void aggregateOrderRequiresPositiveSequenceAndNonNegativeEventIndex() {
|
||||
assertThat(new AggregateOrder(1, 0)).isEqualTo(new AggregateOrder(1, 0));
|
||||
assertThatThrownBy(() -> new AggregateOrder(0, 0)).isInstanceOf(IllegalArgumentException.class);
|
||||
assertThatThrownBy(() -> new AggregateOrder(1, -1))
|
||||
.isInstanceOf(IllegalArgumentException.class);
|
||||
}
|
||||
|
||||
@Test
|
||||
void draftRequiresPositiveVersionTimeCanonicalCorrelationAndTypedPayload() {
|
||||
TestPayload payload = new TestPayload("worklog-42");
|
||||
IntegrationEventDraft<TestPayload> draft = draft(payload, Optional.of("cause-1"));
|
||||
|
||||
assertThat(draft.featurePayload()).isSameAs(payload);
|
||||
assertThat(draft.occurredAt()).isEqualTo(Instant.parse("2026-07-28T05:10:30.123Z"));
|
||||
assertThatThrownBy(
|
||||
() ->
|
||||
new IntegrationEventDraft<>(
|
||||
new EventId("event-1"),
|
||||
new ContractId("portfolio.worklog.reserved"),
|
||||
0,
|
||||
new LogicalDestinationId("portfolio-domain-events"),
|
||||
new AggregateIdentity("tenant-a", "worklog", "worklog-42"),
|
||||
new AggregateOrder(17, 0),
|
||||
Instant.parse("2026-07-28T05:10:30.123Z"),
|
||||
"corr-1",
|
||||
Optional.empty(),
|
||||
payload))
|
||||
.isInstanceOf(IllegalArgumentException.class);
|
||||
assertThatThrownBy(
|
||||
() ->
|
||||
new IntegrationEventDraft<>(
|
||||
new EventId("event-1"),
|
||||
new ContractId("portfolio.worklog.reserved"),
|
||||
1,
|
||||
new LogicalDestinationId("portfolio-domain-events"),
|
||||
new AggregateIdentity("tenant-a", "worklog", "worklog-42"),
|
||||
new AggregateOrder(17, 0),
|
||||
null,
|
||||
"corr-1",
|
||||
Optional.empty(),
|
||||
payload))
|
||||
.isInstanceOf(IllegalArgumentException.class);
|
||||
assertThatThrownBy(() -> draft(payload, null)).isInstanceOf(IllegalArgumentException.class);
|
||||
assertThatThrownBy(() -> draft(null, Optional.empty()))
|
||||
.isInstanceOf(IllegalArgumentException.class);
|
||||
}
|
||||
|
||||
@Test
|
||||
void encoderPortOwnsOnlyTheProviderNeutralDraftToValidatedBoundary() throws Exception {
|
||||
assertThat(
|
||||
IntegrationEventEncoderPort.class
|
||||
.getDeclaredMethod("encode", IntegrationEventDraft.class)
|
||||
.getReturnType())
|
||||
.isEqualTo(ValidatedIntegrationEvent.class);
|
||||
assertThat(IntegrationEventEncoderPort.class.getDeclaredMethods()).hasSize(1);
|
||||
}
|
||||
|
||||
private static IntegrationEventDraft<TestPayload> draft(
|
||||
TestPayload payload, Optional<String> causationId) {
|
||||
return new IntegrationEventDraft<>(
|
||||
new EventId("event-1"),
|
||||
new ContractId("portfolio.worklog.reserved"),
|
||||
1,
|
||||
new LogicalDestinationId("portfolio-domain-events"),
|
||||
new AggregateIdentity("tenant-a", "worklog", "worklog-42"),
|
||||
new AggregateOrder(17, 0),
|
||||
Instant.parse("2026-07-28T05:10:30.123Z"),
|
||||
"corr-1",
|
||||
causationId,
|
||||
payload);
|
||||
}
|
||||
|
||||
private record TestPayload(String workLogId) implements IntegrationPayload {}
|
||||
}
|
||||
+158
@@ -0,0 +1,158 @@
|
||||
package dev.caskeleton.application.messaging.event;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
|
||||
import dev.caskeleton.application.messaging.contract.ContractId;
|
||||
import dev.caskeleton.application.messaging.contract.LogicalDestinationId;
|
||||
import dev.caskeleton.application.messaging.contract.Sha256;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.time.Instant;
|
||||
import java.util.Arrays;
|
||||
import java.util.Locale;
|
||||
import java.util.Optional;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
class ValidatedIntegrationEventTest {
|
||||
|
||||
@Test
|
||||
void validatedEventOwnsExactEncodedBytesThroughDefensiveCopies() {
|
||||
byte[] keyBytes = "a".repeat(64).getBytes(StandardCharsets.US_ASCII);
|
||||
byte[] envelopeBytes = "{\"envelopeVersion\":1}".getBytes(StandardCharsets.UTF_8);
|
||||
ValidatedIntegrationEvent event = event(keyBytes, envelopeBytes);
|
||||
|
||||
keyBytes[0] = 'b';
|
||||
envelopeBytes[0] = 'x';
|
||||
byte[] exposedKey = event.partitionKeyBytes();
|
||||
byte[] exposedEnvelope = event.envelopeBytes();
|
||||
exposedKey[0] = 'c';
|
||||
exposedEnvelope[0] = 'y';
|
||||
|
||||
assertThat(event.partitionKeyBytes())
|
||||
.containsExactly("a".repeat(64).getBytes(StandardCharsets.US_ASCII));
|
||||
assertThat(event.envelopeBytes())
|
||||
.containsExactly("{\"envelopeVersion\":1}".getBytes(StandardCharsets.UTF_8));
|
||||
}
|
||||
|
||||
@Test
|
||||
void validatedEventValueEqualityUsesEncodedByteContents() {
|
||||
ValidatedIntegrationEvent first =
|
||||
event(
|
||||
"a".repeat(64).getBytes(StandardCharsets.US_ASCII),
|
||||
"{}".getBytes(StandardCharsets.UTF_8));
|
||||
ValidatedIntegrationEvent equal =
|
||||
event(
|
||||
"a".repeat(64).getBytes(StandardCharsets.US_ASCII),
|
||||
"{}".getBytes(StandardCharsets.UTF_8));
|
||||
|
||||
assertThat(first).isEqualTo(equal);
|
||||
assertThat(first.hashCode()).isEqualTo(equal.hashCode());
|
||||
}
|
||||
|
||||
@Test
|
||||
void validatedEventRequiresPositiveVersionsAndMatchingCanonicalAsciiPartitionKey() {
|
||||
byte[] envelope = "{}".getBytes(StandardCharsets.UTF_8);
|
||||
|
||||
assertThatThrownBy(
|
||||
() ->
|
||||
event(
|
||||
"b".repeat(64).getBytes(StandardCharsets.US_ASCII),
|
||||
envelope,
|
||||
"a".repeat(64),
|
||||
0,
|
||||
1))
|
||||
.isInstanceOf(IllegalArgumentException.class);
|
||||
assertThatThrownBy(
|
||||
() ->
|
||||
event(
|
||||
"b".repeat(64).getBytes(StandardCharsets.US_ASCII),
|
||||
envelope,
|
||||
"a".repeat(64),
|
||||
1,
|
||||
0))
|
||||
.isInstanceOf(IllegalArgumentException.class);
|
||||
assertThatThrownBy(
|
||||
() ->
|
||||
event(
|
||||
"b".repeat(64).getBytes(StandardCharsets.US_ASCII),
|
||||
envelope,
|
||||
"a".repeat(64),
|
||||
1,
|
||||
1))
|
||||
.isInstanceOf(IllegalArgumentException.class);
|
||||
assertThatThrownBy(
|
||||
() ->
|
||||
event(
|
||||
"é".repeat(64).getBytes(StandardCharsets.UTF_8),
|
||||
envelope,
|
||||
"é".repeat(64),
|
||||
1,
|
||||
1))
|
||||
.isInstanceOf(IllegalArgumentException.class);
|
||||
}
|
||||
|
||||
@Test
|
||||
void validatedEventCarriesStableSemanticIdentityAndProviderNeutralEvidenceOnly() {
|
||||
ValidatedIntegrationEvent event =
|
||||
event(
|
||||
"a".repeat(64).getBytes(StandardCharsets.US_ASCII),
|
||||
"{}".getBytes(StandardCharsets.UTF_8));
|
||||
|
||||
assertThat(event.eventId()).isEqualTo(new EventId("event-1"));
|
||||
assertThat(event.contractId()).isEqualTo(new ContractId("portfolio.worklog.reserved"));
|
||||
assertThat(event.logicalDestinationId())
|
||||
.isEqualTo(new LogicalDestinationId("portfolio-domain-events"));
|
||||
assertThat(event.aggregate())
|
||||
.isEqualTo(new AggregateIdentity("tenant-a", "worklog", "worklog-42"));
|
||||
assertThat(event.order()).isEqualTo(new AggregateOrder(17, 0));
|
||||
assertThat(event.contentType()).isEqualTo("application/json");
|
||||
assertThat(event.envelopeSha256()).isEqualTo(new Sha256(new byte[32]));
|
||||
assertThat(event.contractCatalogRevision()).isEqualTo("catalog-r1");
|
||||
assertThat(event.destinationBindingRevision()).isEqualTo("binding-r1");
|
||||
|
||||
assertThat(
|
||||
Arrays.stream(ValidatedIntegrationEvent.class.getRecordComponents())
|
||||
.map(component -> component.getName()))
|
||||
.noneMatch(
|
||||
name ->
|
||||
name.toLowerCase(Locale.ROOT).contains("topic")
|
||||
|| name.toLowerCase(Locale.ROOT).contains("kafka")
|
||||
|| name.toLowerCase(Locale.ROOT).contains("metadata")
|
||||
|| name.toLowerCase(Locale.ROOT).contains("publicationepoch")
|
||||
|| name.toLowerCase(Locale.ROOT).contains("validator"));
|
||||
}
|
||||
|
||||
private static ValidatedIntegrationEvent event(byte[] keyBytes, byte[] envelopeBytes) {
|
||||
return event(keyBytes, envelopeBytes, "a".repeat(64), 1, 1);
|
||||
}
|
||||
|
||||
private static ValidatedIntegrationEvent event(
|
||||
byte[] keyBytes,
|
||||
byte[] envelopeBytes,
|
||||
String keyText,
|
||||
int envelopeVersion,
|
||||
int payloadVersion) {
|
||||
Sha256 hash = new Sha256(new byte[32]);
|
||||
return new ValidatedIntegrationEvent(
|
||||
new EventId("event-1"),
|
||||
new ContractId("portfolio.worklog.reserved"),
|
||||
envelopeVersion,
|
||||
payloadVersion,
|
||||
new LogicalDestinationId("portfolio-domain-events"),
|
||||
new AggregateIdentity("tenant-a", "worklog", "worklog-42"),
|
||||
new AggregateOrder(17, 0),
|
||||
Instant.parse("2026-07-28T05:10:30.123Z"),
|
||||
"corr-1",
|
||||
Optional.of("cause-1"),
|
||||
keyText,
|
||||
keyBytes,
|
||||
envelopeBytes,
|
||||
"application/json",
|
||||
hash,
|
||||
hash,
|
||||
hash,
|
||||
hash,
|
||||
"catalog-r1",
|
||||
"binding-r1");
|
||||
}
|
||||
}
|
||||
+160
-20
@@ -7,6 +7,7 @@ import dev.caskeleton.application.transaction.TransactionPort;
|
||||
import java.time.Clock;
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
import java.time.ZoneId;
|
||||
import java.time.ZoneOffset;
|
||||
import java.util.ArrayList;
|
||||
import java.util.LinkedHashMap;
|
||||
@@ -61,7 +62,7 @@ class PublishPendingOutboxEventsUseCaseTest {
|
||||
// ---- success path ----
|
||||
|
||||
@Test
|
||||
void successfulPublishTransitionsEventToPublished() {
|
||||
void legacyVoidPublisherNormalReturnTransitionsEventToPublishedCharacterization() {
|
||||
OutboxEvent event = makeEvent("evt-1", "UserCreated", "agg-1", NOW.minusSeconds(60), 1);
|
||||
store.addClaimable(event);
|
||||
|
||||
@@ -93,7 +94,7 @@ class PublishPendingOutboxEventsUseCaseTest {
|
||||
// ---- transient failure path ----
|
||||
|
||||
@Test
|
||||
void transientFailureTransitionsEventToFailedWithBackoff() {
|
||||
void senderExceptionTransitionsEventToFailedWithBackoffCharacterization() {
|
||||
OutboxEvent event = makeEvent("evt-fail", "UserCreated", "agg-1", NOW.minusSeconds(60), 1);
|
||||
store.addClaimable(event);
|
||||
publishPort.failOn("evt-fail", new RuntimeException("broker down"));
|
||||
@@ -122,7 +123,7 @@ class PublishPendingOutboxEventsUseCaseTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
void failureOnThirdAttemptTransitionsEventToDead() {
|
||||
void senderExceptionAtRetryLimitTransitionsEventToDeadCharacterization() {
|
||||
// attemptCount=3 means this is the 3rd attempt — next failure should DEAD
|
||||
OutboxEvent event = makeEvent("evt-dead", "UserCreated", "agg-1", NOW.minusSeconds(60), 3);
|
||||
store.addClaimable(event);
|
||||
@@ -202,8 +203,8 @@ class PublishPendingOutboxEventsUseCaseTest {
|
||||
// ---- markPublished failure — spec §엣지·실패·의존 semantics ----
|
||||
|
||||
/**
|
||||
* When {@code store.markPublished} throws after a SUCCESSFUL publish, the exception must
|
||||
* propagate out of {@code handle()} rather than being caught and misclassified as a publish
|
||||
* When {@code store.markPublished} throws after a normal void publisher return, the exception
|
||||
* must propagate out of {@code handle()} rather than being caught and misclassified as a publish
|
||||
* failure (which would trigger FAILED/DEAD state machine and potentially dead-letter a
|
||||
* successfully-delivered event).
|
||||
*
|
||||
@@ -213,19 +214,21 @@ class PublishPendingOutboxEventsUseCaseTest {
|
||||
* <li>The exception propagates — it is NOT swallowed inside {@code publishOne}.
|
||||
* <li>{@code markFailed} is NOT called for the event (no misclassification).
|
||||
* <li>{@code markDead} is NOT called for the event (no misclassification).
|
||||
* <li>{@code publishPort.publish} was called exactly once.
|
||||
* <li>The row remains IN_FLIGHT and is recovered via the orphan visibility-timeout reclaim path
|
||||
* on the next tick — re-published → duplicate absorbed by consumer dedupe (at-least-once).
|
||||
* <li>{@code publishPort.publish} is not called again before the in-flight timeout.
|
||||
* <li>The row remains IN_FLIGHT and can be recovered via the orphan visibility-timeout reclaim
|
||||
* path on a later tick. Because the prior void publisher return is not broker
|
||||
* acknowledgement evidence, a later publish can be a duplicate.
|
||||
* </ul>
|
||||
*/
|
||||
@Test
|
||||
void markPublishedFailurePropagatesAndDoesNotMisclassifyAsPublishFailure() {
|
||||
void legacyMarkPublishedFailureLeavesInFlightAndDuplicatePossibleCharacterization() {
|
||||
OutboxEvent event =
|
||||
makeEvent("evt-store-fail", "UserCreated", "agg-1", NOW.minusSeconds(60), 1);
|
||||
|
||||
ThrowingOnMarkPublishedStorePort throwingStore =
|
||||
new ThrowingOnMarkPublishedStorePort(new RuntimeException("DB down on markPublished"));
|
||||
throwingStore.addClaimable(event);
|
||||
MutableClock mutableClock = new MutableClock(NOW, ZoneOffset.UTC);
|
||||
TimeoutAwareThrowingOnMarkPublishedStorePort throwingStore =
|
||||
new TimeoutAwareThrowingOnMarkPublishedStorePort(
|
||||
event, NOW, new RuntimeException("DB down on markPublished"));
|
||||
|
||||
PublishPendingOutboxEventsUseCase useCaseWithThrowingStore =
|
||||
new PublishPendingOutboxEventsUseCase(
|
||||
@@ -234,7 +237,7 @@ class PublishPendingOutboxEventsUseCaseTest {
|
||||
reporter,
|
||||
tx,
|
||||
backoffPolicy,
|
||||
clock,
|
||||
mutableClock,
|
||||
BATCH_SIZE,
|
||||
IN_FLIGHT_TIMEOUT);
|
||||
|
||||
@@ -244,17 +247,36 @@ class PublishPendingOutboxEventsUseCaseTest {
|
||||
.isInstanceOf(RuntimeException.class)
|
||||
.hasMessage("DB down on markPublished");
|
||||
|
||||
// publish was called exactly once — the broker call succeeded.
|
||||
// A normal void return records only that the legacy publisher call completed.
|
||||
assertThat(publishPort.publishedEvents).containsExactly("evt-store-fail");
|
||||
assertThat(throwingStore.currentStatus()).isEqualTo(OutboxEventStatus.IN_FLIGHT);
|
||||
assertThat(throwingStore.attemptCount()).isEqualTo(1);
|
||||
assertThat(throwingStore.nextAttemptAt()).isEqualTo(NOW.plus(IN_FLIGHT_TIMEOUT));
|
||||
|
||||
// No misclassification: the event must NOT be marked FAILED or DEAD.
|
||||
assertThat(throwingStore.failedEvents)
|
||||
.as("markFailed must NOT be called when only markPublished fails")
|
||||
.doesNotContainKey("evt-store-fail");
|
||||
assertThat(throwingStore.deadEvents)
|
||||
.as("markDead must NOT be called when only markPublished fails")
|
||||
.doesNotContain("evt-store-fail");
|
||||
assertThat(throwingStore.markFailedCalls()).isZero();
|
||||
assertThat(throwingStore.markDeadCalls()).isZero();
|
||||
assertThat(reporter.reports).isEmpty();
|
||||
|
||||
mutableClock.advance(IN_FLIGHT_TIMEOUT.minusNanos(1));
|
||||
OutboxRelayResult beforeTimeout =
|
||||
useCaseWithThrowingStore.handle(PublishPendingOutboxEventsCommand.INSTANCE);
|
||||
assertThat(beforeTimeout.claimedCount()).isZero();
|
||||
assertThat(publishPort.publishedEvents).containsExactly("evt-store-fail");
|
||||
assertThat(throwingStore.attemptCount()).isEqualTo(1);
|
||||
|
||||
// Move strictly past the timeout. The orphan becomes eligible and the same event is sent again
|
||||
// because no PUBLISHED transition was persisted after the first normal void return.
|
||||
mutableClock.advance(Duration.ofNanos(2));
|
||||
assertThatThrownBy(
|
||||
() -> useCaseWithThrowingStore.handle(PublishPendingOutboxEventsCommand.INSTANCE))
|
||||
.isInstanceOf(RuntimeException.class)
|
||||
.hasMessage("DB down on markPublished");
|
||||
assertThat(throwingStore.currentStatus()).isEqualTo(OutboxEventStatus.IN_FLIGHT);
|
||||
assertThat(throwingStore.attemptCount()).isEqualTo(2);
|
||||
assertThat(publishPort.publishedEvents)
|
||||
.as("mark failure leaves a duplicate-possible retry window")
|
||||
.containsExactly("evt-store-fail", "evt-store-fail");
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -487,6 +509,95 @@ class PublishPendingOutboxEventsUseCaseTest {
|
||||
}
|
||||
}
|
||||
|
||||
static final class TimeoutAwareThrowingOnMarkPublishedStorePort implements OutboxStorePort {
|
||||
private final OutboxEvent event;
|
||||
private final RuntimeException markPublishedException;
|
||||
private OutboxEventStatus currentStatus = OutboxEventStatus.PENDING;
|
||||
private int attemptCount;
|
||||
private Instant nextAttemptAt;
|
||||
private int markFailedCalls;
|
||||
private int markDeadCalls;
|
||||
|
||||
TimeoutAwareThrowingOnMarkPublishedStorePort(
|
||||
OutboxEvent event, Instant firstEligibleAt, RuntimeException markPublishedException) {
|
||||
this.event = event;
|
||||
this.nextAttemptAt = firstEligibleAt;
|
||||
this.markPublishedException = markPublishedException;
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<OutboxEvent> claimBatch(int batchSize, Instant now, Duration inFlightTimeout) {
|
||||
if (batchSize == 0
|
||||
|| currentStatus == OutboxEventStatus.PUBLISHED
|
||||
|| currentStatus == OutboxEventStatus.DEAD
|
||||
|| nextAttemptAt.isAfter(now)) {
|
||||
return List.of();
|
||||
}
|
||||
currentStatus = OutboxEventStatus.IN_FLIGHT;
|
||||
attemptCount++;
|
||||
nextAttemptAt = now.plus(inFlightTimeout);
|
||||
return List.of(
|
||||
new OutboxEvent(
|
||||
event.eventId(),
|
||||
event.eventType(),
|
||||
event.aggregateId(),
|
||||
event.payload(),
|
||||
event.occurredAt(),
|
||||
event.correlationId(),
|
||||
event.idempotencyKey(),
|
||||
currentStatus,
|
||||
attemptCount));
|
||||
}
|
||||
|
||||
@Override
|
||||
public void markPublished(String eventId) {
|
||||
throw markPublishedException;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void markFailed(String eventId, Instant retryAt) {
|
||||
markFailedCalls++;
|
||||
currentStatus = OutboxEventStatus.FAILED;
|
||||
nextAttemptAt = retryAt;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void markDead(String eventId) {
|
||||
markDeadCalls++;
|
||||
currentStatus = OutboxEventStatus.DEAD;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Map<OutboxEventStatus, Long> countByStatus() {
|
||||
return Map.of(currentStatus, 1L);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Map<String, Long> oldestUnpublishedAgeSecondsByEventType(Instant now) {
|
||||
return Map.of();
|
||||
}
|
||||
|
||||
OutboxEventStatus currentStatus() {
|
||||
return currentStatus;
|
||||
}
|
||||
|
||||
int attemptCount() {
|
||||
return attemptCount;
|
||||
}
|
||||
|
||||
Instant nextAttemptAt() {
|
||||
return nextAttemptAt;
|
||||
}
|
||||
|
||||
int markFailedCalls() {
|
||||
return markFailedCalls;
|
||||
}
|
||||
|
||||
int markDeadCalls() {
|
||||
return markDeadCalls;
|
||||
}
|
||||
}
|
||||
|
||||
static final class ThrowingTransitionStorePort extends FakeOutboxStorePort {
|
||||
private final RuntimeException markFailedException;
|
||||
private final RuntimeException markDeadException;
|
||||
@@ -566,6 +677,35 @@ class PublishPendingOutboxEventsUseCaseTest {
|
||||
}
|
||||
}
|
||||
|
||||
static final class MutableClock extends Clock {
|
||||
private Instant current;
|
||||
private final ZoneId zone;
|
||||
|
||||
MutableClock(Instant current, ZoneId zone) {
|
||||
this.current = current;
|
||||
this.zone = zone;
|
||||
}
|
||||
|
||||
void advance(Duration duration) {
|
||||
current = current.plus(duration);
|
||||
}
|
||||
|
||||
@Override
|
||||
public ZoneId getZone() {
|
||||
return zone;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Clock withZone(ZoneId requestedZone) {
|
||||
return new MutableClock(current, requestedZone);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Instant instant() {
|
||||
return current;
|
||||
}
|
||||
}
|
||||
|
||||
/** Deterministic RandomGenerator that always returns 0 — produces zero jitter. */
|
||||
static final class ZeroRandom implements RandomGenerator {
|
||||
@Override
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import groovy.json.JsonSlurper
|
||||
import groovy.json.JsonOutput
|
||||
import groovy.xml.XmlSlurper
|
||||
import java.time.Duration
|
||||
import java.time.Instant
|
||||
import org.gradle.api.artifacts.dsl.LockMode
|
||||
import org.gradle.api.artifacts.component.ModuleComponentIdentifier
|
||||
import org.gradle.api.tasks.bundling.AbstractArchiveTask
|
||||
@@ -47,6 +50,115 @@ ext.releaseVersion = releaseVersion
|
||||
ext.sourceRevision = sourceRevision
|
||||
ext.traceableVersion = traceableVersion
|
||||
|
||||
// Messaging first-R2 task names are reserved early, but qualification is deliberately fail-closed.
|
||||
// Follow-up owner tasks replace these skeleton actions only when matching tests write schema-valid,
|
||||
// source/profile-bound, payload-free evidence. Merely placing a manifest on disk cannot pass.
|
||||
Map<String, List<String>> messagingVerificationSkeletons = [
|
||||
'verifyMessagingPollingOutboxR2': [
|
||||
'app-bootstrap/build/messaging-evidence/polling-outbox-r2/manifest.json'
|
||||
],
|
||||
'verifyMessagingKafkaProducerR2': [
|
||||
'app-bootstrap/build/messaging-evidence/real-kafka-postgresql-r2/manifest.json'
|
||||
],
|
||||
'verifyMessagingSecurityR2': [
|
||||
'app-bootstrap/build/messaging-evidence/security-r2/manifest.json',
|
||||
'app-bootstrap/build/messaging-evidence/multi-broker-r2/manifest.json',
|
||||
'app-bootstrap/build/messaging-evidence/rotation-shutdown-r2/manifest.json'
|
||||
],
|
||||
'verifyMessagingReleaseProfile': [
|
||||
'build/messaging-evidence/contracts-schema/manifest.json',
|
||||
'app-bootstrap/build/messaging-evidence/polling-outbox-r2/manifest.json',
|
||||
'app-bootstrap/build/messaging-evidence/cutover-rehearsal/manifest.json',
|
||||
'app-bootstrap/build/messaging-evidence/real-kafka-postgresql-r2/manifest.json',
|
||||
'app-bootstrap/build/messaging-evidence/security-r2/manifest.json',
|
||||
'app-bootstrap/build/messaging-evidence/multi-broker-r2/manifest.json',
|
||||
'app-bootstrap/build/messaging-evidence/rotation-shutdown-r2/manifest.json'
|
||||
],
|
||||
'verifyMessagingTargetBindingPreflight': [
|
||||
'app-bootstrap/build/messaging-evidence/target-binding-preflight/manifest.json'
|
||||
],
|
||||
'verifyMessagingTargetBinding': [
|
||||
'app-bootstrap/build/messaging-evidence/target-binding-attestation/manifest.json'
|
||||
],
|
||||
'verifyMessagingDeploymentCutover': [
|
||||
'app-bootstrap/build/messaging-evidence/target-binding-attestation/manifest.json',
|
||||
'app-bootstrap/build/messaging-evidence/deployment-cutover/manifest.json'
|
||||
],
|
||||
'verifyMessagingCleanupTargetBinding': [
|
||||
'app-bootstrap/build/messaging-evidence/cleanup-target-binding-attestation/manifest.json'
|
||||
],
|
||||
'verifyMessagingFinalR2Profile': [
|
||||
'app-bootstrap/build/messaging-evidence/target-binding-attestation/manifest.json',
|
||||
'app-bootstrap/build/messaging-evidence/cleanup-target-binding-attestation/manifest.json',
|
||||
'build/messaging-evidence/final-r2-profile/manifest.json'
|
||||
]
|
||||
]
|
||||
|
||||
Closure<Void> messagingFailClosedEvidenceGuard = { String taskName, List<String> relativePaths ->
|
||||
List<File> evidenceFiles = relativePaths.collect { rootProject.file(it) }
|
||||
List<String> violations = evidenceFiles.findAll { !it.isFile() }.collect {
|
||||
"missing evidence ${rootProject.relativePath(it)}"
|
||||
}
|
||||
|
||||
String expectedSourceDigest = providers.gradleProperty('messagingSourceDigest').getOrElse('')
|
||||
String expectedProfileHash = providers.gradleProperty('messagingProfileHash').getOrElse('')
|
||||
if (expectedSourceDigest.isBlank()) {
|
||||
violations << 'missing -PmessagingSourceDigest=sha256:<exact-source-digest>'
|
||||
}
|
||||
if (expectedProfileHash.isBlank()) {
|
||||
violations << 'missing -PmessagingProfileHash=sha256:<exact-profile-hash>'
|
||||
}
|
||||
|
||||
evidenceFiles.findAll { it.isFile() }.each { File evidenceFile ->
|
||||
try {
|
||||
def manifest = new JsonSlurper().parse(evidenceFile)
|
||||
if (manifest.sourceDigest != expectedSourceDigest) {
|
||||
violations << "${rootProject.relativePath(evidenceFile)} has wrong source digest"
|
||||
}
|
||||
if (manifest.hashes?.profile != expectedProfileHash) {
|
||||
violations << "${rootProject.relativePath(evidenceFile)} has mismatched profile hash"
|
||||
}
|
||||
if ((manifest.counts?.skipped ?: 0) != 0 || !(manifest.skips instanceof List) ||
|
||||
!manifest.skips.isEmpty()) {
|
||||
violations << "${rootProject.relativePath(evidenceFile)} contains skipped evidence"
|
||||
}
|
||||
if ((manifest.counts?.failed ?: 0) != 0 || !(manifest.failures instanceof List) ||
|
||||
!manifest.failures.isEmpty()) {
|
||||
violations << "${rootProject.relativePath(evidenceFile)} contains failed evidence"
|
||||
}
|
||||
try {
|
||||
Instant generatedAt = Instant.parse(manifest.generatedAt as String)
|
||||
if (generatedAt.isBefore(Instant.now().minus(Duration.ofHours(24))) ||
|
||||
generatedAt.isAfter(Instant.now().plus(Duration.ofMinutes(5)))) {
|
||||
violations << "${rootProject.relativePath(evidenceFile)} is stale or future-dated"
|
||||
}
|
||||
} catch (RuntimeException ignored) {
|
||||
violations << "${rootProject.relativePath(evidenceFile)} has invalid generatedAt"
|
||||
}
|
||||
} catch (RuntimeException ignored) {
|
||||
violations << "${rootProject.relativePath(evidenceFile)} is not valid JSON evidence"
|
||||
}
|
||||
}
|
||||
|
||||
// Task 2 intentionally has no matching qualification Test tasks or complete schema validator.
|
||||
// This unconditional violation prevents hand-written evidence from manufacturing an R2 PASS.
|
||||
violations << 'qualification producer/tests and common-schema validator are not implemented'
|
||||
throw new GradleException(
|
||||
"${taskName}: FAIL_CLOSED — no R2 claim is available:\n ${violations.join('\n ')}")
|
||||
}
|
||||
|
||||
messagingVerificationSkeletons.each { String taskName, List<String> evidencePaths ->
|
||||
tasks.register(taskName) {
|
||||
group = 'verification'
|
||||
description = "Fail-closed Messaging qualification skeleton for ${taskName}."
|
||||
inputs.files(evidencePaths.collect { rootProject.file(it) }).optional()
|
||||
outputs.upToDateWhen { false }
|
||||
doLast {
|
||||
messagingFailClosedEvidenceGuard(taskName, evidencePaths)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Inbound gRPC adapter (adapter:inbound:grpc) — the Spring Boot BOM does NOT manage io.grpc:* or
|
||||
// protobuf versions, and this repo has no version catalog. Pin them here as the single SSOT so the
|
||||
// grpc module (and the future sample grpc feature) import io.grpc:grpc-bom + protobuf-bom as
|
||||
@@ -289,6 +401,394 @@ configure(subprojects.findAll { it.childProjects.isEmpty() }) {
|
||||
}
|
||||
}
|
||||
|
||||
// Task 6 replaces only the contract/schema skeletons with real, no-match-failing Test lanes.
|
||||
// The manifest is payload-free and is rebuilt only after exact source/artifact/profile properties
|
||||
// and every selected Task 3-6 test have passed in the current invocation.
|
||||
def messagingEvidenceResultRoot = layout.buildDirectory.dir('test-results/messaging-evidence')
|
||||
def registerMessagingQualificationTest = {
|
||||
Project owner, String taskName, List<String> patterns, String resultDirectory ->
|
||||
owner.tasks.register(taskName, Test) {
|
||||
group = 'verification'
|
||||
description = 'Runs exact Messaging Task 3-6 qualification tests without broad discovery.'
|
||||
testClassesDirs = owner.sourceSets.test.output.classesDirs
|
||||
classpath = owner.sourceSets.test.runtimeClasspath
|
||||
useJUnitPlatform()
|
||||
filter {
|
||||
patterns.each { includeTestsMatching(it) }
|
||||
failOnNoMatchingTests = true
|
||||
}
|
||||
failOnNoDiscoveredTests = true
|
||||
reports.junitXml.required = true
|
||||
reports.junitXml.outputLocation =
|
||||
messagingEvidenceResultRoot.map { it.dir(resultDirectory) }
|
||||
reports.html.required = false
|
||||
binaryResultsDirectory =
|
||||
layout.buildDirectory.dir("test-results/messaging-evidence-binary/${resultDirectory}")
|
||||
outputs.upToDateWhen { false }
|
||||
jvmArgs '-Duser.timezone=UTC'
|
||||
}
|
||||
}
|
||||
|
||||
def messagingApplicationQualification = registerMessagingQualificationTest(
|
||||
project(':application-core'),
|
||||
'messagingApplicationContractQualificationTest',
|
||||
[
|
||||
'dev.caskeleton.application.messaging.contract.IntegrationEventContractContributionTest',
|
||||
'dev.caskeleton.application.messaging.event.IntegrationEventDraftTest',
|
||||
'dev.caskeleton.application.messaging.event.ValidatedIntegrationEventTest'
|
||||
],
|
||||
'application')
|
||||
def messagingSharedQualification = registerMessagingQualificationTest(
|
||||
project(':shared-contract'),
|
||||
'messagingSharedSchemaQualificationTest',
|
||||
['dev.caskeleton.shared.contract.messaging.MessagingEnvelopeSchemaResourceTest'],
|
||||
'shared')
|
||||
def messagingSampleQualification = registerMessagingQualificationTest(
|
||||
project(':sample-portfolio'),
|
||||
'messagingSampleContractQualificationTest',
|
||||
['dev.caskeleton.sample.portfolio.application.event.WorkLogReservedContractContributionTest'],
|
||||
'sample')
|
||||
def messagingCompiledQualification = registerMessagingQualificationTest(
|
||||
project(':adapter:outbound:messaging'),
|
||||
'messagingCompiledContractsQualificationTest',
|
||||
[
|
||||
'dev.caskeleton.adapter.outbound.messaging.config.MessagingCapabilityCardRegistryTest',
|
||||
'dev.caskeleton.adapter.outbound.messaging.contract.ContractCatalogCompilerTest',
|
||||
'dev.caskeleton.adapter.outbound.messaging.contract.ContractCatalogDigestTest',
|
||||
'dev.caskeleton.adapter.outbound.messaging.destination.DestinationBindingCompilerTest',
|
||||
'dev.caskeleton.adapter.outbound.messaging.destination.PartitionKeyV1Test'
|
||||
],
|
||||
'compiled')
|
||||
def messagingJsonSchemaQualification = registerMessagingQualificationTest(
|
||||
project(':adapter:outbound:messaging'),
|
||||
'messagingJsonSchemaV1QualificationTest',
|
||||
[
|
||||
'dev.caskeleton.adapter.outbound.messaging.envelope.LocalJsonSchemaRegistryTest',
|
||||
'dev.caskeleton.adapter.outbound.messaging.envelope.JsonSchemaIntegrationEventEncoderTest',
|
||||
'dev.caskeleton.adapter.outbound.messaging.envelope.EnvelopeAdversarialCorpusTest',
|
||||
'dev.caskeleton.adapter.outbound.messaging.qualification.MessagingEvidenceManifestSchemaValidatorTest'
|
||||
],
|
||||
'json-schema')
|
||||
|
||||
def messagingEvidenceFile = layout.buildDirectory.file(
|
||||
'messaging-evidence/contracts-schema/manifest.json')
|
||||
def messagingProfileFile = file('config/messaging/profile-compatibility.yaml')
|
||||
def messagingDigestProperty = { String propertyName ->
|
||||
String value = providers.gradleProperty(propertyName).getOrElse('')
|
||||
if (!(value ==~ /sha256:[a-f0-9]{64}/)) {
|
||||
throw new GradleException(
|
||||
"-P${propertyName}=sha256:<64-lowercase-hex> is required for Messaging evidence.")
|
||||
}
|
||||
value
|
||||
}
|
||||
def messagingSha256Bytes = { byte[] bytes ->
|
||||
'sha256:' + java.util.HexFormat.of().formatHex(
|
||||
MessageDigest.getInstance('SHA-256').digest(bytes))
|
||||
}
|
||||
def messagingSha256FileSet = { String domain, List<File> files ->
|
||||
MessageDigest digest = MessageDigest.getInstance('SHA-256')
|
||||
digest.update(domain.getBytes(java.nio.charset.StandardCharsets.UTF_8))
|
||||
digest.update((byte) 0)
|
||||
files.sort { rootProject.relativePath(it) }.each { File input ->
|
||||
if (!input.isFile()) {
|
||||
throw new GradleException(
|
||||
"Messaging evidence input is missing: ${rootProject.relativePath(input)}")
|
||||
}
|
||||
byte[] path = rootProject.relativePath(input)
|
||||
.getBytes(java.nio.charset.StandardCharsets.UTF_8)
|
||||
byte[] content = input.bytes
|
||||
digest.update(java.nio.ByteBuffer.allocate(Integer.BYTES).putInt(path.length).array())
|
||||
digest.update(path)
|
||||
digest.update(java.nio.ByteBuffer.allocate(Integer.BYTES).putInt(content.length).array())
|
||||
digest.update(content)
|
||||
}
|
||||
'sha256:' + java.util.HexFormat.of().formatHex(digest.digest())
|
||||
}
|
||||
|
||||
def prepareMessagingContractEvidence = tasks.register('prepareMessagingContractEvidence') {
|
||||
group = 'verification'
|
||||
outputs.upToDateWhen { false }
|
||||
doLast {
|
||||
File output = messagingEvidenceFile.get().asFile
|
||||
if (output.exists() && !output.delete()) {
|
||||
throw new GradleException("Could not delete stale Messaging evidence ${output}")
|
||||
}
|
||||
messagingDigestProperty('messagingSourceDigest')
|
||||
messagingDigestProperty('messagingArtifactDigest')
|
||||
String suppliedProfile = messagingDigestProperty('messagingProfileHash')
|
||||
String exactProfile = messagingSha256Bytes(messagingProfileFile.bytes)
|
||||
if (suppliedProfile != exactProfile) {
|
||||
throw new GradleException(
|
||||
"messagingProfileHash does not match exact config/messaging/profile-compatibility.yaml bytes.")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
[
|
||||
messagingApplicationQualification,
|
||||
messagingSharedQualification,
|
||||
messagingSampleQualification,
|
||||
messagingCompiledQualification,
|
||||
messagingJsonSchemaQualification
|
||||
].each {
|
||||
it.configure {
|
||||
dependsOn prepareMessagingContractEvidence
|
||||
}
|
||||
}
|
||||
|
||||
def messagingEvidenceFromXml = { List<String> resultDirectories ->
|
||||
List<Map<String, String>> cases = []
|
||||
resultDirectories.each { String directory ->
|
||||
File resultDirectory = messagingEvidenceResultRoot.get().dir(directory).asFile
|
||||
fileTree(resultDirectory).matching { include 'TEST-*.xml' }.files.sort().each { File xml ->
|
||||
def suite = new XmlSlurper(false, false).parse(xml)
|
||||
suite.testcase.each { testCase ->
|
||||
boolean failed = !testCase.failure.isEmpty() || !testCase.error.isEmpty()
|
||||
boolean skipped = !testCase.skipped.isEmpty()
|
||||
String simpleClass = testCase.@classname.text().tokenize('.').last()
|
||||
String rawId = "${simpleClass}.${testCase.@name.text()}"
|
||||
String scenarioId = rawId
|
||||
.replace('()', '')
|
||||
.replaceAll('[^A-Za-z0-9._:-]', '-')
|
||||
.replaceAll('-+', '-')
|
||||
cases << [id: scenarioId, failed: failed.toString(), skipped: skipped.toString()]
|
||||
}
|
||||
}
|
||||
}
|
||||
if (cases.isEmpty()) {
|
||||
throw new GradleException('Messaging qualification XML contains no discovered test cases.')
|
||||
}
|
||||
List<String> scenarioIds = cases.collect { it.id }.sort()
|
||||
if (scenarioIds.toSet().size() != scenarioIds.size()) {
|
||||
throw new GradleException('Messaging qualification scenario IDs are not unique.')
|
||||
}
|
||||
int failed = cases.count { it.failed == 'true' }
|
||||
int skipped = cases.count { it.skipped == 'true' }
|
||||
[
|
||||
scenarioIds: scenarioIds,
|
||||
counts: [
|
||||
executed: cases.size(),
|
||||
passed: cases.size() - failed - skipped,
|
||||
failed: failed,
|
||||
skipped: skipped
|
||||
]
|
||||
]
|
||||
}
|
||||
|
||||
def validateMessagingEvidenceStructure = { Map manifest, String expectedProducer ->
|
||||
Set<String> exactRootKeys = [
|
||||
'schemaVersion', 'sourceDigest', 'artifactDigest', 'producerTask', 'scenarioIds',
|
||||
'counts', 'command', 'generatedAt', 'hashes', 'failures', 'skips',
|
||||
'unsupportedClaims'
|
||||
] as Set
|
||||
Set<String> exactCountKeys = ['executed', 'passed', 'failed', 'skipped'] as Set
|
||||
Set<String> exactHashKeys = ['profile', 'catalog', 'schema', 'settings'] as Set
|
||||
List<String> violations = []
|
||||
if (manifest.keySet() != exactRootKeys) {
|
||||
violations << 'root fields do not match the common manifest schema'
|
||||
}
|
||||
if (manifest.schemaVersion != 1 || manifest.producerTask != expectedProducer) {
|
||||
violations << 'schemaVersion or producerTask is wrong'
|
||||
}
|
||||
['sourceDigest', 'artifactDigest'].each { String field ->
|
||||
if (!(manifest[field] instanceof String) ||
|
||||
!(manifest[field] ==~ /sha256:[a-f0-9]{64}/)) {
|
||||
violations << "${field} is not a canonical SHA-256"
|
||||
}
|
||||
}
|
||||
if (!(manifest.scenarioIds instanceof List) || manifest.scenarioIds.isEmpty() ||
|
||||
manifest.scenarioIds.toSet().size() != manifest.scenarioIds.size() ||
|
||||
manifest.scenarioIds.any {
|
||||
!(it instanceof String) ||
|
||||
!(it ==~ /[A-Za-z0-9][A-Za-z0-9._:-]{0,159}/)
|
||||
}) {
|
||||
violations << 'scenarioIds violate the common schema'
|
||||
}
|
||||
if (!(manifest.counts instanceof Map) || manifest.counts.keySet() != exactCountKeys ||
|
||||
!(manifest.counts.executed instanceof Integer) || manifest.counts.executed < 1 ||
|
||||
manifest.counts.values().any { !(it instanceof Integer) || it < 0 } ||
|
||||
manifest.counts.executed !=
|
||||
manifest.counts.passed + manifest.counts.failed + manifest.counts.skipped) {
|
||||
violations << 'counts are invalid or inconsistent'
|
||||
}
|
||||
if (manifest.counts?.failed != 0 || manifest.counts?.skipped != 0 ||
|
||||
manifest.failures != [] || manifest.skips != []) {
|
||||
violations << 'failed or skipped qualification cannot produce PASS evidence'
|
||||
}
|
||||
if (!(manifest.hashes instanceof Map) || manifest.hashes.keySet() != exactHashKeys ||
|
||||
manifest.hashes.values().any {
|
||||
!(it instanceof String) || !(it ==~ /sha256:[a-f0-9]{64}/)
|
||||
}) {
|
||||
violations << 'hashes violate the common schema'
|
||||
}
|
||||
if (!(manifest.command instanceof String) || manifest.command.isBlank() ||
|
||||
manifest.command.length() > 2048) {
|
||||
violations << 'command is missing or unbounded'
|
||||
}
|
||||
try {
|
||||
Instant.parse(manifest.generatedAt as String)
|
||||
} catch (RuntimeException ignored) {
|
||||
violations << 'generatedAt is not UTC date-time evidence'
|
||||
}
|
||||
if (!(manifest.unsupportedClaims instanceof List) ||
|
||||
manifest.unsupportedClaims.toSet().size() != manifest.unsupportedClaims.size() ||
|
||||
manifest.unsupportedClaims.any {
|
||||
!(it instanceof String) ||
|
||||
!(it ==~ /[A-Za-z0-9][A-Za-z0-9._:-]{0,159}/)
|
||||
}) {
|
||||
violations << 'unsupportedClaims violate the common schema'
|
||||
}
|
||||
if (!violations.isEmpty()) {
|
||||
throw new GradleException(
|
||||
"Messaging evidence fails the common schema structural validator:\n " +
|
||||
violations.join('\n '))
|
||||
}
|
||||
}
|
||||
|
||||
def writeMessagingEvidence = {
|
||||
String producerTask, List<String> resultDirectories, List<String> commandTasks ->
|
||||
Map result = messagingEvidenceFromXml(resultDirectories)
|
||||
Map manifest = [
|
||||
schemaVersion: 1,
|
||||
sourceDigest: messagingDigestProperty('messagingSourceDigest'),
|
||||
artifactDigest: messagingDigestProperty('messagingArtifactDigest'),
|
||||
producerTask: producerTask,
|
||||
scenarioIds: result.scenarioIds,
|
||||
counts: result.counts,
|
||||
command: './gradlew ' + commandTasks.join(' ') +
|
||||
' -PmessagingSourceDigest=<sha256> -PmessagingArtifactDigest=<sha256> ' +
|
||||
'-PmessagingProfileHash=<exact-sha256> --console=plain',
|
||||
generatedAt: Instant.now().toString(),
|
||||
hashes: [
|
||||
profile: messagingSha256Bytes(messagingProfileFile.bytes),
|
||||
catalog: messagingSha256FileSet(
|
||||
'ca-skeleton.messaging.evidence.catalog.v1',
|
||||
[file('config/messaging/readiness-cards.yaml')]),
|
||||
schema: messagingSha256FileSet(
|
||||
'ca-skeleton.messaging.evidence.schema-set.v1',
|
||||
[
|
||||
file('shared-contract/src/main/resources/contracts/messaging/envelope/v1.schema.json'),
|
||||
file('sample-portfolio/src/main/resources/contracts/messaging/portfolio.worklog.reserved/v1.schema.json')
|
||||
] + fileTree(
|
||||
'adapter/outbound/messaging/src/main/resources/contracts/messaging/meta/draft-2020-12'
|
||||
).files.toList()),
|
||||
settings: messagingSha256FileSet(
|
||||
'ca-skeleton.messaging.evidence.settings.v1',
|
||||
[
|
||||
file('adapter/outbound/messaging/build.gradle'),
|
||||
file('adapter/outbound/messaging/gradle.lockfile')
|
||||
])
|
||||
],
|
||||
failures: [],
|
||||
skips: [],
|
||||
unsupportedClaims: [
|
||||
'consumer-compatibility-full-suite',
|
||||
'durable-outbox-r2',
|
||||
'kafka-acknowledged-r2',
|
||||
'regex-engine-timeout',
|
||||
'remote-schema-resolution'
|
||||
]
|
||||
]
|
||||
validateMessagingEvidenceStructure(manifest, producerTask)
|
||||
File commonSchema =
|
||||
file('config/messaging/evidence/build-evidence-manifest-v1.schema.json')
|
||||
if (!commonSchema.isFile()) {
|
||||
throw new GradleException('Common Messaging evidence schema is missing.')
|
||||
}
|
||||
File output = messagingEvidenceFile.get().asFile
|
||||
output.parentFile.mkdirs()
|
||||
output.text = JsonOutput.prettyPrint(JsonOutput.toJson(manifest)) + System.lineSeparator()
|
||||
Map reloaded = new JsonSlurper().parse(output) as Map
|
||||
validateMessagingEvidenceStructure(reloaded, producerTask)
|
||||
logger.lifecycle(
|
||||
"${producerTask}: wrote payload-free evidence with ${result.counts.executed} scenarios.")
|
||||
}
|
||||
|
||||
def verifyMessagingJsonSchemaV1 = tasks.register('verifyMessagingJsonSchemaV1') {
|
||||
group = 'verification'
|
||||
description = 'Qualifies the deterministic local Draft 2020-12 envelope candidate.'
|
||||
dependsOn messagingJsonSchemaQualification
|
||||
dependsOn project(':adapter:outbound:messaging').tasks.named('verifyJsonSchemaRuntimeGraph')
|
||||
outputs.file(messagingEvidenceFile)
|
||||
outputs.upToDateWhen { false }
|
||||
doLast {
|
||||
writeMessagingEvidence(
|
||||
'verifyMessagingJsonSchemaV1',
|
||||
['json-schema'],
|
||||
[':adapter:outbound:messaging:messagingJsonSchemaV1QualificationTest',
|
||||
'verifyMessagingJsonSchemaV1'])
|
||||
}
|
||||
}
|
||||
|
||||
def validateMessagingJsonSchemaV1EvidenceManifestSchema =
|
||||
tasks.register('validateMessagingJsonSchemaV1EvidenceManifestSchema', JavaExec) {
|
||||
group = 'verification'
|
||||
description =
|
||||
'Validates the exact generated JSON qualification manifest bytes against the common Draft 2020-12 schema.'
|
||||
dependsOn verifyMessagingJsonSchemaV1
|
||||
classpath =
|
||||
project(':adapter:outbound:messaging').sourceSets.test.runtimeClasspath
|
||||
mainClass =
|
||||
'dev.caskeleton.adapter.outbound.messaging.qualification.MessagingEvidenceManifestSchemaValidator'
|
||||
args file('config/messaging/evidence/build-evidence-manifest-v1.schema.json')
|
||||
.absolutePath,
|
||||
messagingEvidenceFile.get().asFile.absolutePath
|
||||
inputs.file(file('config/messaging/evidence/build-evidence-manifest-v1.schema.json'))
|
||||
inputs.file(messagingEvidenceFile)
|
||||
outputs.upToDateWhen { false }
|
||||
}
|
||||
verifyMessagingJsonSchemaV1.configure {
|
||||
finalizedBy validateMessagingJsonSchemaV1EvidenceManifestSchema
|
||||
}
|
||||
|
||||
def verifyMessagingContracts = tasks.register('verifyMessagingContracts') {
|
||||
group = 'verification'
|
||||
description = 'Qualifies the closed Task 3-6 contract, catalog, binding and schema candidate.'
|
||||
dependsOn validateMessagingJsonSchemaV1EvidenceManifestSchema
|
||||
dependsOn messagingApplicationQualification
|
||||
dependsOn messagingSharedQualification
|
||||
dependsOn messagingSampleQualification
|
||||
dependsOn messagingCompiledQualification
|
||||
dependsOn messagingJsonSchemaQualification
|
||||
dependsOn project(':adapter:outbound:messaging').tasks.named('verifyJsonSchemaRuntimeGraph')
|
||||
outputs.file(messagingEvidenceFile)
|
||||
outputs.upToDateWhen { false }
|
||||
doLast {
|
||||
writeMessagingEvidence(
|
||||
'verifyMessagingContracts',
|
||||
['application', 'shared', 'sample', 'compiled', 'json-schema'],
|
||||
[
|
||||
':application-core:messagingApplicationContractQualificationTest',
|
||||
':shared-contract:messagingSharedSchemaQualificationTest',
|
||||
':sample-portfolio:messagingSampleContractQualificationTest',
|
||||
':adapter:outbound:messaging:messagingCompiledContractsQualificationTest',
|
||||
':adapter:outbound:messaging:messagingJsonSchemaV1QualificationTest',
|
||||
'verifyMessagingContracts'
|
||||
])
|
||||
}
|
||||
}
|
||||
|
||||
def validateMessagingContractsEvidenceManifestSchema =
|
||||
tasks.register('validateMessagingContractsEvidenceManifestSchema', JavaExec) {
|
||||
group = 'verification'
|
||||
description =
|
||||
'Validates the exact generated combined qualification manifest bytes against the common Draft 2020-12 schema.'
|
||||
dependsOn verifyMessagingContracts
|
||||
classpath =
|
||||
project(':adapter:outbound:messaging').sourceSets.test.runtimeClasspath
|
||||
mainClass =
|
||||
'dev.caskeleton.adapter.outbound.messaging.qualification.MessagingEvidenceManifestSchemaValidator'
|
||||
args file('config/messaging/evidence/build-evidence-manifest-v1.schema.json')
|
||||
.absolutePath,
|
||||
messagingEvidenceFile.get().asFile.absolutePath
|
||||
inputs.file(file('config/messaging/evidence/build-evidence-manifest-v1.schema.json'))
|
||||
inputs.file(messagingEvidenceFile)
|
||||
outputs.upToDateWhen { false }
|
||||
}
|
||||
verifyMessagingContracts.configure {
|
||||
finalizedBy validateMessagingContractsEvidenceManifestSchema
|
||||
}
|
||||
|
||||
// One explicit command regenerates every module's Gradle-default lockfile.
|
||||
tasks.register('resolveAndLockAll') {
|
||||
group = 'build setup'
|
||||
|
||||
@@ -0,0 +1,170 @@
|
||||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"$id": "urn:dev.caskeleton:messaging:build-evidence-manifest:v1",
|
||||
"title": "Messaging build evidence manifest v1",
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": [
|
||||
"schemaVersion",
|
||||
"sourceDigest",
|
||||
"artifactDigest",
|
||||
"producerTask",
|
||||
"scenarioIds",
|
||||
"counts",
|
||||
"command",
|
||||
"generatedAt",
|
||||
"hashes",
|
||||
"failures",
|
||||
"skips",
|
||||
"unsupportedClaims"
|
||||
],
|
||||
"properties": {
|
||||
"schemaVersion": {
|
||||
"type": "integer",
|
||||
"const": 1
|
||||
},
|
||||
"sourceDigest": {
|
||||
"$ref": "#/$defs/sha256"
|
||||
},
|
||||
"artifactDigest": {
|
||||
"$ref": "#/$defs/sha256"
|
||||
},
|
||||
"producerTask": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"maxLength": 128,
|
||||
"pattern": "^verifyMessaging[A-Za-z0-9]+$",
|
||||
"enum": [
|
||||
"verifyMessagingContracts",
|
||||
"verifyMessagingJsonSchemaV1",
|
||||
"verifyMessagingPollingOutboxR2",
|
||||
"verifyMessagingKafkaProducerR2",
|
||||
"verifyMessagingSecurityR2",
|
||||
"verifyMessagingReleaseProfile",
|
||||
"verifyMessagingTargetBindingPreflight",
|
||||
"verifyMessagingTargetBinding",
|
||||
"verifyMessagingDeploymentCutover",
|
||||
"verifyMessagingCleanupTargetBinding",
|
||||
"verifyMessagingFinalR2Profile"
|
||||
]
|
||||
},
|
||||
"scenarioIds": {
|
||||
"type": "array",
|
||||
"minItems": 1,
|
||||
"uniqueItems": true,
|
||||
"items": {
|
||||
"$ref": "#/$defs/identifier"
|
||||
}
|
||||
},
|
||||
"counts": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": [
|
||||
"executed",
|
||||
"passed",
|
||||
"failed",
|
||||
"skipped"
|
||||
],
|
||||
"properties": {
|
||||
"executed": {
|
||||
"type": "integer",
|
||||
"minimum": 1
|
||||
},
|
||||
"passed": {
|
||||
"type": "integer",
|
||||
"minimum": 0
|
||||
},
|
||||
"failed": {
|
||||
"type": "integer",
|
||||
"minimum": 0
|
||||
},
|
||||
"skipped": {
|
||||
"type": "integer",
|
||||
"minimum": 0
|
||||
}
|
||||
}
|
||||
},
|
||||
"command": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"maxLength": 2048
|
||||
},
|
||||
"generatedAt": {
|
||||
"type": "string",
|
||||
"format": "date-time"
|
||||
},
|
||||
"hashes": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": [
|
||||
"profile",
|
||||
"catalog",
|
||||
"schema",
|
||||
"settings"
|
||||
],
|
||||
"properties": {
|
||||
"profile": {
|
||||
"$ref": "#/$defs/sha256"
|
||||
},
|
||||
"catalog": {
|
||||
"$ref": "#/$defs/sha256"
|
||||
},
|
||||
"schema": {
|
||||
"$ref": "#/$defs/sha256"
|
||||
},
|
||||
"settings": {
|
||||
"$ref": "#/$defs/sha256"
|
||||
}
|
||||
}
|
||||
},
|
||||
"failures": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/$defs/result"
|
||||
}
|
||||
},
|
||||
"skips": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/$defs/result"
|
||||
}
|
||||
},
|
||||
"unsupportedClaims": {
|
||||
"type": "array",
|
||||
"uniqueItems": true,
|
||||
"items": {
|
||||
"$ref": "#/$defs/identifier"
|
||||
}
|
||||
}
|
||||
},
|
||||
"$defs": {
|
||||
"sha256": {
|
||||
"type": "string",
|
||||
"pattern": "^sha256:[a-f0-9]{64}$"
|
||||
},
|
||||
"identifier": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"maxLength": 160,
|
||||
"pattern": "^[A-Za-z0-9][A-Za-z0-9._:-]*$"
|
||||
},
|
||||
"result": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": [
|
||||
"scenarioId",
|
||||
"reason"
|
||||
],
|
||||
"properties": {
|
||||
"scenarioId": {
|
||||
"$ref": "#/$defs/identifier"
|
||||
},
|
||||
"reason": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"maxLength": 512
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
schemaVersion: 1
|
||||
profiles:
|
||||
- profileId: messaging-first-r2-polling-producer.v1
|
||||
semanticContractId: messaging-outbox-publish.v1
|
||||
selectedCardIds:
|
||||
- messaging-outbox-publish.v1
|
||||
- kafka-spring-acknowledged-idempotent.v1
|
||||
- postgresql-polling-outbox.v2
|
||||
- postgresql-per-record-jit-claim.v1
|
||||
- json-schema-envelope.v1
|
||||
- external-topic-validated.v1
|
||||
- kafka-sasl-ssl-scram-sha-512.v1
|
||||
- kafka-compression-none.v1
|
||||
- per-key-normal-path-sequence-detectable.v1
|
||||
- same-postgresql-transaction-resource.v1
|
||||
- authenticated-internal-web-disposition.v1
|
||||
dispatchProfile: postgresql-polling-outbox.v2
|
||||
claimProfile: postgresql-per-record-jit-claim.v1
|
||||
serializationProfile: json-schema-envelope.v1
|
||||
producerProfile: kafka-spring-acknowledged-idempotent.v1
|
||||
securityProfile: kafka-sasl-ssl-scram-sha-512.v1
|
||||
topicProfile: external-topic-validated.v1
|
||||
compressionProfile: kafka-compression-none.v1
|
||||
orderingProfile: per-key-normal-path-sequence-detectable.v1
|
||||
transactionProfile: same-postgresql-transaction-resource.v1
|
||||
operatorControlProfile: authenticated-internal-web-disposition.v1
|
||||
requiredScenarios:
|
||||
- messaging.interaction.append-encode-claim-ack.v1
|
||||
- messaging.interaction.indeterminate-hold-disposition.v1
|
||||
- messaging.interaction.shutdown-drain.v1
|
||||
@@ -0,0 +1,381 @@
|
||||
schemaVersion: 1
|
||||
cards:
|
||||
- cardId: messaging-outbox-publish.v1
|
||||
cardVersion: 1
|
||||
phase: P1
|
||||
semanticContractIds:
|
||||
- messaging-outbox-publish.v1
|
||||
providerId: provider-neutral
|
||||
providerVersion: 1
|
||||
maturity: not-implemented
|
||||
guarantees:
|
||||
- transactional-append-before-publication
|
||||
- explicit-terminal-or-operator-disposition
|
||||
explicitNonGuarantees:
|
||||
- consumer-deduplication
|
||||
- global-ordering
|
||||
outcomeTaxonomyVersion: outbox-publication-outcome.v1
|
||||
orderingProfile: per-key-normal-path-sequence-detectable.v1
|
||||
resourceBounds: bounded-first-r2.v1
|
||||
automaticPublicationAge: finite-required
|
||||
sameEventRequeueHorizon: finite-required
|
||||
securityProfile: kafka-sasl-ssl-scram-sha-512.v1
|
||||
topologyProfile: external-topic-validated.v1
|
||||
lifecycleProfile: bounded-startup-shutdown.v1
|
||||
operatorControlProfile: authenticated-internal-web-disposition.v1
|
||||
schemaSetHash: ""
|
||||
settingsDigest: ""
|
||||
evidenceFingerprint: ""
|
||||
evidenceTasks:
|
||||
- verifyMessagingContracts
|
||||
requiredScenarios:
|
||||
- messaging.contract.transactional-append.v1
|
||||
- messaging.contract.explicit-disposition.v1
|
||||
runbookIds:
|
||||
- runbook.messaging-terminal-delivery-disposition.v1
|
||||
owner: application-core
|
||||
|
||||
- cardId: kafka-spring-acknowledged-idempotent.v1
|
||||
cardVersion: 1
|
||||
phase: P3
|
||||
semanticContractIds:
|
||||
- messaging-outbox-publish.v1
|
||||
providerId: spring-kafka
|
||||
providerVersion: 4
|
||||
maturity: not-implemented
|
||||
guarantees:
|
||||
- broker-acknowledgement-observed
|
||||
- finite-producer-settings
|
||||
explicitNonGuarantees:
|
||||
- duplicate-free-delivery
|
||||
- global-ordering
|
||||
outcomeTaxonomyVersion: outbox-publication-outcome.v1
|
||||
orderingProfile: per-key-normal-path-sequence-detectable.v1
|
||||
resourceBounds: per-record-jit-bounded.v1
|
||||
automaticPublicationAge: finite-required
|
||||
sameEventRequeueHorizon: finite-required
|
||||
securityProfile: kafka-sasl-ssl-scram-sha-512.v1
|
||||
topologyProfile: external-topic-validated.v1
|
||||
lifecycleProfile: bounded-startup-shutdown.v1
|
||||
operatorControlProfile: authenticated-internal-web-disposition.v1
|
||||
schemaSetHash: ""
|
||||
settingsDigest: ""
|
||||
evidenceFingerprint: ""
|
||||
evidenceTasks:
|
||||
- verifyMessagingKafkaProducerR2
|
||||
requiredScenarios:
|
||||
- messaging.kafka.ack-metadata.v1
|
||||
- messaging.kafka.indeterminate-outcome.v1
|
||||
runbookIds:
|
||||
- runbook.messaging-producer-unavailable-or-unauthorized.v1
|
||||
owner: adapter-outbound-messaging
|
||||
|
||||
- cardId: postgresql-polling-outbox.v2
|
||||
cardVersion: 2
|
||||
phase: P2
|
||||
semanticContractIds:
|
||||
- messaging-outbox-publish.v1
|
||||
providerId: postgresql-polling
|
||||
providerVersion: 2
|
||||
maturity: not-implemented
|
||||
guarantees:
|
||||
- immutable-event-mutable-delivery-split
|
||||
- fenced-publication-authority
|
||||
explicitNonGuarantees:
|
||||
- zero-duplicate-delivery
|
||||
- global-ordering
|
||||
outcomeTaxonomyVersion: outbox-publication-outcome.v1
|
||||
orderingProfile: per-key-normal-path-sequence-detectable.v1
|
||||
resourceBounds: per-record-jit-bounded.v1
|
||||
automaticPublicationAge: finite-required
|
||||
sameEventRequeueHorizon: finite-required
|
||||
securityProfile: kafka-sasl-ssl-scram-sha-512.v1
|
||||
topologyProfile: external-topic-validated.v1
|
||||
lifecycleProfile: bounded-startup-shutdown.v1
|
||||
operatorControlProfile: authenticated-internal-web-disposition.v1
|
||||
schemaSetHash: ""
|
||||
settingsDigest: ""
|
||||
evidenceFingerprint: ""
|
||||
evidenceTasks:
|
||||
- verifyMessagingPollingOutboxR2
|
||||
requiredScenarios:
|
||||
- messaging.polling.delivery-lifecycle.v2
|
||||
- messaging.polling.authority-fence.v1
|
||||
runbookIds:
|
||||
- runbook.messaging-outbox-backlog-and-stale-lease.v1
|
||||
owner: adapter-outbound-persistence-jpa
|
||||
|
||||
- cardId: postgresql-per-record-jit-claim.v1
|
||||
cardVersion: 1
|
||||
phase: P2
|
||||
semanticContractIds:
|
||||
- messaging-outbox-publish.v1
|
||||
providerId: postgresql-jit-claim
|
||||
providerVersion: 1
|
||||
maturity: not-implemented
|
||||
guarantees:
|
||||
- one-record-claim-after-local-admission
|
||||
- token-and-unexpired-lease-cas
|
||||
explicitNonGuarantees:
|
||||
- distributed-strict-fifo
|
||||
- duplicate-free-delivery
|
||||
outcomeTaxonomyVersion: outbox-publication-outcome.v1
|
||||
orderingProfile: per-key-normal-path-sequence-detectable.v1
|
||||
resourceBounds: admitted-record-upper-bound-one.v1
|
||||
automaticPublicationAge: finite-required
|
||||
sameEventRequeueHorizon: finite-required
|
||||
securityProfile: kafka-sasl-ssl-scram-sha-512.v1
|
||||
topologyProfile: external-topic-validated.v1
|
||||
lifecycleProfile: bounded-startup-shutdown.v1
|
||||
operatorControlProfile: authenticated-internal-web-disposition.v1
|
||||
schemaSetHash: ""
|
||||
settingsDigest: ""
|
||||
evidenceFingerprint: ""
|
||||
evidenceTasks:
|
||||
- verifyMessagingPollingOutboxR2
|
||||
requiredScenarios:
|
||||
- messaging.polling.jit-claim.v1
|
||||
- messaging.polling.stale-token-rejected.v1
|
||||
runbookIds:
|
||||
- runbook.messaging-outbox-backlog-and-stale-lease.v1
|
||||
owner: adapter-outbound-persistence-jpa
|
||||
|
||||
- cardId: json-schema-envelope.v1
|
||||
cardVersion: 1
|
||||
phase: P1
|
||||
semanticContractIds:
|
||||
- messaging-outbox-publish.v1
|
||||
providerId: json-schema-envelope
|
||||
providerVersion: 1
|
||||
maturity: implemented-candidate
|
||||
guarantees:
|
||||
- deterministic-utf8-envelope
|
||||
- closed-offline-schema-catalog
|
||||
explicitNonGuarantees:
|
||||
- alternate-wire-format
|
||||
- dynamic-contract-discovery
|
||||
outcomeTaxonomyVersion: outbox-publication-outcome.v1
|
||||
orderingProfile: per-key-normal-path-sequence-detectable.v1
|
||||
resourceBounds: bounded-envelope-and-payload.v1
|
||||
automaticPublicationAge: finite-required
|
||||
sameEventRequeueHorizon: finite-required
|
||||
securityProfile: kafka-sasl-ssl-scram-sha-512.v1
|
||||
topologyProfile: external-topic-validated.v1
|
||||
lifecycleProfile: bounded-startup-shutdown.v1
|
||||
operatorControlProfile: authenticated-internal-web-disposition.v1
|
||||
schemaSetHash: "sha256:42040504d5c204f9ee0e01bfa17fd9a03182db13f8f99e31b63ec79d5ecc40d0"
|
||||
settingsDigest: "sha256:fcd849322d43a8d88c926a43160296339cbc6e945db81e46c513ab45d185b924"
|
||||
evidenceFingerprint: ""
|
||||
evidenceTasks:
|
||||
- verifyMessagingContracts
|
||||
- verifyMessagingJsonSchemaV1
|
||||
requiredScenarios:
|
||||
- messaging.schema.envelope-valid.v1
|
||||
- messaging.schema.adversarial-bounds.v1
|
||||
runbookIds:
|
||||
- runbook.messaging-schema-poison-or-record-too-large.v1
|
||||
owner: adapter-outbound-messaging
|
||||
|
||||
- cardId: external-topic-validated.v1
|
||||
cardVersion: 1
|
||||
phase: P4
|
||||
semanticContractIds:
|
||||
- messaging-outbox-publish.v1
|
||||
providerId: external-kafka-topic
|
||||
providerVersion: 1
|
||||
maturity: not-implemented
|
||||
guarantees:
|
||||
- external-topic-policy-attested
|
||||
- exact-destination-binding
|
||||
explicitNonGuarantees:
|
||||
- automatic-topic-creation
|
||||
- multi-cluster-failover
|
||||
outcomeTaxonomyVersion: outbox-publication-outcome.v1
|
||||
orderingProfile: per-key-normal-path-sequence-detectable.v1
|
||||
resourceBounds: finite-topic-catalog.v1
|
||||
automaticPublicationAge: finite-required
|
||||
sameEventRequeueHorizon: finite-required
|
||||
securityProfile: kafka-sasl-ssl-scram-sha-512.v1
|
||||
topologyProfile: external-topic-validated.v1
|
||||
lifecycleProfile: bounded-startup-shutdown.v1
|
||||
operatorControlProfile: authenticated-internal-web-disposition.v1
|
||||
schemaSetHash: ""
|
||||
settingsDigest: ""
|
||||
evidenceFingerprint: ""
|
||||
evidenceTasks:
|
||||
- verifyMessagingSecurityR2
|
||||
requiredScenarios:
|
||||
- messaging.topic.policy-attestation.v1
|
||||
- messaging.topic.below-min-isr-rejection.v1
|
||||
runbookIds:
|
||||
- runbook.messaging-topic-policy-or-partition-change.v1
|
||||
owner: adapter-outbound-messaging
|
||||
|
||||
- cardId: kafka-sasl-ssl-scram-sha-512.v1
|
||||
cardVersion: 1
|
||||
phase: P4
|
||||
semanticContractIds:
|
||||
- messaging-outbox-publish.v1
|
||||
providerId: kafka-sasl-ssl-scram-sha-512
|
||||
providerVersion: 1
|
||||
maturity: not-implemented
|
||||
guarantees:
|
||||
- authenticated-encrypted-broker-transport
|
||||
- least-privilege-producer-principal
|
||||
explicitNonGuarantees:
|
||||
- encryption-at-rest
|
||||
- credential-zero-downtime-without-drill
|
||||
outcomeTaxonomyVersion: outbox-publication-outcome.v1
|
||||
orderingProfile: per-key-normal-path-sequence-detectable.v1
|
||||
resourceBounds: bounded-secret-refresh.v1
|
||||
automaticPublicationAge: finite-required
|
||||
sameEventRequeueHorizon: finite-required
|
||||
securityProfile: kafka-sasl-ssl-scram-sha-512.v1
|
||||
topologyProfile: external-topic-validated.v1
|
||||
lifecycleProfile: bounded-startup-shutdown.v1
|
||||
operatorControlProfile: authenticated-internal-web-disposition.v1
|
||||
schemaSetHash: ""
|
||||
settingsDigest: ""
|
||||
evidenceFingerprint: ""
|
||||
evidenceTasks:
|
||||
- verifyMessagingSecurityR2
|
||||
requiredScenarios:
|
||||
- messaging.security.sasl-ssl-scram.v1
|
||||
- messaging.security.least-privilege-negative-probe.v1
|
||||
runbookIds:
|
||||
- runbook.messaging-shutdown-deploy-and-secret-rotation.v1
|
||||
owner: adapter-outbound-messaging
|
||||
|
||||
- cardId: kafka-compression-none.v1
|
||||
cardVersion: 1
|
||||
phase: P3
|
||||
semanticContractIds:
|
||||
- messaging-outbox-publish.v1
|
||||
providerId: kafka-compression-none
|
||||
providerVersion: 1
|
||||
maturity: not-implemented
|
||||
guarantees:
|
||||
- exact-uncompressed-producer-profile
|
||||
explicitNonGuarantees:
|
||||
- compression-ratio
|
||||
- compression-throughput
|
||||
outcomeTaxonomyVersion: outbox-publication-outcome.v1
|
||||
orderingProfile: per-key-normal-path-sequence-detectable.v1
|
||||
resourceBounds: bounded-record-size.v1
|
||||
automaticPublicationAge: finite-required
|
||||
sameEventRequeueHorizon: finite-required
|
||||
securityProfile: kafka-sasl-ssl-scram-sha-512.v1
|
||||
topologyProfile: external-topic-validated.v1
|
||||
lifecycleProfile: bounded-startup-shutdown.v1
|
||||
operatorControlProfile: authenticated-internal-web-disposition.v1
|
||||
schemaSetHash: ""
|
||||
settingsDigest: ""
|
||||
evidenceFingerprint: ""
|
||||
evidenceTasks:
|
||||
- verifyMessagingKafkaProducerR2
|
||||
requiredScenarios:
|
||||
- messaging.kafka.compression-none-effective.v1
|
||||
runbookIds:
|
||||
- runbook.messaging-producer-unavailable-or-unauthorized.v1
|
||||
owner: adapter-outbound-messaging
|
||||
|
||||
- cardId: per-key-normal-path-sequence-detectable.v1
|
||||
cardVersion: 1
|
||||
phase: P2
|
||||
semanticContractIds:
|
||||
- messaging-outbox-publish.v1
|
||||
providerId: aggregate-sequence-header
|
||||
providerVersion: 1
|
||||
maturity: not-implemented
|
||||
guarantees:
|
||||
- per-key-sequence-gap-detectable
|
||||
explicitNonGuarantees:
|
||||
- strict-fifo-through-failure
|
||||
- cross-key-ordering
|
||||
outcomeTaxonomyVersion: outbox-publication-outcome.v1
|
||||
orderingProfile: per-key-normal-path-sequence-detectable.v1
|
||||
resourceBounds: bounded-sequence-metadata.v1
|
||||
automaticPublicationAge: finite-required
|
||||
sameEventRequeueHorizon: finite-required
|
||||
securityProfile: kafka-sasl-ssl-scram-sha-512.v1
|
||||
topologyProfile: external-topic-validated.v1
|
||||
lifecycleProfile: bounded-startup-shutdown.v1
|
||||
operatorControlProfile: authenticated-internal-web-disposition.v1
|
||||
schemaSetHash: ""
|
||||
settingsDigest: ""
|
||||
evidenceFingerprint: ""
|
||||
evidenceTasks:
|
||||
- verifyMessagingPollingOutboxR2
|
||||
requiredScenarios:
|
||||
- messaging.ordering.per-key-sequence.v1
|
||||
runbookIds:
|
||||
- runbook.messaging-delivery-indeterminate-and-duplicate-burst.v1
|
||||
owner: application-core
|
||||
|
||||
- cardId: same-postgresql-transaction-resource.v1
|
||||
cardVersion: 1
|
||||
phase: P2
|
||||
semanticContractIds:
|
||||
- messaging-outbox-publish.v1
|
||||
providerId: same-postgresql-transaction-resource
|
||||
providerVersion: 1
|
||||
maturity: not-implemented
|
||||
guarantees:
|
||||
- business-write-and-outbox-append-atomic
|
||||
explicitNonGuarantees:
|
||||
- cross-database-atomicity
|
||||
- broker-transaction-atomicity
|
||||
outcomeTaxonomyVersion: outbox-publication-outcome.v1
|
||||
orderingProfile: per-key-normal-path-sequence-detectable.v1
|
||||
resourceBounds: one-transaction-resource.v1
|
||||
automaticPublicationAge: finite-required
|
||||
sameEventRequeueHorizon: finite-required
|
||||
securityProfile: kafka-sasl-ssl-scram-sha-512.v1
|
||||
topologyProfile: external-topic-validated.v1
|
||||
lifecycleProfile: bounded-startup-shutdown.v1
|
||||
operatorControlProfile: authenticated-internal-web-disposition.v1
|
||||
schemaSetHash: ""
|
||||
settingsDigest: ""
|
||||
evidenceFingerprint: ""
|
||||
evidenceTasks:
|
||||
- verifyMessagingPollingOutboxR2
|
||||
requiredScenarios:
|
||||
- messaging.persistence.same-resource-rollback.v1
|
||||
runbookIds:
|
||||
- runbook.messaging-outbox-backlog-and-stale-lease.v1
|
||||
owner: app-bootstrap
|
||||
|
||||
- cardId: authenticated-internal-web-disposition.v1
|
||||
cardVersion: 1
|
||||
phase: P3
|
||||
semanticContractIds:
|
||||
- messaging-outbox-publish.v1
|
||||
providerId: authenticated-internal-web-disposition
|
||||
providerVersion: 1
|
||||
maturity: not-implemented
|
||||
guarantees:
|
||||
- authenticated-operator-disposition
|
||||
- audited-generation-cas
|
||||
explicitNonGuarantees:
|
||||
- raw-status-update
|
||||
- unauthenticated-operator-access
|
||||
outcomeTaxonomyVersion: outbox-publication-outcome.v1
|
||||
orderingProfile: per-key-normal-path-sequence-detectable.v1
|
||||
resourceBounds: bounded-operator-request.v1
|
||||
automaticPublicationAge: finite-required
|
||||
sameEventRequeueHorizon: finite-required
|
||||
securityProfile: kafka-sasl-ssl-scram-sha-512.v1
|
||||
topologyProfile: external-topic-validated.v1
|
||||
lifecycleProfile: bounded-startup-shutdown.v1
|
||||
operatorControlProfile: authenticated-internal-web-disposition.v1
|
||||
schemaSetHash: ""
|
||||
settingsDigest: ""
|
||||
evidenceFingerprint: ""
|
||||
evidenceTasks:
|
||||
- verifyMessagingKafkaProducerR2
|
||||
requiredScenarios:
|
||||
- messaging.operator.authenticated-disposition.v1
|
||||
- messaging.operator.stale-generation-rejected.v1
|
||||
runbookIds:
|
||||
- runbook.messaging-terminal-delivery-disposition.v1
|
||||
owner: adapter-inbound-web
|
||||
@@ -0,0 +1,55 @@
|
||||
schemaVersion: 1
|
||||
releaseProfiles:
|
||||
- releaseProfileId: messaging-first-r2-polling-producer-release.v1
|
||||
compatibilityProfileId: messaging-first-r2-polling-producer.v1
|
||||
selectedCardIds:
|
||||
- messaging-outbox-publish.v1
|
||||
- kafka-spring-acknowledged-idempotent.v1
|
||||
- postgresql-polling-outbox.v2
|
||||
- postgresql-per-record-jit-claim.v1
|
||||
- json-schema-envelope.v1
|
||||
- external-topic-validated.v1
|
||||
- kafka-sasl-ssl-scram-sha-512.v1
|
||||
- kafka-compression-none.v1
|
||||
- per-key-normal-path-sequence-detectable.v1
|
||||
- same-postgresql-transaction-resource.v1
|
||||
- authenticated-internal-web-disposition.v1
|
||||
requiredCardMaturity: release-eligible
|
||||
expectedProfileHash: ""
|
||||
expectedCatalogHash: ""
|
||||
expectedSchemaHash: ""
|
||||
expectedSettingsHash: ""
|
||||
requiredEvidenceTasks:
|
||||
- verifyMessagingContracts
|
||||
- verifyMessagingJsonSchemaV1
|
||||
- verifyMessagingPollingOutboxR2
|
||||
- verifyMessagingKafkaProducerR2
|
||||
- verifyMessagingSecurityR2
|
||||
- verifyMessagingReleaseProfile
|
||||
- verifyMessagingTargetBindingPreflight
|
||||
- verifyMessagingTargetBinding
|
||||
- verifyMessagingDeploymentCutover
|
||||
- verifyMessagingCleanupTargetBinding
|
||||
- verifyMessagingFinalR2Profile
|
||||
requiredScenarios:
|
||||
- messaging.release.contracts-schema.v1
|
||||
- messaging.release.polling-outbox.v2
|
||||
- messaging.release.kafka-producer.v1
|
||||
- messaging.release.security-topology.v1
|
||||
- messaging.release.shutdown-rotation.v1
|
||||
runbookIds:
|
||||
- runbook.messaging-producer-unavailable-or-unauthorized.v1
|
||||
- runbook.messaging-outbox-backlog-and-stale-lease.v1
|
||||
- runbook.messaging-delivery-indeterminate-and-duplicate-burst.v1
|
||||
- runbook.messaging-schema-poison-or-record-too-large.v1
|
||||
- runbook.messaging-terminal-delivery-disposition.v1
|
||||
- runbook.messaging-topic-policy-or-partition-change.v1
|
||||
- runbook.messaging-shutdown-deploy-and-secret-rotation.v1
|
||||
- runbook.messaging-legacy-to-v2-relay-authority-cutover.v1
|
||||
evidencePolicy:
|
||||
rejectMissing: true
|
||||
rejectSkipped: true
|
||||
rejectStale: true
|
||||
rejectWrongSource: true
|
||||
rejectMismatchedProfile: true
|
||||
maximumAgeSeconds: 86400
|
||||
@@ -22,6 +22,9 @@ Package root: `dev.caskeleton.sample.portfolio`.
|
||||
`RepoStatsAclMapper`), `adapter/identifier` (`UuidWorkLogIdFactory`).
|
||||
- Contract-test fixtures the app-bootstrap verification suite analyses
|
||||
(`DomainExceptionHandler`, `PortfolioErrorCode`, wire/contract tests).
|
||||
- Disposable messaging contract fixture: the exact `WorkLogReservedPayload` record, its static
|
||||
application-core contract contribution, sample-owned schema/digest and golden vectors. It is not
|
||||
runtime-discovered, and validator compatibility remains unproven until messaging Task 6.
|
||||
- Sample application collaborators consume invocation context through application-core ports and
|
||||
must not import SLF4J/MDC.
|
||||
|
||||
@@ -39,6 +42,8 @@ Package root: `dev.caskeleton.sample.portfolio`.
|
||||
- Production-required behaviour living here: deleting this module must not break the
|
||||
production build or runtime (the base `GlobalExceptionHandler`, envelope, and
|
||||
error-code contract live in `adapter:inbound:web` / `shared-contract`).
|
||||
- Treating a sample schema/contribution as an automatically discovered production registry entry,
|
||||
or placing JSON mapper, physical topic, Kafka or bootstrap-server concerns in the contribution.
|
||||
|
||||
## Test
|
||||
|
||||
|
||||
@@ -223,6 +223,23 @@ curl -X POST localhost:8080/work-logs -H 'Content-Type: application/json' -d '{
|
||||
이벤트의 클래스 단순명을 쓰는 대안도 있지만, 클래스 이름을 바꾸면 토픽이 조용히 바뀌어 컨슈머가
|
||||
깨질 수 있어, 안정적인 문자열 리터럴을 택했습니다(토픽 마이그레이션 계획 없이는 바꾸지 말 것).
|
||||
|
||||
### WorkLogReservedPayload / …ContractContribution — canonical contract fixture
|
||||
|
||||
- `WorkLogReservedPayload`는 새 closed contract SPI에 제공하는 exact final record다. v1은
|
||||
`workLogId` 하나만 소유하며, schema와 같은 canonical bounded identifier 규칙을 생성자에서 지킨다.
|
||||
- `contracts/messaging/portfolio.worklog.reserved/v1.schema.json`과 golden vector는
|
||||
**sample fixture contract**다. 공통 envelope는 WorkLog 필드를 알지 못하며 이 모듈을 삭제해도
|
||||
production module build/runtime은 깨지지 않아야 한다.
|
||||
- contribution은 contract ID, exact payload type/component order, classpath resource ID, checked-in
|
||||
digest와 provider-neutral descriptor만 제공한다. JSON mapper, physical topic, Kafka 설정을 소유하지
|
||||
않으며 매 호출 filesystem I/O도 하지 않는다.
|
||||
- 기존 3-field `WorkLogReservedIntegrationEvent`/hand-rolled mapper는 현재 legacy outbox
|
||||
characterization 경로에 남아 있다. 새 1-field canonical payload는 그 타입을 조용히 대체하지 않으며,
|
||||
closed catalog와 encoder가 조립되기 전까지 양쪽은 서로 호출하거나 직렬화하지 않는다.
|
||||
- 현재는 classpath scanning이나 runtime discovery가 없다. Task 5의 closed catalog 조립 전에는
|
||||
자동 등록되지 않고, Task 6 validator qualification 전에는 Draft 2020-12 validator compatibility를
|
||||
주장하지 않는다.
|
||||
|
||||
### RepoStatsPort
|
||||
- 외부 저장소 통계를 가져오는 아웃바운드 포트(구현은 adapter-outbound).
|
||||
|
||||
|
||||
+69
@@ -0,0 +1,69 @@
|
||||
package dev.caskeleton.sample.portfolio.application.event;
|
||||
|
||||
import dev.caskeleton.application.messaging.contract.ContractDescriptor;
|
||||
import dev.caskeleton.application.messaging.contract.ContractId;
|
||||
import dev.caskeleton.application.messaging.contract.IntegrationEventContractContribution;
|
||||
import dev.caskeleton.application.messaging.contract.LogicalDestinationId;
|
||||
import dev.caskeleton.application.messaging.contract.SchemaResourceId;
|
||||
import dev.caskeleton.application.messaging.contract.Sha256;
|
||||
import java.time.Duration;
|
||||
import java.util.HexFormat;
|
||||
import java.util.List;
|
||||
|
||||
/** Static sample contribution for the {@code portfolio.worklog.reserved} v1 contract. */
|
||||
public final class WorkLogReservedContractContribution
|
||||
implements IntegrationEventContractContribution<WorkLogReservedPayload> {
|
||||
|
||||
private static final ContractId CONTRACT_ID = new ContractId("portfolio.worklog.reserved");
|
||||
private static final SchemaResourceId SCHEMA_RESOURCE =
|
||||
new SchemaResourceId("contracts/messaging/portfolio.worklog.reserved/v1.schema.json");
|
||||
private static final Sha256 SCHEMA_HASH =
|
||||
new Sha256(
|
||||
HexFormat.of()
|
||||
.parseHex("7264efd4e2531e6fd00010bb3deca96a" + "f2f1d0408d0021d014feaf90190f2eac"));
|
||||
private static final ContractDescriptor DESCRIPTOR =
|
||||
new ContractDescriptor(
|
||||
"sample-portfolio",
|
||||
new LogicalDestinationId("portfolio-domain-events"),
|
||||
"json-schema-envelope-v1",
|
||||
true,
|
||||
64 * 1024,
|
||||
128 * 1024,
|
||||
ContractDescriptor.SensitivityClassification.INTERNAL,
|
||||
Duration.ofDays(7));
|
||||
|
||||
@Override
|
||||
public ContractId contractId() {
|
||||
return CONTRACT_ID;
|
||||
}
|
||||
|
||||
@Override
|
||||
public int payloadVersion() {
|
||||
return 1;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Class<WorkLogReservedPayload> exactPayloadRecordType() {
|
||||
return WorkLogReservedPayload.class;
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<String> canonicalRecordComponentOrder() {
|
||||
return List.of("workLogId");
|
||||
}
|
||||
|
||||
@Override
|
||||
public SchemaResourceId payloadSchemaResource() {
|
||||
return SCHEMA_RESOURCE;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Sha256 payloadSchemaHash() {
|
||||
return SCHEMA_HASH;
|
||||
}
|
||||
|
||||
@Override
|
||||
public ContractDescriptor descriptor() {
|
||||
return DESCRIPTOR;
|
||||
}
|
||||
}
|
||||
+19
@@ -0,0 +1,19 @@
|
||||
package dev.caskeleton.sample.portfolio.application.event;
|
||||
|
||||
import dev.caskeleton.application.messaging.contract.IntegrationPayload;
|
||||
|
||||
/** Immutable sample payload for the {@code portfolio.worklog.reserved} v1 contract. */
|
||||
public record WorkLogReservedPayload(String workLogId) implements IntegrationPayload {
|
||||
|
||||
private static final int MAXIMUM_WORK_LOG_ID_LENGTH = 160;
|
||||
private static final String WORK_LOG_ID_GRAMMAR = "[A-Za-z0-9][A-Za-z0-9._:-]*";
|
||||
|
||||
public WorkLogReservedPayload {
|
||||
if (workLogId == null
|
||||
|| workLogId.length() > MAXIMUM_WORK_LOG_ID_LENGTH
|
||||
|| !workLogId.matches(WORK_LOG_ID_GRAMMAR)) {
|
||||
throw new IllegalArgumentException(
|
||||
"workLogId must be a 1-160 character canonical US-ASCII identifier");
|
||||
}
|
||||
}
|
||||
}
|
||||
+18
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"$id": "urn:dev-caskeleton:contracts:messaging:portfolio.worklog.reserved:v1",
|
||||
"title": "WorkLogReserved payload v1",
|
||||
"type": "object",
|
||||
"required": [
|
||||
"workLogId"
|
||||
],
|
||||
"properties": {
|
||||
"workLogId": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"maxLength": 160,
|
||||
"pattern": "^[A-Za-z0-9][A-Za-z0-9._:-]*$"
|
||||
}
|
||||
},
|
||||
"unevaluatedProperties": false
|
||||
}
|
||||
+1
@@ -0,0 +1 @@
|
||||
7264efd4e2531e6fd00010bb3deca96af2f1d0408d0021d014feaf90190f2eac
|
||||
+181
@@ -0,0 +1,181 @@
|
||||
package dev.caskeleton.sample.portfolio.application.event;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import dev.caskeleton.application.messaging.contract.ContractDescriptor;
|
||||
import dev.caskeleton.application.messaging.contract.ContractId;
|
||||
import dev.caskeleton.application.messaging.contract.IntegrationPayload;
|
||||
import dev.caskeleton.application.messaging.contract.LogicalDestinationId;
|
||||
import dev.caskeleton.application.messaging.contract.SchemaResourceId;
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.lang.reflect.Modifier;
|
||||
import java.net.URI;
|
||||
import java.nio.ByteBuffer;
|
||||
import java.nio.charset.CharacterCodingException;
|
||||
import java.nio.charset.CodingErrorAction;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.security.MessageDigest;
|
||||
import java.time.Duration;
|
||||
import java.util.HexFormat;
|
||||
import java.util.Locale;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
class WorkLogReservedContractContributionTest {
|
||||
|
||||
private static final String SCHEMA_RESOURCE =
|
||||
"contracts/messaging/portfolio.worklog.reserved/v1.schema.json";
|
||||
private static final String DIGEST_RESOURCE =
|
||||
"contracts/messaging/portfolio.worklog.reserved/v1.schema.sha256";
|
||||
private static final String VALID_RESOURCE =
|
||||
"contracts/messaging/portfolio.worklog.reserved/v1.valid.json";
|
||||
private static final String INVALID_UNKNOWN_FIELD_RESOURCE =
|
||||
"contracts/messaging/portfolio.worklog.reserved/v1.invalid-unknown-field.json";
|
||||
private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper();
|
||||
|
||||
@Test
|
||||
void payloadIsTheExactImmutableValidatedIntegrationPayloadRecord() {
|
||||
WorkLogReservedPayload payload = new WorkLogReservedPayload("worklog-42");
|
||||
|
||||
assertThat(payload.workLogId()).isEqualTo("worklog-42");
|
||||
assertThat(WorkLogReservedPayload.class.isRecord()).isTrue();
|
||||
assertThat(Modifier.isFinal(WorkLogReservedPayload.class.getModifiers())).isTrue();
|
||||
assertThat(IntegrationPayload.class).isAssignableFrom(WorkLogReservedPayload.class);
|
||||
|
||||
assertThatThrownBy(() -> new WorkLogReservedPayload(null))
|
||||
.isInstanceOf(IllegalArgumentException.class);
|
||||
assertThatThrownBy(() -> new WorkLogReservedPayload(""))
|
||||
.isInstanceOf(IllegalArgumentException.class);
|
||||
assertThatThrownBy(() -> new WorkLogReservedPayload("worklog 42"))
|
||||
.isInstanceOf(IllegalArgumentException.class);
|
||||
assertThatThrownBy(() -> new WorkLogReservedPayload("x".repeat(161)))
|
||||
.isInstanceOf(IllegalArgumentException.class);
|
||||
}
|
||||
|
||||
@Test
|
||||
void contributionPublishesOnlyTheClosedProviderNeutralContractDescription() {
|
||||
WorkLogReservedContractContribution contribution = new WorkLogReservedContractContribution();
|
||||
|
||||
assertThat(contribution.contractId()).isEqualTo(new ContractId("portfolio.worklog.reserved"));
|
||||
assertThat(contribution.payloadVersion()).isEqualTo(1);
|
||||
assertThat(contribution.exactPayloadRecordType()).isEqualTo(WorkLogReservedPayload.class);
|
||||
assertThat(contribution.canonicalRecordComponentOrder()).containsExactly("workLogId");
|
||||
assertThat(contribution.payloadSchemaResource())
|
||||
.isEqualTo(
|
||||
new SchemaResourceId("contracts/messaging/portfolio.worklog.reserved/v1.schema.json"));
|
||||
assertThat(contribution.payloadSchemaHash()).isSameAs(contribution.payloadSchemaHash());
|
||||
|
||||
ContractDescriptor descriptor = contribution.descriptor();
|
||||
assertThat(descriptor.ownerModule()).isEqualTo("sample-portfolio");
|
||||
assertThat(descriptor.logicalDestination())
|
||||
.isEqualTo(new LogicalDestinationId("portfolio-domain-events"));
|
||||
assertThat(descriptor.serializerId()).isEqualTo("json-schema-envelope-v1");
|
||||
assertThat(descriptor.orderingRequired()).isTrue();
|
||||
assertThat(descriptor.maximumPayloadBytes()).isEqualTo(64 * 1024);
|
||||
assertThat(descriptor.maximumEnvelopeBytes()).isEqualTo(128 * 1024);
|
||||
assertThat(descriptor.sensitivityClassification())
|
||||
.isEqualTo(ContractDescriptor.SensitivityClassification.INTERNAL);
|
||||
assertThat(descriptor.sameEventRequeueHorizon()).isEqualTo(Duration.ofDays(7));
|
||||
|
||||
assertThat(WorkLogReservedContractContribution.class.getDeclaredMethods())
|
||||
.allSatisfy(
|
||||
method -> {
|
||||
String name = method.getName().toLowerCase(Locale.ROOT);
|
||||
assertThat(name)
|
||||
.doesNotContain(
|
||||
"json", "mapper", "tree", "parser", "topic", "kafka", "bootstrap");
|
||||
});
|
||||
}
|
||||
|
||||
@Test
|
||||
void checkedInPayloadSchemaIsClosedBoundedAndMatchesItsExactByteDigest() throws Exception {
|
||||
byte[] schemaBytes = readResource(SCHEMA_RESOURCE);
|
||||
String schemaText = strictUtf8(schemaBytes);
|
||||
JsonNode schema = OBJECT_MAPPER.readTree(schemaText);
|
||||
|
||||
assertThat(schema.path("$schema").textValue())
|
||||
.isEqualTo("https://json-schema.org/draft/2020-12/schema");
|
||||
String schemaId = schema.path("$id").textValue();
|
||||
assertThat(schemaId)
|
||||
.isEqualTo("urn:dev-caskeleton:contracts:messaging:portfolio.worklog.reserved:v1");
|
||||
assertThat(URI.create(schemaId).isAbsolute()).isTrue();
|
||||
assertThat(schema.path("type").textValue()).isEqualTo("object");
|
||||
assertThat(schema.path("required")).containsExactly(OBJECT_MAPPER.valueToTree("workLogId"));
|
||||
assertThat(schema.path("properties").size()).isEqualTo(1);
|
||||
assertThat(schema.path("properties").has("workLogId")).isTrue();
|
||||
JsonNode workLogId = schema.path("properties").path("workLogId");
|
||||
assertThat(workLogId.path("type").textValue()).isEqualTo("string");
|
||||
assertThat(workLogId.path("minLength").intValue()).isEqualTo(1);
|
||||
assertThat(workLogId.path("maxLength").intValue()).isEqualTo(160);
|
||||
assertThat(workLogId.path("pattern").textValue()).isEqualTo("^[A-Za-z0-9][A-Za-z0-9._:-]*$");
|
||||
assertThat(schema.path("unevaluatedProperties").booleanValue()).isFalse();
|
||||
assertThat(schemaText).doesNotContainPattern("\"type\"\\s*:\\s*\\[[^]]*\"null\"");
|
||||
assertThat(schemaText).doesNotContain("\"$ref\"");
|
||||
|
||||
byte[] manifestBytes = readResource(DIGEST_RESOURCE);
|
||||
String manifest = strictUtf8(manifestBytes);
|
||||
assertThat(manifestBytes).endsWith((byte) '\n');
|
||||
assertThat(manifest).matches("[0-9a-f]{64}\\n");
|
||||
String exactDigest =
|
||||
HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(schemaBytes));
|
||||
assertThat(manifest).isEqualTo(exactDigest + "\n");
|
||||
|
||||
WorkLogReservedContractContribution contribution = new WorkLogReservedContractContribution();
|
||||
assertThat(contribution.payloadSchemaHash().toString()).isEqualTo(exactDigest);
|
||||
}
|
||||
|
||||
@Test
|
||||
void goldenVectorsFreezeRequiredUnknownAndNullPolicyWithoutClaimingValidatorCompatibility()
|
||||
throws Exception {
|
||||
JsonNode valid = OBJECT_MAPPER.readTree(strictUtf8(readResource(VALID_RESOURCE)));
|
||||
JsonNode invalidUnknown =
|
||||
OBJECT_MAPPER.readTree(strictUtf8(readResource(INVALID_UNKNOWN_FIELD_RESOURCE)));
|
||||
JsonNode missing = OBJECT_MAPPER.readTree("{}");
|
||||
JsonNode explicitNull = OBJECT_MAPPER.readTree("{\"workLogId\":null}");
|
||||
|
||||
assertThat(valid).isEqualTo(OBJECT_MAPPER.readTree("{\"workLogId\":\"worklog-42\"}"));
|
||||
assertThat(invalidUnknown.path("workLogId").textValue()).isEqualTo("worklog-42");
|
||||
assertThat(invalidUnknown.has("unexpected")).isTrue();
|
||||
|
||||
assertThat(matchesStructuralV1Policy(valid)).isTrue();
|
||||
assertThat(matchesStructuralV1Policy(invalidUnknown)).isFalse();
|
||||
assertThat(matchesStructuralV1Policy(missing)).isFalse();
|
||||
assertThat(matchesStructuralV1Policy(explicitNull)).isFalse();
|
||||
}
|
||||
|
||||
private static boolean matchesStructuralV1Policy(JsonNode candidate) {
|
||||
if (!candidate.isObject() || candidate.size() != 1 || !candidate.has("workLogId")) {
|
||||
return false;
|
||||
}
|
||||
JsonNode workLogId = candidate.path("workLogId");
|
||||
if (!workLogId.isTextual()) {
|
||||
return false;
|
||||
}
|
||||
String value = workLogId.textValue();
|
||||
return value.length() <= 160 && value.matches("[A-Za-z0-9][A-Za-z0-9._:-]*");
|
||||
}
|
||||
|
||||
private static byte[] readResource(String resource) throws IOException {
|
||||
try (InputStream input =
|
||||
WorkLogReservedContractContributionTest.class
|
||||
.getClassLoader()
|
||||
.getResourceAsStream(resource)) {
|
||||
if (input == null) {
|
||||
throw new IOException("Missing classpath resource: " + resource);
|
||||
}
|
||||
return input.readAllBytes();
|
||||
}
|
||||
}
|
||||
|
||||
private static String strictUtf8(byte[] bytes) throws CharacterCodingException {
|
||||
return StandardCharsets.UTF_8
|
||||
.newDecoder()
|
||||
.onMalformedInput(CodingErrorAction.REPORT)
|
||||
.onUnmappableCharacter(CodingErrorAction.REPORT)
|
||||
.decode(ByteBuffer.wrap(bytes))
|
||||
.toString();
|
||||
}
|
||||
}
|
||||
+1
@@ -0,0 +1 @@
|
||||
{"workLogId":"worklog-42","unexpected":"rejected"}
|
||||
+1
@@ -0,0 +1 @@
|
||||
{"workLogId":"worklog-42"}
|
||||
@@ -25,6 +25,9 @@ envelope shape, metric cardinality bounds, tracing seam, domain-context propagat
|
||||
`tracing/SpanErrorRecorder`) — W3C `traceparent` value type, baggage allowlist,
|
||||
and the span-error-recording seam (feature-distributed-tracing-contract; the OTel
|
||||
tracer runtime is a fork-activated seam, so these stay Java-stdlib-only).
|
||||
- Generic messaging envelope schema resources under `contracts/messaging/envelope/`.
|
||||
They own transport-neutral envelope metadata only; feature payload schemas remain in their
|
||||
feature-owner modules. Resource presence is not runtime discovery or registration.
|
||||
|
||||
## Allowed
|
||||
- Java standard library ONLY. No Spring, no Jackson, no JPA imports.
|
||||
@@ -32,6 +35,8 @@ envelope shape, metric cardinality bounds, tracing seam, domain-context propagat
|
||||
## Forbidden
|
||||
- business/domain concept (domain error codes belong in the consuming module).
|
||||
- framework imports (HTTP status is expressed as transport-neutral `int`; the web module maps it).
|
||||
- claiming Draft 2020-12 validator compatibility before the messaging adapter's Task 6 validator
|
||||
qualification exists.
|
||||
|
||||
## Test
|
||||
```bash
|
||||
|
||||
@@ -33,6 +33,20 @@ Spring/Lettuce 타입을 노출하지 않는다.
|
||||
|
||||
---
|
||||
|
||||
## messaging — generic envelope schema resource
|
||||
|
||||
- `contracts/messaging/envelope/v1.schema.json` 은 비즈니스 필드를 모르는 공통 envelope v1의
|
||||
저장소 소유 Draft 2020-12 리소스다. root와 aggregate metadata는 닫혀 있고, `payload`는 object
|
||||
크기만 제한한다. 실제 payload 필드와 닫힘 정책은 feature owner의 별도 schema가 소유한다.
|
||||
- `.schema.sha256`은 schema 파일의 exact bytes SHA-256이다. 같은 version의 schema bytes를 바꾸면
|
||||
digest도 의도적으로 갱신하고 compatibility 검토를 다시 해야 한다.
|
||||
- 이 단계의 테스트는 UTF-8, 정본 구조, digest, remote `$ref` 금지만 JDK로 검사한다. Task 6의
|
||||
실제 Draft 2020-12 validator가 붙기 전까지 validator compatibility가 증명된 것은 아니다.
|
||||
- classpath resource가 존재한다는 사실은 runtime discovery나 자동 등록을 의미하지 않는다.
|
||||
contract catalog와 encoder가 명시적으로 조립되기 전에는 어떤 publisher도 이 리소스를 읽지 않는다.
|
||||
|
||||
---
|
||||
|
||||
## error — 에러 코드 계약
|
||||
|
||||
### ApiErrorCode (인터페이스)
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"$id": "urn:dev-caskeleton:contracts:messaging:envelope:v1",
|
||||
"title": "Messaging envelope v1",
|
||||
"type": "object",
|
||||
"required": [
|
||||
"envelopeVersion",
|
||||
"eventId",
|
||||
"contractId",
|
||||
"payloadVersion",
|
||||
"logicalDestination",
|
||||
"aggregate",
|
||||
"occurredAt",
|
||||
"correlationId",
|
||||
"contentType",
|
||||
"payload"
|
||||
],
|
||||
"properties": {
|
||||
"envelopeVersion": {
|
||||
"const": 1
|
||||
},
|
||||
"eventId": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"maxLength": 96,
|
||||
"pattern": "^[A-Za-z0-9][A-Za-z0-9._:-]*$"
|
||||
},
|
||||
"contractId": {
|
||||
"type": "string",
|
||||
"minLength": 3,
|
||||
"maxLength": 160,
|
||||
"pattern": "^(?!.*(?:\\.|-)v[0-9]+$)[a-z][a-z0-9]*(?:-[a-z0-9]+)*(?:\\.[a-z][a-z0-9]*(?:-[a-z0-9]+)*)+$"
|
||||
},
|
||||
"payloadVersion": {
|
||||
"type": "integer",
|
||||
"minimum": 1,
|
||||
"maximum": 2147483647
|
||||
},
|
||||
"logicalDestination": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"maxLength": 96,
|
||||
"pattern": "^(?!.*-v[0-9]+$)[a-z][a-z0-9]*(?:-[a-z0-9]+)*$"
|
||||
},
|
||||
"aggregate": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"type",
|
||||
"id",
|
||||
"sequence",
|
||||
"eventIndex"
|
||||
],
|
||||
"properties": {
|
||||
"type": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"maxLength": 64,
|
||||
"pattern": "^[a-z][a-z0-9]*(?:[.-][a-z0-9]+)*$"
|
||||
},
|
||||
"id": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"maxLength": 160,
|
||||
"pattern": "^[A-Za-z0-9][A-Za-z0-9._:-]*$"
|
||||
},
|
||||
"sequence": {
|
||||
"type": "integer",
|
||||
"minimum": 1,
|
||||
"maximum": 9223372036854775807
|
||||
},
|
||||
"eventIndex": {
|
||||
"type": "integer",
|
||||
"minimum": 0,
|
||||
"maximum": 2147483647
|
||||
}
|
||||
},
|
||||
"unevaluatedProperties": false
|
||||
},
|
||||
"occurredAt": {
|
||||
"type": "string",
|
||||
"minLength": 20,
|
||||
"maxLength": 64,
|
||||
"format": "date-time"
|
||||
},
|
||||
"correlationId": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"maxLength": 96,
|
||||
"pattern": "^[A-Za-z0-9][A-Za-z0-9._:-]*$"
|
||||
},
|
||||
"contentType": {
|
||||
"const": "application/json"
|
||||
},
|
||||
"payload": {
|
||||
"type": "object",
|
||||
"maxProperties": 64
|
||||
}
|
||||
},
|
||||
"unevaluatedProperties": false
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
bf6f2e13fafe01b8ef4cbb73d7ba3f5703bfc68d145bdfe43190bf606dbd00b1
|
||||
+274
@@ -0,0 +1,274 @@
|
||||
package dev.caskeleton.shared.contract.messaging;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.math.BigInteger;
|
||||
import java.net.URI;
|
||||
import java.nio.ByteBuffer;
|
||||
import java.nio.charset.CharacterCodingException;
|
||||
import java.nio.charset.CodingErrorAction;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.security.MessageDigest;
|
||||
import java.security.NoSuchAlgorithmException;
|
||||
import java.util.HexFormat;
|
||||
import java.util.List;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
class MessagingEnvelopeSchemaResourceTest {
|
||||
|
||||
private static final String SCHEMA_RESOURCE = "contracts/messaging/envelope/v1.schema.json";
|
||||
private static final String DIGEST_RESOURCE = "contracts/messaging/envelope/v1.schema.sha256";
|
||||
private static final String CONTRACT_ID_PATTERN =
|
||||
"^(?!.*(?:\\.|-)v[0-9]+$)[a-z][a-z0-9]*(?:-[a-z0-9]+)*"
|
||||
+ "(?:\\.[a-z][a-z0-9]*(?:-[a-z0-9]+)*)+$";
|
||||
private static final String LOGICAL_DESTINATION_PATTERN =
|
||||
"^(?!.*-v[0-9]+$)[a-z][a-z0-9]*(?:-[a-z0-9]+)*$";
|
||||
private static final BigInteger INT_MAXIMUM = BigInteger.valueOf(Integer.MAX_VALUE);
|
||||
private static final BigInteger LONG_MAXIMUM = BigInteger.valueOf(Long.MAX_VALUE);
|
||||
|
||||
@Test
|
||||
void envelopeV1IsTheCanonicalBusinessFreeClosedResource() throws Exception {
|
||||
String schema = strictUtf8(readResource(SCHEMA_RESOURCE));
|
||||
|
||||
assertThat(schema)
|
||||
.isEqualTo(
|
||||
"""
|
||||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"$id": "urn:dev-caskeleton:contracts:messaging:envelope:v1",
|
||||
"title": "Messaging envelope v1",
|
||||
"type": "object",
|
||||
"required": [
|
||||
"envelopeVersion",
|
||||
"eventId",
|
||||
"contractId",
|
||||
"payloadVersion",
|
||||
"logicalDestination",
|
||||
"aggregate",
|
||||
"occurredAt",
|
||||
"correlationId",
|
||||
"contentType",
|
||||
"payload"
|
||||
],
|
||||
"properties": {
|
||||
"envelopeVersion": {
|
||||
"const": 1
|
||||
},
|
||||
"eventId": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"maxLength": 96,
|
||||
"pattern": "^[A-Za-z0-9][A-Za-z0-9._:-]*$"
|
||||
},
|
||||
"contractId": {
|
||||
"type": "string",
|
||||
"minLength": 3,
|
||||
"maxLength": 160,
|
||||
"pattern": "^(?!.*(?:\\\\.|-)v[0-9]+$)[a-z][a-z0-9]*(?:-[a-z0-9]+)*(?:\\\\.[a-z][a-z0-9]*(?:-[a-z0-9]+)*)+$"
|
||||
},
|
||||
"payloadVersion": {
|
||||
"type": "integer",
|
||||
"minimum": 1,
|
||||
"maximum": 2147483647
|
||||
},
|
||||
"logicalDestination": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"maxLength": 96,
|
||||
"pattern": "^(?!.*-v[0-9]+$)[a-z][a-z0-9]*(?:-[a-z0-9]+)*$"
|
||||
},
|
||||
"aggregate": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"type",
|
||||
"id",
|
||||
"sequence",
|
||||
"eventIndex"
|
||||
],
|
||||
"properties": {
|
||||
"type": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"maxLength": 64,
|
||||
"pattern": "^[a-z][a-z0-9]*(?:[.-][a-z0-9]+)*$"
|
||||
},
|
||||
"id": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"maxLength": 160,
|
||||
"pattern": "^[A-Za-z0-9][A-Za-z0-9._:-]*$"
|
||||
},
|
||||
"sequence": {
|
||||
"type": "integer",
|
||||
"minimum": 1,
|
||||
"maximum": 9223372036854775807
|
||||
},
|
||||
"eventIndex": {
|
||||
"type": "integer",
|
||||
"minimum": 0,
|
||||
"maximum": 2147483647
|
||||
}
|
||||
},
|
||||
"unevaluatedProperties": false
|
||||
},
|
||||
"occurredAt": {
|
||||
"type": "string",
|
||||
"minLength": 20,
|
||||
"maxLength": 64,
|
||||
"format": "date-time"
|
||||
},
|
||||
"correlationId": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"maxLength": 96,
|
||||
"pattern": "^[A-Za-z0-9][A-Za-z0-9._:-]*$"
|
||||
},
|
||||
"contentType": {
|
||||
"const": "application/json"
|
||||
},
|
||||
"payload": {
|
||||
"type": "object",
|
||||
"maxProperties": 64
|
||||
}
|
||||
},
|
||||
"unevaluatedProperties": false
|
||||
}
|
||||
""");
|
||||
|
||||
assertThat(schema)
|
||||
.doesNotContain(
|
||||
"\"workLogId\"", "\"portfolio.worklog.reserved\"", "\"topic\"", "\"bootstrapServers\"");
|
||||
assertThat(URI.create("urn:dev-caskeleton:contracts:messaging:envelope:v1").isAbsolute())
|
||||
.isTrue();
|
||||
assertThat(schema).doesNotContainPattern("\"type\"\\s*:\\s*\\[[^]]*\"null\"");
|
||||
assertThat(schema).doesNotContain("\"$ref\"");
|
||||
}
|
||||
|
||||
@Test
|
||||
void identifierPatternsStayInParityWithTaskThreeValueObjects() throws Exception {
|
||||
String schema = strictUtf8(readResource(SCHEMA_RESOURCE));
|
||||
|
||||
assertThat(schema)
|
||||
.contains(
|
||||
"\"pattern\": \"" + jsonEscape(CONTRACT_ID_PATTERN) + "\"",
|
||||
"\"pattern\": \"" + jsonEscape(LOGICAL_DESTINATION_PATTERN) + "\"");
|
||||
assertThat(List.of("portfolio.worklog.reserved", "portfolio-v1.worklog.reserved"))
|
||||
.allMatch(value -> value.matches(CONTRACT_ID_PATTERN));
|
||||
assertThat(List.of("portfolio.worklog.v1", "portfolio.worklog-v1"))
|
||||
.noneMatch(value -> value.matches(CONTRACT_ID_PATTERN));
|
||||
assertThat(List.of("portfolio-domain-events", "events"))
|
||||
.allMatch(value -> value.matches(LOGICAL_DESTINATION_PATTERN));
|
||||
assertThat(List.of("events-v2")).noneMatch(value -> value.matches(LOGICAL_DESTINATION_PATTERN));
|
||||
}
|
||||
|
||||
@Test
|
||||
void numericConstraintsFreezeJavaBoundaryAndOverBoundaryVectors() throws Exception {
|
||||
String schema = strictUtf8(readResource(SCHEMA_RESOURCE));
|
||||
|
||||
assertThat(countOccurrences(schema, "\"maximum\": 2147483647")).isEqualTo(2);
|
||||
assertThat(countOccurrences(schema, "\"maximum\": 9223372036854775807")).isEqualTo(1);
|
||||
|
||||
assertThat(withinRange("2147483647", BigInteger.ONE, INT_MAXIMUM)).isTrue();
|
||||
assertThat(withinRange("2147483648", BigInteger.ONE, INT_MAXIMUM)).isFalse();
|
||||
assertThat(withinRange("2147483647", BigInteger.ZERO, INT_MAXIMUM)).isTrue();
|
||||
assertThat(withinRange("2147483648", BigInteger.ZERO, INT_MAXIMUM)).isFalse();
|
||||
assertThat(withinRange("9223372036854775807", BigInteger.ONE, LONG_MAXIMUM)).isTrue();
|
||||
assertThat(withinRange("9223372036854775808", BigInteger.ONE, LONG_MAXIMUM)).isFalse();
|
||||
}
|
||||
|
||||
@Test
|
||||
void checkedInDigestMatchesExactSchemaBytesAndCanonicalManifestFormat() throws Exception {
|
||||
byte[] schemaBytes = readResource(SCHEMA_RESOURCE);
|
||||
byte[] manifestBytes = readResource(DIGEST_RESOURCE);
|
||||
String manifest = strictUtf8(manifestBytes);
|
||||
|
||||
assertThat(manifestBytes).endsWith((byte) '\n');
|
||||
assertThat(manifest).matches("[0-9a-f]{64}\\n");
|
||||
assertThat(manifest).isEqualTo(HexFormat.of().formatHex(sha256(schemaBytes)) + "\n");
|
||||
}
|
||||
|
||||
@Test
|
||||
void utf8DecoderRejectsMalformedResourceBytes() {
|
||||
byte[] malformed = {(byte) 0xc3, (byte) 0x28};
|
||||
|
||||
assertThatThrownBy(() -> strictUtf8(malformed)).isInstanceOf(CharacterCodingException.class);
|
||||
}
|
||||
|
||||
@Test
|
||||
void frozenEnvelopeExampleRetainsTheDesignValuesWithoutOwningBusinessSchema() {
|
||||
String frozenExample =
|
||||
"""
|
||||
{
|
||||
"envelopeVersion": 1,
|
||||
"eventId": "event-1",
|
||||
"contractId": "portfolio.worklog.reserved",
|
||||
"payloadVersion": 1,
|
||||
"logicalDestination": "portfolio-domain-events",
|
||||
"aggregate": {
|
||||
"type": "worklog",
|
||||
"id": "worklog-42",
|
||||
"sequence": 17,
|
||||
"eventIndex": 0
|
||||
},
|
||||
"occurredAt": "2026-07-28T05:10:30.123Z",
|
||||
"correlationId": "corr-1",
|
||||
"contentType": "application/json",
|
||||
"payload": {
|
||||
"workLogId": "worklog-42"
|
||||
}
|
||||
}
|
||||
""";
|
||||
|
||||
assertThat(frozenExample)
|
||||
.contains(
|
||||
"\"envelopeVersion\": 1",
|
||||
"\"eventId\": \"event-1\"",
|
||||
"\"contractId\": \"portfolio.worklog.reserved\"",
|
||||
"\"logicalDestination\": \"portfolio-domain-events\"",
|
||||
"\"sequence\": 17",
|
||||
"\"eventIndex\": 0",
|
||||
"\"occurredAt\": \"2026-07-28T05:10:30.123Z\"",
|
||||
"\"correlationId\": \"corr-1\"",
|
||||
"\"contentType\": \"application/json\"",
|
||||
"\"workLogId\": \"worklog-42\"");
|
||||
}
|
||||
|
||||
private static byte[] readResource(String resource) throws IOException {
|
||||
try (InputStream input =
|
||||
MessagingEnvelopeSchemaResourceTest.class.getClassLoader().getResourceAsStream(resource)) {
|
||||
if (input == null) {
|
||||
throw new IOException("Missing classpath resource: " + resource);
|
||||
}
|
||||
return input.readAllBytes();
|
||||
}
|
||||
}
|
||||
|
||||
private static String strictUtf8(byte[] bytes) throws CharacterCodingException {
|
||||
return StandardCharsets.UTF_8
|
||||
.newDecoder()
|
||||
.onMalformedInput(CodingErrorAction.REPORT)
|
||||
.onUnmappableCharacter(CodingErrorAction.REPORT)
|
||||
.decode(ByteBuffer.wrap(bytes))
|
||||
.toString();
|
||||
}
|
||||
|
||||
private static byte[] sha256(byte[] bytes) throws NoSuchAlgorithmException {
|
||||
return MessageDigest.getInstance("SHA-256").digest(bytes);
|
||||
}
|
||||
|
||||
private static String jsonEscape(String value) {
|
||||
return value.replace("\\", "\\\\");
|
||||
}
|
||||
|
||||
private static int countOccurrences(String value, String fragment) {
|
||||
return (value.length() - value.replace(fragment, "").length()) / fragment.length();
|
||||
}
|
||||
|
||||
private static boolean withinRange(String candidate, BigInteger minimum, BigInteger maximum) {
|
||||
BigInteger value = new BigInteger(candidate);
|
||||
return value.compareTo(minimum) >= 0 && value.compareTo(maximum) <= 0;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user