Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
name: Example - API-Only TranscriberJ Hand-Written Contract Tests Build
on:
push:
paths:
- 'examples/api-only-transcriberj/hand-written-contract-tests/**/*.java'
- 'examples/api-only-transcriberj/hand-written-contract-tests/**/*.yaml'
- 'examples/api-only-transcriberj/hand-written-contract-tests/**/*.gradle'
- 'examples/api-only-transcriberj/hand-written-contract-tests/**/*.properties'
- 'examples/api-only-transcriberj/hand-written-contract-tests/**/libs.versions.toml'
- 'examples/api-only-transcriberj/hand-written-contract-tests/package.json'
- 'examples/api-only-transcriberj/hand-written-contract-tests/package-lock.json'
- '.github/workflows/example-api-only-transcriberj-hand-written-contract-tests-build.yml'

permissions:
contents: read

jobs:
Build-Example:
name: Build Example
runs-on: ubuntu-latest

steps:
- name: Checkout
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
fetch-depth: 1
persist-credentials: false

- name: Setup Java
uses: actions/setup-java@ad2b38190b15e4d6bdf0c97fb4fca8412226d287 # v5.3.0
with:
distribution: 'temurin'
java-version: 21

# The example builds its contract with the API-Only Publisher, which is a Node.js tool.
- name: Setup Node.js
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version: 24

- name: Setup Gradle
uses: gradle/actions/setup-gradle@3f131e8634966bd73d06cc69884922b02e6faf92 # v6.2.0

- name: Build example
run: |
cd examples/api-only-transcriberj/hand-written-contract-tests
chmod +x ./gradlew
./gradlew check --no-daemon
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
node_modules/
build/
.gradle/
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
extends:
- recommended
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
= Example: contract tests the generator cannot write, on top of the classes it does
:toc: left
:toc-title: Contents
:icons: font
:source-highlighter: rouge

A small Spring Boot service, and the contract tests of its one endpoint, built on the classes the API-Only TranscriberJ generates from its contract -- including where the generator stops.

The request body is a `oneOf` whose branches are written inline, so the generator cannot write it: `RefundRequestV1.body(...)` throws.
A refund must also go back the way the order was paid, a rule the schema cannot express, so a request can be valid against the schema and still be refused with a 422.
The tests write the request by hand, from the generated pieces that are still there, and check every response against the generated REST Docs descriptors.

The guide that walks through it is https://github.com/Arc-E-Tect/SoftwareEngineeringDoneRight-API/blob/main/docs/guides/hand-written-contract-tests/tutorial.adoc[the hand-written contract tests tutorial ↗]; the reference is https://github.com/Arc-E-Tect/SoftwareEngineeringDoneRight-API/blob/main/api-only-transcriberj/README.adoc#unrepresentable[the TranscriberJ's README ↗].

== What is in it

[cols="2,3", options="header"]
|===
|File |What it is

|`src/main/api/openapi/`
|The `refunds` contract: `POST /orders/{orderId}/refunds`, answered with 201, 400, 404 or 422.
`RefundRequestV1` is the `oneOf` the generator cannot write.

|`src/main/java/com/example/refunds/`
|The service, written by hand.
It never imports a generated class.

|`src/test/java/com/example/contract/RefundRequests.java`
|The request body, written by hand, in the generated package, from the generated constants.

|`src/test/java/com/example/contract/RefundRequestsTest.java`
|What keeps `RefundRequests` honest as the contract moves.

|`src/test/java/com/example/refunds/CreateRefundContractTest.java`
|The contract tests: one per response the contract lists, each checking the response's shape.

|`src/test/java/com/example/refunds/RefundsTest.java`
|The behaviour: every way a request can be invalid, and every business rule, as scenarios.
|===

[IMPORTANT]
.Hand-written test code lives in the generated package, and owns what it types
====
`RefundRequests` is in `com.example.contract`, the generated package, in the test source set.
That is the TranscriberJ's design: `ContractJson` and the scalar components it draws on, such as `AmountV1`, are package-private.
They are regenerated on every build and are not a published API, so an upgrade may change them.

`RefundRequests` also types what no generated class holds: the member names of the inline branches, and the `destination` values `card` and `bank-account`.
Keeping those in line with the contract is this project's responsibility; `RefundRequestsTest` pins the class to the request schema's hash, which is the only thing that notices when one of them changes.
https://github.com/Arc-E-Tect/SoftwareEngineeringDoneRight-API/blob/main/api-only-transcriberj/README.adoc#hand-written-code[Hand-written code beside the generated classes ↗] is the reference.
====

== Run it

You need Java 21 and Node.js (`^22.14.0`, `^24.10.0` or `>=26.0.0`).

[source,console]
----
./gradlew check
----

The build installs the Publisher, builds and publishes the contract, generates the classes, runs the tests, and checks the service's coverage.

== What to look at

`build/reports/transcriberj/refunds.txt`::
The two methods the generator could not write, why, and the remedy.

`build/generated/sources/transcriberj/refunds/com/example/contract/RefundRequestV1.java`::
What is still there when `body(...)` throws: the provenance, and the fragment's hash.

`build/generated-snippets/`::
The REST Docs snippets of the four responses, from the generated descriptors.

`build/reports/jacoco/test/html/index.html`::
The service's coverage; the generated classes are excluded by their annotation.

== Try it

* Remove `maximum` from `AmountV1.yaml` and raise the contract's version in `refunds.bundle.properties` and `gradle.properties`.
The tests no longer compile: `RefundRequests` and `RefundRequestsTest` use `AmountV1.MAXIMUM`, which is gone.
* Put the `maximum` back, rename `cardLast4` to `lastFourDigits` in `RefundRequestV1.yaml`, and raise the version again.
Everything compiles, and every contract test still passes, since the service is unchanged; only `theHandWrittenBodyWasWrittenAgainstTheRequestSchemaAsItIsNow` fails, and says what to do:
+
[source,text]
----
[openapi/components/schemas/RefundRequestV1.yaml changed; check RefundRequests against it, then update WRITTEN_AGAINST]
----
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
# apionly.lock -- the published contracts this project builds against.
# Generated by the Arc-E-Tect API-Only Subscriber. Commit it; do not edit it.
# Run `verifyApiSpec` to check that what is on disk still matches.
format 1

target refunds
version 1.0.0
channel file
sha256 bf47b56e864e88a77e945985f730d108532fd29d19b610aa60594e688a0d7d01 openapi.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
schemaVersion: 1

sources:
root: src/main/api
openapi: openapi

defaults:
openapi:
lint: .redocly.yaml
outputName: openapi.yaml

build:
staging: build/api-only/staging
dist: build/api-only/dist

reports:
lint: build/api-only/reports/lint

toolchain:
redocly: "@redocly/cli@2.52.0"

channels:
file:
directory: build/api-only/publish
clean: true

targets:
refunds:
openapi:
bundle: bundles/refunds.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
import org.springframework.boot.gradle.plugin.SpringBootPlugin

plugins {
id 'java'
id 'jacoco'
alias(libs.plugins.spring.boot)
alias(libs.plugins.api.only.transcriberj)
}

java {
toolchain {
languageVersion = JavaLanguageVersion.of(21)
}
}

repositories {
mavenCentral()
}

dependencies {
// The service: hand-written, and it never imports the generated classes.
implementation platform(SpringBootPlugin.BOM_COORDINATES)
implementation 'org.springframework.boot:spring-boot-starter-webmvc'

// The tests: the generated classes, their REST Docs companions, and MockMvc.
transcriberjEmitters libs.api.only.transcriberj.restdocs
testImplementation platform(SpringBootPlugin.BOM_COORDINATES)
testImplementation 'org.springframework.boot:spring-boot-starter-webmvc-test'
testImplementation 'org.springframework.boot:spring-boot-restdocs'
testImplementation 'org.springframework.restdocs:spring-restdocs-mockmvc'
testRuntimeOnly 'org.junit.platform:junit-platform-launcher'
}

tasks.named('test') {
useJUnitPlatform()
finalizedBy 'jacocoTestReport'
}

tasks.named('jacocoTestReport') {
reports {
xml.required = true
html.required = true
}
}

// The service's own code is measured; the generated tree is excluded by its annotation.
tasks.named('jacocoTestCoverageVerification') {
violationRules {
rule {
limit {
minimum = 0.90
}
}
}
}

tasks.named('check') { dependsOn 'jacocoTestCoverageVerification' }

// ---------------------------------------------------------------------------
// The contract. The Publisher builds it into build/, and the Subscriber, which
// the TranscriberJ applies, fetches it from there.
// ---------------------------------------------------------------------------

def npm = System.getProperty('os.name').toLowerCase().contains('windows') ? 'npm.cmd' : 'npm'

def installApiOnlyPublisher = tasks.register('installApiOnlyPublisher', Exec) {
group = 'api-only'
description = 'Installs the API-Only Publisher at the version package-lock.json pins.'
inputs.files('package.json', 'package-lock.json').withPropertyName('manifests')
outputs.file('node_modules/.package-lock.json').withPropertyName('installation')
commandLine npm, 'ci', '--no-audit', '--no-fund'
}

def buildApiContract = tasks.register('buildApiContract', Exec) {
group = 'api-only'
description = 'Bundles, stamps and lints the refunds contract.'
dependsOn installApiOnlyPublisher
inputs.dir('src/main/api').withPropertyName('specification')
inputs.files('apionly.yaml', 'package-lock.json').withPropertyName('configuration')
outputs.dir('build/api-only/dist/refunds').withPropertyName('bundle')
commandLine npm, 'run', '--silent', 'apionly', '--', 'build', '--target', 'refunds'
}

def publishApiContract = tasks.register('publishApiContract', Exec) {
group = 'api-only'
description = 'Publishes the refunds contract to the file channel in build/api-only/publish.'
dependsOn buildApiContract
inputs.dir('build/api-only/dist/refunds').withPropertyName('bundle')
outputs.dir('build/api-only/publish/refunds').withPropertyName('archive')
commandLine npm, 'run', '--silent', 'apionly', '--', 'publish', '--target', 'refunds', '--channel', 'file'
}

apiOnlySubscriber {
channel {
type = 'file'
directory = file('build/api-only/publish').path
}
subscribe('refunds')
}

tasks.named('fetchApiSpecRefunds') { dependsOn publishApiContract }

// ---------------------------------------------------------------------------
// The generated classes, for the tests only.
// ---------------------------------------------------------------------------

apiOnlyTranscriberJ {
subscription('refunds') {
basePackage = 'com.example.contract'
// The REST Docs field tables show the contract's descriptions.
generateDocs = true
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
# The version of the refunds contract this project implements.
apiContractVersion=1.0.0
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
[versions]
api-only-transcriberj = "0.3.3"
api-only-transcriberj-restdocs = "0.1.1"
spring-boot = "4.1.1"

[libraries]
api-only-transcriberj-restdocs = { module = "com.arc-e-tect:api-only-transcriberj-restdocs", version.ref = "api-only-transcriberj-restdocs" }

[plugins]
api-only-transcriberj = { id = "com.arc-e-tect.api-only-transcriberj", version.ref = "api-only-transcriberj" }
spring-boot = { id = "org.springframework.boot", version.ref = "spring-boot" }
Binary file not shown.
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-9.7.1-bin.zip
networkTimeout=10000
retries=0
retryBackOffMs=500
validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists
Loading
Loading