feat: add messaging R2 polling producer
This commit is contained in:
@@ -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"
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user