From 88f596305b643cdd5e17a86d9b10bf1e5e7a46fd Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 9 Aug 2026 21:08:26 +0900 Subject: [PATCH 1/8] test: require machine-readable HTTP and CDC contracts --- .../MachineReadableApiContractTest.java | 84 +++++++++++++++++++ 1 file changed, 84 insertions(+) create mode 100644 etl-service/src/test/java/com/xtrmetl/etl/documentation/MachineReadableApiContractTest.java diff --git a/etl-service/src/test/java/com/xtrmetl/etl/documentation/MachineReadableApiContractTest.java b/etl-service/src/test/java/com/xtrmetl/etl/documentation/MachineReadableApiContractTest.java new file mode 100644 index 00000000..6dcc2429 --- /dev/null +++ b/etl-service/src/test/java/com/xtrmetl/etl/documentation/MachineReadableApiContractTest.java @@ -0,0 +1,84 @@ +package com.xtrmetl.etl.documentation; + +import org.junit.jupiter.api.Test; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Guards the checked-in machine-readable public HTTP and CDC event contracts against the exact + * protected-develop product surface. Active pull-request routes must not be advertised early. + */ +class MachineReadableApiContractTest { + + private static final Path PROJECT_ROOT = projectRoot(); + + @Test + void openApiContractExistsAndDescribesOnlyProtectedHttpSurface() throws IOException { + String openApi = read("contracts/openapi/mightyetl.yaml"); + + assertTrue(openApi.contains("openapi: 3.2.0")); + assertTrue(openApi.contains("/api/etl/process:")); + assertTrue(openApi.contains("/api/etl/connectors:")); + assertTrue(openApi.contains("/api/etl/jobs:")); + assertTrue(openApi.contains("/api/etl/jobs/{jobRecordId}:")); + assertTrue(openApi.contains("/api/cdc/start:")); + assertTrue(openApi.contains("/api/cdc/stop:")); + assertTrue(openApi.contains("/api/cdc/status:")); + assertTrue(openApi.contains("/api/cdc/sources:")); + assertTrue(openApi.contains("/api/cdc/targets:")); + assertTrue(openApi.contains("application/problem+json")); + assertTrue(openApi.contains("Idempotency-Key")); + assertTrue(openApi.contains("Idempotency-Replayed")); + assertTrue(openApi.contains("Location")); + assertTrue(openApi.contains("Cache-Control")); + + assertFalse(openApi.contains("/api/etl/jobs/{jobRecordId}/cancellation:")); + assertFalse(openApi.contains("/api/etl/jobs/{sourceJobRecordId}/replays:")); + assertFalse(openApi.contains("Retry-After")); + assertFalse(openApi.contains("If-None-Match")); + assertFalse(openApi.contains("ETag")); + } + + @Test + void asyncApiContractExistsAndDescribesReplayTolerantKafkaCdc() throws IOException { + String asyncApi = read("contracts/asyncapi/mightyetl-cdc.yaml"); + + assertTrue(asyncApi.contains("asyncapi: 3.1.0")); + assertTrue(asyncApi.contains("kafka")); + assertTrue(asyncApi.contains("application/json")); + assertTrue(asyncApi.contains("Debezium")); + assertTrue(asyncApi.contains("at-least-once")); + assertFalse(asyncApi.toLowerCase().contains("exactly-once")); + } + + private static String read(String relativePath) throws IOException { + return Files.readString(PROJECT_ROOT.resolve(relativePath), StandardCharsets.UTF_8) + .replace("\r\n", "\n") + .replace("\r", "\n"); + } + + private static Path projectRoot() { + Path current = Paths.get(System.getProperty("user.dir")).toAbsolutePath(); + Path lastPomParent = null; + while (current != null) { + if (Files.exists(current.resolve(".git"))) { + return current; + } + if (Files.exists(current.resolve("pom.xml"))) { + lastPomParent = current; + } + current = current.getParent(); + } + if (lastPomParent != null) { + return lastPomParent; + } + throw new IllegalStateException("Could not find project root"); + } +} From a6f3c1da482ca19f2e53f4491309a2e66ca8d50d Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 9 Aug 2026 21:14:07 +0900 Subject: [PATCH 2/8] feat(api): add protected HTTP OpenAPI contract --- contracts/openapi/mightyetl.yaml | 369 +++++++++++++++++++++++++++++++ 1 file changed, 369 insertions(+) create mode 100644 contracts/openapi/mightyetl.yaml diff --git a/contracts/openapi/mightyetl.yaml b/contracts/openapi/mightyetl.yaml new file mode 100644 index 00000000..c6f6790b --- /dev/null +++ b/contracts/openapi/mightyetl.yaml @@ -0,0 +1,369 @@ +openapi: 3.2.0 +info: + title: mightyETL protected HTTP API + version: 0.1.0-develop + description: >- + Machine-readable contract for endpoints implemented on protected develop at + 622e5e6c3d534f230c390f10e3832efadfc01825. Open pull-request capabilities are intentionally excluded. +servers: + - url: / +tags: + - name: etl + - name: durable-jobs + - name: cdc +paths: + /api/etl/process: + post: + tags: [etl] + operationId: processEtlBatch + summary: Process one bounded atomic JSON ETL batch + security: + - basicAuth: [] + parameters: + - $ref: '#/components/parameters/OptionalIdempotencyKey' + requestBody: + required: true + content: + application/json: + schema: + type: array + items: + type: object + additionalProperties: true + responses: + '200': + description: Batch processed successfully + headers: + Idempotency-Replayed: + description: Present for keyed requests; true when a prior successful response was replayed. + schema: + type: string + enum: ['true', 'false'] + content: + text/plain: + schema: + type: string + '400': + $ref: '#/components/responses/Problem' + '409': + $ref: '#/components/responses/Problem' + '413': + $ref: '#/components/responses/Problem' + '422': + $ref: '#/components/responses/Problem' + '500': + $ref: '#/components/responses/Problem' + '503': + $ref: '#/components/responses/Problem' + /api/etl/connectors: + get: + tags: [etl] + operationId: listTargetConnectors + summary: List target connector discovery and support state + security: + - basicAuth: [] + responses: + '200': + description: Operator-safe connector catalog + content: + application/json: + schema: + type: object + required: [product, primaryLoadPath, connectors, docs] + properties: + product: + type: string + const: mightyETL + primaryLoadPath: + type: string + const: postgresql + connectors: + type: array + items: + type: object + additionalProperties: true + docs: + type: string + /api/etl/jobs: + post: + tags: [durable-jobs] + operationId: submitDurableEtlJob + summary: Accept a feature-gated durable ETL job + description: >- + This route exists only when xtrmetl.etl.jobs.intake-enabled=true. The protected baseline accepts + and exposes pending job state but does not yet include the active-PR worker/pagination/cancellation/replay lifecycle. + security: + - basicAuth: [] + parameters: + - $ref: '#/components/parameters/RequiredIdempotencyKey' + requestBody: + required: true + content: + application/json: + schema: + type: array + items: + type: object + additionalProperties: true + responses: + '202': + description: Durable job accepted; execution success is not implied + headers: + Location: + description: Relative owner-scoped status resource + required: true + schema: + type: string + Cache-Control: + description: Response is not to be stored + required: true + schema: + type: string + const: no-store + Idempotency-Replayed: + description: Whether this accepted representation replays a prior identical submission + required: true + schema: + type: string + enum: ['true', 'false'] + content: + application/json: + schema: + $ref: '#/components/schemas/EtlJobAcceptedResponse' + '400': + $ref: '#/components/responses/Problem' + '409': + $ref: '#/components/responses/Problem' + '413': + $ref: '#/components/responses/Problem' + '422': + $ref: '#/components/responses/Problem' + '500': + $ref: '#/components/responses/Problem' + '503': + $ref: '#/components/responses/Problem' + /api/etl/jobs/{jobRecordId}: + get: + tags: [durable-jobs] + operationId: getDurableEtlJobStatus + summary: Read one owner-scoped durable job status + description: Route exists only while durable job intake is enabled. + security: + - basicAuth: [] + parameters: + - name: jobRecordId + in: path + required: true + description: Opaque UUID job identifier. Missing and foreign-owned identifiers share the same not-found surface. + schema: + type: string + format: uuid + responses: + '200': + description: Current operator-safe job status + headers: + Cache-Control: + description: Response is not to be stored + required: true + schema: + type: string + const: no-store + content: + application/json: + schema: + $ref: '#/components/schemas/EtlJobStatusResponse' + '400': + $ref: '#/components/responses/Problem' + '404': + $ref: '#/components/responses/Problem' + '500': + $ref: '#/components/responses/Problem' + '503': + $ref: '#/components/responses/Problem' + /api/cdc/start: + post: + tags: [cdc] + operationId: startCdc + summary: Start the configured PostgreSQL Debezium CDC engine + responses: + '200': + description: Start was requested or the engine was already running + content: + text/plain: + schema: + type: string + /api/cdc/stop: + post: + tags: [cdc] + operationId: stopCdc + summary: Request stop of the configured CDC engine + description: Protected develop does not yet prove asynchronous Debezium engine-task completion before returning success. + responses: + '200': + description: The current stop method returned without an IOException; asynchronous task completion is not guaranteed by this baseline. + content: + text/plain: + schema: + type: string + '500': + description: IOException while closing the current engine + content: + text/plain: + schema: + type: string + /api/cdc/status: + get: + tags: [cdc] + operationId: getCdcStatus + summary: Read operator-safe CDC status + responses: + '200': + description: Current CDC runtime/configuration summary without configured secrets + content: + application/json: + schema: + type: object + additionalProperties: true + /api/cdc/sources: + get: + tags: [cdc] + operationId: listCdcSources + summary: List registered CDC source descriptors + responses: + '200': + description: Registered source descriptors + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/CdcSourceDescriptor' + /api/cdc/targets: + get: + tags: [cdc] + operationId: listCdcTargets + summary: List registered CDC target descriptors + responses: + '200': + description: Registered target descriptors + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/CdcTargetDescriptor' +components: + securitySchemes: + basicAuth: + type: http + scheme: basic + description: ETL service protected /api/** baseline uses Spring Security HTTP Basic authentication. + parameters: + OptionalIdempotencyKey: + name: Idempotency-Key + in: header + required: false + description: Optional bounded key; when supplied the request also requires an authenticated principal. + schema: + type: string + RequiredIdempotencyKey: + name: Idempotency-Key + in: header + required: true + description: Required bounded durable-submission key scoped to the authenticated principal. + schema: + type: string + responses: + Problem: + description: Typed ETL failure + headers: + Cache-Control: + description: Problem responses are not to be stored + schema: + type: string + const: no-store + content: + application/problem+json: + schema: + $ref: '#/components/schemas/ProblemDetail' + schemas: + ProblemDetail: + type: object + required: [type, title, status, detail, instance, errorCode] + properties: + type: + type: string + format: uri + title: + type: string + status: + type: integer + detail: + type: string + instance: + type: string + format: uri-reference + errorCode: + type: string + additionalProperties: true + EtlJobAcceptedResponse: + type: object + required: [jobRecordId, jobStatus, statusUrl] + properties: + jobRecordId: + type: string + format: uuid + jobStatus: + $ref: '#/components/schemas/ProtectedJobStatus' + statusUrl: + type: string + EtlJobStatusResponse: + type: object + required: [jobRecordId, jobStatus, attemptCount, createdAt, updatedAt] + properties: + jobRecordId: + type: string + format: uuid + jobStatus: + $ref: '#/components/schemas/ProtectedJobStatus' + attemptCount: + type: integer + minimum: 0 + failureCode: + type: [string, 'null'] + createdAt: + type: string + format: date-time + updatedAt: + type: string + format: date-time + ProtectedJobStatus: + type: string + description: Protected develop lifecycle only; cancellation exists only on an active PR. + enum: [PENDING, RUNNING, SUCCEEDED, FAILED] + CdcSourceDescriptor: + type: object + required: [id, displayName, engine, databases, scaffoldOnly] + properties: + id: + type: string + displayName: + type: string + engine: + type: string + databases: + type: array + items: + type: string + scaffoldOnly: + type: boolean + CdcTargetDescriptor: + type: object + required: [id, displayName, scaffoldOnly] + properties: + id: + type: string + displayName: + type: string + scaffoldOnly: + type: boolean From b7a229667721e78e74ec8b216ab9c75108655027 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 9 Aug 2026 21:14:28 +0900 Subject: [PATCH 3/8] feat(cdc): add protected Kafka AsyncAPI contract --- contracts/asyncapi/mightyetl-cdc.yaml | 65 +++++++++++++++++++++++++++ 1 file changed, 65 insertions(+) create mode 100644 contracts/asyncapi/mightyetl-cdc.yaml diff --git a/contracts/asyncapi/mightyetl-cdc.yaml b/contracts/asyncapi/mightyetl-cdc.yaml new file mode 100644 index 00000000..25e83bb0 --- /dev/null +++ b/contracts/asyncapi/mightyetl-cdc.yaml @@ -0,0 +1,65 @@ +asyncapi: 3.1.0 +info: + title: mightyETL protected CDC publication API + version: 0.1.0-develop + description: >- + Machine-readable description of the protected-develop PostgreSQL Debezium to Kafka publication boundary. + The live path forwards raw Debezium JSON and is replay-tolerant with at-least-once semantics; this contract + deliberately makes no end-to-end delivery-strength claim beyond that implemented behavior. +defaultContentType: application/json +servers: + kafkaBroker: + host: '{bootstrapServers}' + protocol: kafka + description: Deployment-provided Kafka bootstrap endpoint. + variables: + bootstrapServers: + default: localhost:9092 + description: Kafka bootstrap host and port supplied by the deployment. +channels: + debeziumChanges: + address: '{destination}' + title: Debezium change-event destination + description: >- + The actual Kafka destination is ChangeEvent.destination() supplied by Debezium. With the protected default + PostgreSQL connector configuration this derives from the configured topic prefix plus source table identity. + servers: + - $ref: '#/servers/kafkaBroker' + parameters: + destination: + description: Runtime Debezium destination/topic. Consumers must not assume a hard-coded table list. + messages: + rawDebeziumChange: + $ref: '#/components/messages/RawDebeziumChange' +operations: + publishRawDebeziumChange: + action: send + title: Publish a raw Debezium change event + description: >- + mightyETL publishes the raw Debezium JSON value to Kafka. When Debezium supplies a key it is forwarded; + otherwise the key-less Kafka send form is used. The optional canonical mapper is validation/scaffolding and + does not replace the live publication payload. + channel: + $ref: '#/channels/debeziumChanges' + messages: + - $ref: '#/channels/debeziumChanges/messages/rawDebeziumChange' +components: + messages: + RawDebeziumChange: + name: RawDebeziumChange + title: Raw Debezium JSON change event + summary: PostgreSQL CDC event forwarded without canonical payload transformation + contentType: application/json + description: >- + Payload compatibility follows the configured Debezium PostgreSQL connector JSON output. The current + protected implementation submits the Kafka send asynchronously and therefore documents replay-tolerant + at-least-once behavior rather than stronger broker-acknowledged source-progress semantics. PR #139 owns + the separate acknowledgement-before-progress repair. + headers: + type: object + additionalProperties: true + description: No application-specific Kafka header contract is asserted by protected mightyETL. + payload: + type: object + additionalProperties: true + description: Raw Debezium JSON object; schema evolves according to Debezium/source configuration. From 0350193f52d29f2e135d11cf5348a050ffa3afc9 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 9 Aug 2026 21:24:45 +0900 Subject: [PATCH 4/8] test(api): bind machine contracts to controller source --- .../MachineReadableApiContractTest.java | 55 +++++++++++++++++++ 1 file changed, 55 insertions(+) diff --git a/etl-service/src/test/java/com/xtrmetl/etl/documentation/MachineReadableApiContractTest.java b/etl-service/src/test/java/com/xtrmetl/etl/documentation/MachineReadableApiContractTest.java index 6dcc2429..4b51688f 100644 --- a/etl-service/src/test/java/com/xtrmetl/etl/documentation/MachineReadableApiContractTest.java +++ b/etl-service/src/test/java/com/xtrmetl/etl/documentation/MachineReadableApiContractTest.java @@ -46,9 +46,40 @@ void openApiContractExistsAndDescribesOnlyProtectedHttpSurface() throws IOExcept assertFalse(openApi.contains("ETag")); } + @Test + void openApiRoutesRemainBoundToProtectedControllerSource() throws IOException { + String openApi = read("contracts/openapi/mightyetl.yaml"); + String etlController = read( + "etl-service/src/main/java/com/xtrmetl/etl/controller/EtlController.java" + ); + String jobController = read( + "etl-service/src/main/java/com/xtrmetl/etl/controller/EtlJobController.java" + ); + String cdcController = read( + "cdc-service/src/main/java/com/xtrmetl/cdc/controller/CdcController.java" + ); + + assertSourceRouteDocumented(etlController, openApi, "/api/etl/process"); + assertSourceRouteDocumented(etlController, openApi, "/api/etl/connectors"); + + assertTrue(jobController.contains("@RequestMapping(\"/api/etl/jobs\")")); + assertTrue(jobController.contains("@PostMapping")); + assertTrue(jobController.contains("@GetMapping(\"/{jobRecordId}\")")); + assertTrue(openApi.contains("/api/etl/jobs:")); + assertTrue(openApi.contains("/api/etl/jobs/{jobRecordId}:")); + + assertTrue(cdcController.contains("@RequestMapping(\"/api/cdc\")")); + assertChildRouteDocumented(cdcController, openApi, "/start", "/api/cdc/start:"); + assertChildRouteDocumented(cdcController, openApi, "/stop", "/api/cdc/stop:"); + assertChildRouteDocumented(cdcController, openApi, "/status", "/api/cdc/status:"); + assertChildRouteDocumented(cdcController, openApi, "/sources", "/api/cdc/sources:"); + assertChildRouteDocumented(cdcController, openApi, "/targets", "/api/cdc/targets:"); + } + @Test void asyncApiContractExistsAndDescribesReplayTolerantKafkaCdc() throws IOException { String asyncApi = read("contracts/asyncapi/mightyetl-cdc.yaml"); + String cdcService = read("cdc-service/src/main/java/com/xtrmetl/cdc/service/CdcService.java"); assertTrue(asyncApi.contains("asyncapi: 3.1.0")); assertTrue(asyncApi.contains("kafka")); @@ -56,6 +87,30 @@ void asyncApiContractExistsAndDescribesReplayTolerantKafkaCdc() throws IOExcepti assertTrue(asyncApi.contains("Debezium")); assertTrue(asyncApi.contains("at-least-once")); assertFalse(asyncApi.toLowerCase().contains("exactly-once")); + + assertTrue(cdcService.contains("String topic = changeEvent.destination();")); + assertTrue(cdcService.contains("kafkaTemplate.send(topic, key, value)")); + assertTrue(cdcService.contains("kafkaTemplate.send(topic, value)")); + assertTrue(asyncApi.contains("address: '{destination}'")); + assertTrue(asyncApi.contains("raw Debezium JSON")); + } + + private static void assertSourceRouteDocumented(String source, String openApi, String route) { + assertTrue(source.contains(route), () -> "production source no longer declares route " + route); + assertTrue(openApi.contains(route + ":"), () -> "OpenAPI missing production route " + route); + } + + private static void assertChildRouteDocumented( + String source, + String openApi, + String childRoute, + String openApiRoute + ) { + assertTrue( + source.contains("(\"" + childRoute + "\")"), + () -> "CDC controller no longer declares child route " + childRoute + ); + assertTrue(openApi.contains(openApiRoute), () -> "OpenAPI missing route " + openApiRoute); } private static String read(String relativePath) throws IOException { From e30a15f9ed93bad64d5fa4b5bfe5617aa2fd81b4 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 9 Aug 2026 21:25:15 +0900 Subject: [PATCH 5/8] docs(api): define machine-contract authority and versioning --- contracts/README.md | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) create mode 100644 contracts/README.md diff --git a/contracts/README.md b/contracts/README.md new file mode 100644 index 00000000..d31d6c72 --- /dev/null +++ b/contracts/README.md @@ -0,0 +1,26 @@ +# Machine-readable contracts + +This directory contains release-facing interoperability contracts for behavior implemented on the protected mightyETL integration branch. These files are product interfaces, not aspirational roadmaps. + +## Authority model + +- `openapi/mightyetl.yaml` describes HTTP behavior implemented on protected `develop` only. +- `asyncapi/mightyetl-cdc.yaml` describes the live PostgreSQL Debezium → Kafka publication boundary implemented on protected `develop` only. +- An endpoint, header, lifecycle state, delivery guarantee, authentication mode, connector, or event transformation that exists only on an open pull request must not appear as protected product truth here. +- Source annotations, controller/service behavior, migrations, and executable tests remain the runtime authority. `MachineReadableApiContractTest` binds the checked-in contracts to representative source declarations and fails when material source/contract identity drifts. + +## Versioning + +Contract `info.version` values identify the protected-development contract line, not an independently releasable semantic-version stream. A release must snapshot these contracts from the exact integrated release head and update them whenever a backward-incompatible public API or event contract changes. + +Backward-compatible additions may extend schemas or add operations only after the corresponding implementation is protected-integrated. Breaking changes require an explicit migration/deprecation plan, affected-client analysis, release notes, and the repository's normal independent review and release gates. + +## Failure and delivery claims + +The OpenAPI contract uses RFC 9457 `application/problem+json` only for ETL failures that the current `EtlApiProblemHandler` actually owns. Framework-owned, gateway-owned, and CDC text-error behavior are not silently normalized into that schema. + +The AsyncAPI contract deliberately describes replay-tolerant **at-least-once** CDC behavior. Protected `CdcService` currently forwards raw Debezium JSON to Kafka without waiting for acknowledgement before returning from its event handler. Stronger acknowledgement-before-source-progress behavior belongs to its separately reviewed implementation and must not be claimed here until protected integration proves it. + +## Validation + +Normal repository CI executes the Java contract tests. A future external OpenAPI/AsyncAPI validator may be added only when its version, supply-chain provenance, syntax support, and failure semantics are immutably governed; absence of such a tool is not grounds for weakening the current source-bound contract tests. From 6f92fcc6d51cf15fc1600f994600091977d75257 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 9 Aug 2026 21:40:02 +0900 Subject: [PATCH 6/8] test(api): require documented HTTP Basic 401 contract --- .../MachineReadableApiContractTest.java | 32 +++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/etl-service/src/test/java/com/xtrmetl/etl/documentation/MachineReadableApiContractTest.java b/etl-service/src/test/java/com/xtrmetl/etl/documentation/MachineReadableApiContractTest.java index 4b51688f..2f5606ef 100644 --- a/etl-service/src/test/java/com/xtrmetl/etl/documentation/MachineReadableApiContractTest.java +++ b/etl-service/src/test/java/com/xtrmetl/etl/documentation/MachineReadableApiContractTest.java @@ -8,6 +8,7 @@ import java.nio.file.Path; import java.nio.file.Paths; +import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertTrue; @@ -76,6 +77,27 @@ void openApiRoutesRemainBoundToProtectedControllerSource() throws IOException { assertChildRouteDocumented(cdcController, openApi, "/targets", "/api/cdc/targets:"); } + @Test + void protectedEtlOperationsDeclareHttpBasicAuthenticationFailures() throws IOException { + String openApi = read("contracts/openapi/mightyetl.yaml"); + String securityConfig = read( + "etl-service/src/main/java/com/xtrmetl/etl/security/SecurityConfig.java" + ); + + assertTrue(securityConfig.contains(".requestMatchers(\"/api/**\").authenticated()")); + assertTrue(securityConfig.contains(".httpBasic(Customizer.withDefaults())")); + assertEquals( + 4, + countOccurrences( + openApi, + "'401':\n $ref: '#/components/responses/Unauthorized'" + ), + "Every protected ETL operation must declare the source-backed 401 surface" + ); + assertTrue(openApi.contains(" Unauthorized:\n")); + assertTrue(openApi.contains("WWW-Authenticate:")); + } + @Test void asyncApiContractExistsAndDescribesReplayTolerantKafkaCdc() throws IOException { String asyncApi = read("contracts/asyncapi/mightyetl-cdc.yaml"); @@ -113,6 +135,16 @@ private static void assertChildRouteDocumented( assertTrue(openApi.contains(openApiRoute), () -> "OpenAPI missing route " + openApiRoute); } + private static int countOccurrences(String text, String needle) { + int count = 0; + int position = 0; + while ((position = text.indexOf(needle, position)) >= 0) { + count++; + position += needle.length(); + } + return count; + } + private static String read(String relativePath) throws IOException { return Files.readString(PROJECT_ROOT.resolve(relativePath), StandardCharsets.UTF_8) .replace("\r\n", "\n") From d43c257c044ed3006980317bd8932cb5b22a32ab Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 9 Aug 2026 21:44:40 +0900 Subject: [PATCH 7/8] docs(api): declare protected ETL authentication failures --- contracts/openapi/mightyetl.yaml | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/contracts/openapi/mightyetl.yaml b/contracts/openapi/mightyetl.yaml index c6f6790b..ec0230af 100644 --- a/contracts/openapi/mightyetl.yaml +++ b/contracts/openapi/mightyetl.yaml @@ -45,6 +45,8 @@ paths: type: string '400': $ref: '#/components/responses/Problem' + '401': + $ref: '#/components/responses/Unauthorized' '409': $ref: '#/components/responses/Problem' '413': @@ -84,6 +86,8 @@ paths: additionalProperties: true docs: type: string + '401': + $ref: '#/components/responses/Unauthorized' /api/etl/jobs: post: tags: [durable-jobs] @@ -132,6 +136,8 @@ paths: $ref: '#/components/schemas/EtlJobAcceptedResponse' '400': $ref: '#/components/responses/Problem' + '401': + $ref: '#/components/responses/Unauthorized' '409': $ref: '#/components/responses/Problem' '413': @@ -174,6 +180,8 @@ paths: $ref: '#/components/schemas/EtlJobStatusResponse' '400': $ref: '#/components/responses/Problem' + '401': + $ref: '#/components/responses/Unauthorized' '404': $ref: '#/components/responses/Problem' '500': @@ -274,8 +282,15 @@ components: schema: type: string responses: + Unauthorized: + description: Authentication is required or the supplied HTTP Basic credentials are invalid. + headers: + WWW-Authenticate: + description: HTTP authentication challenge emitted by the Spring Security Basic entry point. + schema: + type: string Problem: - description: Typed ETL failure + description: Typed ETL failure after the request reaches the ETL application error boundary. headers: Cache-Control: description: Problem responses are not to be stored From aa558f25363bfb833e49ed79b49cfdbea39c8eea Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 9 Aug 2026 22:09:19 +0900 Subject: [PATCH 8/8] test(api): bind composed controller routes correctly --- .../MachineReadableApiContractTest.java | 12 ++++-------- 1 file changed, 4 insertions(+), 8 deletions(-) diff --git a/etl-service/src/test/java/com/xtrmetl/etl/documentation/MachineReadableApiContractTest.java b/etl-service/src/test/java/com/xtrmetl/etl/documentation/MachineReadableApiContractTest.java index 2f5606ef..6c95052e 100644 --- a/etl-service/src/test/java/com/xtrmetl/etl/documentation/MachineReadableApiContractTest.java +++ b/etl-service/src/test/java/com/xtrmetl/etl/documentation/MachineReadableApiContractTest.java @@ -60,8 +60,9 @@ void openApiRoutesRemainBoundToProtectedControllerSource() throws IOException { "cdc-service/src/main/java/com/xtrmetl/cdc/controller/CdcController.java" ); - assertSourceRouteDocumented(etlController, openApi, "/api/etl/process"); - assertSourceRouteDocumented(etlController, openApi, "/api/etl/connectors"); + assertTrue(etlController.contains("@RequestMapping(\"/api/etl\")")); + assertChildRouteDocumented(etlController, openApi, "/process", "/api/etl/process:"); + assertChildRouteDocumented(etlController, openApi, "/connectors", "/api/etl/connectors:"); assertTrue(jobController.contains("@RequestMapping(\"/api/etl/jobs\")")); assertTrue(jobController.contains("@PostMapping")); @@ -117,11 +118,6 @@ void asyncApiContractExistsAndDescribesReplayTolerantKafkaCdc() throws IOExcepti assertTrue(asyncApi.contains("raw Debezium JSON")); } - private static void assertSourceRouteDocumented(String source, String openApi, String route) { - assertTrue(source.contains(route), () -> "production source no longer declares route " + route); - assertTrue(openApi.contains(route + ":"), () -> "OpenAPI missing production route " + route); - } - private static void assertChildRouteDocumented( String source, String openApi, @@ -130,7 +126,7 @@ private static void assertChildRouteDocumented( ) { assertTrue( source.contains("(\"" + childRoute + "\")"), - () -> "CDC controller no longer declares child route " + childRoute + () -> "controller no longer declares child route " + childRoute ); assertTrue(openApi.contains(openApiRoute), () -> "OpenAPI missing route " + openApiRoute); }