diff --git a/.github/workflows/example-api-only-transcriberj-hand-written-contract-tests-build.yml b/.github/workflows/example-api-only-transcriberj-hand-written-contract-tests-build.yml new file mode 100644 index 0000000..475e36a --- /dev/null +++ b/.github/workflows/example-api-only-transcriberj-hand-written-contract-tests-build.yml @@ -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 diff --git a/examples/api-only-transcriberj/hand-written-contract-tests/.gitignore b/examples/api-only-transcriberj/hand-written-contract-tests/.gitignore new file mode 100644 index 0000000..1facb38 --- /dev/null +++ b/examples/api-only-transcriberj/hand-written-contract-tests/.gitignore @@ -0,0 +1,3 @@ +node_modules/ +build/ +.gradle/ diff --git a/examples/api-only-transcriberj/hand-written-contract-tests/.redocly.yaml b/examples/api-only-transcriberj/hand-written-contract-tests/.redocly.yaml new file mode 100644 index 0000000..c2373f5 --- /dev/null +++ b/examples/api-only-transcriberj/hand-written-contract-tests/.redocly.yaml @@ -0,0 +1,2 @@ +extends: + - recommended diff --git a/examples/api-only-transcriberj/hand-written-contract-tests/README.adoc b/examples/api-only-transcriberj/hand-written-contract-tests/README.adoc new file mode 100644 index 0000000..caf824e --- /dev/null +++ b/examples/api-only-transcriberj/hand-written-contract-tests/README.adoc @@ -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] +---- diff --git a/examples/api-only-transcriberj/hand-written-contract-tests/apionly.lock b/examples/api-only-transcriberj/hand-written-contract-tests/apionly.lock new file mode 100644 index 0000000..00f046b --- /dev/null +++ b/examples/api-only-transcriberj/hand-written-contract-tests/apionly.lock @@ -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 diff --git a/examples/api-only-transcriberj/hand-written-contract-tests/apionly.yaml b/examples/api-only-transcriberj/hand-written-contract-tests/apionly.yaml new file mode 100644 index 0000000..bc6dbd5 --- /dev/null +++ b/examples/api-only-transcriberj/hand-written-contract-tests/apionly.yaml @@ -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 diff --git a/examples/api-only-transcriberj/hand-written-contract-tests/build.gradle b/examples/api-only-transcriberj/hand-written-contract-tests/build.gradle new file mode 100644 index 0000000..514b179 --- /dev/null +++ b/examples/api-only-transcriberj/hand-written-contract-tests/build.gradle @@ -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 + } +} diff --git a/examples/api-only-transcriberj/hand-written-contract-tests/gradle.properties b/examples/api-only-transcriberj/hand-written-contract-tests/gradle.properties new file mode 100644 index 0000000..07ea8dc --- /dev/null +++ b/examples/api-only-transcriberj/hand-written-contract-tests/gradle.properties @@ -0,0 +1,2 @@ +# The version of the refunds contract this project implements. +apiContractVersion=1.0.0 diff --git a/examples/api-only-transcriberj/hand-written-contract-tests/gradle/libs.versions.toml b/examples/api-only-transcriberj/hand-written-contract-tests/gradle/libs.versions.toml new file mode 100644 index 0000000..3072f6f --- /dev/null +++ b/examples/api-only-transcriberj/hand-written-contract-tests/gradle/libs.versions.toml @@ -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" } diff --git a/examples/api-only-transcriberj/hand-written-contract-tests/gradle/wrapper/gradle-wrapper.jar b/examples/api-only-transcriberj/hand-written-contract-tests/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000..eddabd2 Binary files /dev/null and b/examples/api-only-transcriberj/hand-written-contract-tests/gradle/wrapper/gradle-wrapper.jar differ diff --git a/examples/api-only-transcriberj/hand-written-contract-tests/gradle/wrapper/gradle-wrapper.properties b/examples/api-only-transcriberj/hand-written-contract-tests/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..ad7845b --- /dev/null +++ b/examples/api-only-transcriberj/hand-written-contract-tests/gradle/wrapper/gradle-wrapper.properties @@ -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 diff --git a/examples/api-only-transcriberj/hand-written-contract-tests/gradlew b/examples/api-only-transcriberj/hand-written-contract-tests/gradlew new file mode 100755 index 0000000..249efbb --- /dev/null +++ b/examples/api-only-transcriberj/hand-written-contract-tests/gradlew @@ -0,0 +1,248 @@ +#!/bin/sh + +# +# Copyright © 2015 the original authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +# SPDX-License-Identifier: Apache-2.0 +# + +############################################################################## +# +# gradlew start up script for POSIX generated by Gradle. +# +# Important for running: +# +# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is +# noncompliant, but you have some other compliant shell such as ksh or +# bash, then to run this script, type that shell name before the whole +# command line, like: +# +# ksh gradlew +# +# Busybox and similar reduced shells will NOT work, because this script +# requires all of these POSIX shell features: +# * functions; +# * expansions «$var», «${var}», «${var:-default}», «${var+SET}», +# «${var#prefix}», «${var%suffix}», and «$( cmd )»; +# * compound commands having a testable exit status, especially «case»; +# * various built-in commands including «command», «set», and «ulimit». +# +# Important for patching: +# +# (2) This script targets any POSIX shell, so it avoids extensions provided +# by Bash, Ksh, etc; in particular arrays are avoided. +# +# The "traditional" practice of packing multiple parameters into a +# space-separated string is a well documented source of bugs and security +# problems, so this is (mostly) avoided, by progressively accumulating +# options in "$@", and eventually passing that to Java. +# +# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, +# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; +# see the in-line comments for details. +# +# There are tweaks for specific operating systems such as AIX, CygWin, +# Darwin, MinGW, and NonStop. +# +# (3) This script is generated from the Groovy template +# https://github.com/gradle/gradle/blob/3d91ce3b8caaf77ad09f381f43615b715b53f72c/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# within the Gradle project. +# +# You can find Gradle at https://github.com/gradle/gradle/. +# +############################################################################## + +# Attempt to set APP_HOME + +# Resolve links: $0 may be a link +app_path=$0 + +# Need this for daisy-chained symlinks. +while + APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path + [ -h "$app_path" ] +do + ls=$( ls -ld "$app_path" ) + link=${ls#*' -> '} + case $link in #( + /*) app_path=$link ;; #( + *) app_path=$APP_HOME$link ;; + esac +done + +# This is normally unused +# shellcheck disable=SC2034 +APP_BASE_NAME=${0##*/} +# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) +APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD=maximum + +warn () { + echo "$*" +} >&2 + +die () { + echo + echo "$*" + echo + exit 1 +} >&2 + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "$( uname )" in #( + CYGWIN* ) cygwin=true ;; #( + Darwin* ) darwin=true ;; #( + MSYS* | MINGW* ) msys=true ;; #( + NONSTOP* ) nonstop=true ;; +esac + + + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD=$JAVA_HOME/jre/sh/java + else + JAVACMD=$JAVA_HOME/bin/java + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD=java + if ! command -v java >/dev/null 2>&1 + then + die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +fi + +# Increase the maximum file descriptors if we can. +if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then + case $MAX_FD in #( + max*) + # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + MAX_FD=$( ulimit -H -n ) || + warn "Could not query maximum file descriptor limit" + esac + case $MAX_FD in #( + '' | soft) :;; #( + *) + # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + ulimit -n "$MAX_FD" || + warn "Could not set maximum file descriptor limit to $MAX_FD" + esac +fi + +# Collect all arguments for the java command, stacking in reverse order: +# * args from the command line +# * the main class name +# * -classpath +# * -D...appname settings +# * --module-path (only if needed) +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. + +# For Cygwin or MSYS, switch paths to Windows format before running java +if "$cygwin" || "$msys" ; then + APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) + + JAVACMD=$( cygpath --unix "$JAVACMD" ) + + # Now convert the arguments - kludge to limit ourselves to /bin/sh + for arg do + if + case $arg in #( + -*) false ;; # don't mess with options #( + /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath + [ -e "$t" ] ;; #( + *) false ;; + esac + then + arg=$( cygpath --path --ignore --mixed "$arg" ) + fi + # Roll the args list around exactly as many times as the number of + # args, so each arg winds up back in the position where it started, but + # possibly modified. + # + # NB: a `for` loop captures its iteration list before it begins, so + # changing the positional parameters here affects neither the number of + # iterations, nor the values presented in `arg`. + shift # remove old arg + set -- "$@" "$arg" # push replacement arg + done +fi + + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Collect all arguments for the java command: +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, +# and any embedded shellness will be escaped. +# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be +# treated as '${Hostname}' itself on the command line. + +set -- \ + "-Dorg.gradle.appname=$APP_BASE_NAME" \ + -jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \ + "$@" + +# Stop when "xargs" is not available. +if ! command -v xargs >/dev/null 2>&1 +then + die "xargs is not available" +fi + +# Use "xargs" to parse quoted args. +# +# With -n1 it outputs one arg per line, with the quotes and backslashes removed. +# +# In Bash we could simply go: +# +# readarray ARGS < <( xargs -n1 <<<"$var" ) && +# set -- "${ARGS[@]}" "$@" +# +# but POSIX shell has neither arrays nor command substitution, so instead we +# post-process each arg (as a line of input to sed) to backslash-escape any +# character that might be a shell metacharacter, then use eval to reverse +# that process (while maintaining the separation between arguments), and wrap +# the whole thing up as a single "set" statement. +# +# This will of course break if any of these variables contains a newline or +# an unmatched quote. +# + +eval "set -- $( + printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | + xargs -n1 | + sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | + tr '\n' ' ' + )" '"$@"' + +exec "$JAVACMD" "$@" diff --git a/examples/api-only-transcriberj/hand-written-contract-tests/gradlew.bat b/examples/api-only-transcriberj/hand-written-contract-tests/gradlew.bat new file mode 100644 index 0000000..a51ec4f --- /dev/null +++ b/examples/api-only-transcriberj/hand-written-contract-tests/gradlew.bat @@ -0,0 +1,82 @@ +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem +@rem SPDX-License-Identifier: Apache-2.0 +@rem + +@if "%DEBUG%"=="" @echo off +@rem ########################################################################## +@rem +@rem gradlew startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables, and ensure extensions are enabled +setlocal EnableExtensions + +set DIRNAME=%~dp0 +if "%DIRNAME%"=="" set DIRNAME=. +@rem This is normally unused +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if %ERRORLEVEL% equ 0 goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +"%COMSPEC%" /c exit 1 + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +"%COMSPEC%" /c exit 1 + +:execute +@rem Setup the command line + + + +@rem Execute gradlew +@rem endlocal doesn't take effect until after the line is parsed and variables are expanded +@rem which allows us to clear the local environment before executing the java command +endlocal & "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* & call :exitWithErrorLevel + +:exitWithErrorLevel +@rem Use "%COMSPEC%" /c exit to allow operators to work properly in scripts +"%COMSPEC%" /c exit %ERRORLEVEL% diff --git a/examples/api-only-transcriberj/hand-written-contract-tests/package-lock.json b/examples/api-only-transcriberj/hand-written-contract-tests/package-lock.json new file mode 100644 index 0000000..8d4b139 --- /dev/null +++ b/examples/api-only-transcriberj/hand-written-contract-tests/package-lock.json @@ -0,0 +1,45 @@ +{ + "name": "api-only-transcriberj-hand-written-contract-tests", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "api-only-transcriberj-hand-written-contract-tests", + "devDependencies": { + "@arc-e-tect/api-only-publisher": "0.5.0" + } + }, + "node_modules/@arc-e-tect/api-only-publisher": { + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/@arc-e-tect/api-only-publisher/-/api-only-publisher-0.5.0.tgz", + "integrity": "sha512-VKJzWTlm9TvquiS+9F7ipwFcP6dIknVyZfNHsw5qqpzau/iLHbuPipYdGXyFrPJUk2B9Uf0hZkVoP3IKyrkqJg==", + "dev": true, + "license": "MIT", + "dependencies": { + "yaml": "2.9.1" + }, + "bin": { + "api-only-publisher": "src/cli.js" + }, + "engines": { + "node": "^22.14.0 || ^24.10.0 || >=26.0.0" + } + }, + "node_modules/yaml": { + "version": "2.9.1", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.9.1.tgz", + "integrity": "sha512-3NxN8+78OdzbT7C/WjGsyfPAtJaN3FNDsWxv7Y7mcDsT/oOmgW8BpyQQFFBnvZE3j9Y2Sdz1ULFLezL7Eb2yFw==", + "dev": true, + "license": "ISC", + "bin": { + "yaml": "bin.mjs" + }, + "engines": { + "node": ">= 14.6" + }, + "funding": { + "url": "https://github.com/sponsors/eemeli" + } + } + } +} diff --git a/examples/api-only-transcriberj/hand-written-contract-tests/package.json b/examples/api-only-transcriberj/hand-written-contract-tests/package.json new file mode 100644 index 0000000..5f55f17 --- /dev/null +++ b/examples/api-only-transcriberj/hand-written-contract-tests/package.json @@ -0,0 +1,10 @@ +{ + "name": "api-only-transcriberj-hand-written-contract-tests", + "private": true, + "scripts": { + "apionly": "api-only-publisher" + }, + "devDependencies": { + "@arc-e-tect/api-only-publisher": "0.5.0" + } +} diff --git a/examples/api-only-transcriberj/hand-written-contract-tests/settings.gradle b/examples/api-only-transcriberj/hand-written-contract-tests/settings.gradle new file mode 100644 index 0000000..b75222f --- /dev/null +++ b/examples/api-only-transcriberj/hand-written-contract-tests/settings.gradle @@ -0,0 +1,11 @@ +pluginManagement { + repositories { + gradlePluginPortal() + } +} + +plugins { + id 'org.gradle.toolchains.foojay-resolver-convention' version '1.0.0' +} + +rootProject.name = 'api-only-transcriberj-hand-written-contract-tests' diff --git a/examples/api-only-transcriberj/hand-written-contract-tests/src/main/api/openapi/bundles/refunds.bundle.properties b/examples/api-only-transcriberj/hand-written-contract-tests/src/main/api/openapi/bundles/refunds.bundle.properties new file mode 100644 index 0000000..d5249aa --- /dev/null +++ b/examples/api-only-transcriberj/hand-written-contract-tests/src/main/api/openapi/bundles/refunds.bundle.properties @@ -0,0 +1,2 @@ +# The version of the refunds contract. +version=1.0.0 diff --git a/examples/api-only-transcriberj/hand-written-contract-tests/src/main/api/openapi/bundles/refunds.yaml b/examples/api-only-transcriberj/hand-written-contract-tests/src/main/api/openapi/bundles/refunds.yaml new file mode 100644 index 0000000..e16ff8f --- /dev/null +++ b/examples/api-only-transcriberj/hand-written-contract-tests/src/main/api/openapi/bundles/refunds.yaml @@ -0,0 +1,13 @@ +openapi: 3.1.1 +info: + title: Refunds API + version: 0.0.0 + license: + name: MIT + identifier: MIT +servers: + - url: https://api.refunds.test +security: [] +paths: + /orders/{orderId}/refunds: + $ref: ../paths/Refunds.yaml diff --git a/examples/api-only-transcriberj/hand-written-contract-tests/src/main/api/openapi/components/schemas/AmountV1.yaml b/examples/api-only-transcriberj/hand-written-contract-tests/src/main/api/openapi/components/schemas/AmountV1.yaml new file mode 100644 index 0000000..1d9e09c --- /dev/null +++ b/examples/api-only-transcriberj/hand-written-contract-tests/src/main/api/openapi/components/schemas/AmountV1.yaml @@ -0,0 +1,4 @@ +type: integer +description: An amount of money, in cents. +minimum: 1 +maximum: 1000000 diff --git a/examples/api-only-transcriberj/hand-written-contract-tests/src/main/api/openapi/components/schemas/CardLast4V1.yaml b/examples/api-only-transcriberj/hand-written-contract-tests/src/main/api/openapi/components/schemas/CardLast4V1.yaml new file mode 100644 index 0000000..80b5203 --- /dev/null +++ b/examples/api-only-transcriberj/hand-written-contract-tests/src/main/api/openapi/components/schemas/CardLast4V1.yaml @@ -0,0 +1,3 @@ +type: string +description: The last four digits of a card number. +pattern: '^[0-9]{4}$' diff --git a/examples/api-only-transcriberj/hand-written-contract-tests/src/main/api/openapi/components/schemas/IbanV1.yaml b/examples/api-only-transcriberj/hand-written-contract-tests/src/main/api/openapi/components/schemas/IbanV1.yaml new file mode 100644 index 0000000..73d38de --- /dev/null +++ b/examples/api-only-transcriberj/hand-written-contract-tests/src/main/api/openapi/components/schemas/IbanV1.yaml @@ -0,0 +1,3 @@ +type: string +description: An international bank account number, without spaces. +pattern: '^[A-Z]{2}[0-9]{2}[A-Z0-9]{11,30}$' diff --git a/examples/api-only-transcriberj/hand-written-contract-tests/src/main/api/openapi/components/schemas/ProblemV1.yaml b/examples/api-only-transcriberj/hand-written-contract-tests/src/main/api/openapi/components/schemas/ProblemV1.yaml new file mode 100644 index 0000000..7f9f9d8 --- /dev/null +++ b/examples/api-only-transcriberj/hand-written-contract-tests/src/main/api/openapi/components/schemas/ProblemV1.yaml @@ -0,0 +1,30 @@ +type: object +description: What went wrong with a request, as RFC 9457 describes it. +required: + - title + - status +properties: + title: + type: string + description: A short summary of the problem. + status: + type: integer + description: The HTTP status code. + detail: + type: string + description: What went wrong with this request. + errors: + type: array + description: The individual problems, one per field. + items: + type: object + required: + - field + - message + properties: + field: + type: string + description: The field that was wrong. + message: + type: string + description: What was wrong with it. diff --git a/examples/api-only-transcriberj/hand-written-contract-tests/src/main/api/openapi/components/schemas/RefundRequestV1.yaml b/examples/api-only-transcriberj/hand-written-contract-tests/src/main/api/openapi/components/schemas/RefundRequestV1.yaml new file mode 100644 index 0000000..7aea1d5 --- /dev/null +++ b/examples/api-only-transcriberj/hand-written-contract-tests/src/main/api/openapi/components/schemas/RefundRequestV1.yaml @@ -0,0 +1,29 @@ +type: object +description: Where a refund goes, and how much of the order it returns. +oneOf: + - type: object + description: A refund to the card the order was paid with. + required: + - destination + - amount + - cardLast4 + properties: + destination: + const: card + amount: + $ref: AmountV1.yaml + cardLast4: + $ref: CardLast4V1.yaml + - type: object + description: A refund to a bank account, for an order paid by bank transfer. + required: + - destination + - amount + - iban + properties: + destination: + const: bank-account + amount: + $ref: AmountV1.yaml + iban: + $ref: IbanV1.yaml diff --git a/examples/api-only-transcriberj/hand-written-contract-tests/src/main/api/openapi/components/schemas/RefundV1.yaml b/examples/api-only-transcriberj/hand-written-contract-tests/src/main/api/openapi/components/schemas/RefundV1.yaml new file mode 100644 index 0000000..cbeb7a1 --- /dev/null +++ b/examples/api-only-transcriberj/hand-written-contract-tests/src/main/api/openapi/components/schemas/RefundV1.yaml @@ -0,0 +1,19 @@ +type: object +description: A refund the shop accepted. +required: + - id + - orderId + - amount + - destination +properties: + id: + type: string + description: The refund's identifier. + orderId: + type: string + description: The order the refund is for. + amount: + $ref: AmountV1.yaml + destination: + type: string + description: Where the money goes, `card` or `bank-account`. diff --git a/examples/api-only-transcriberj/hand-written-contract-tests/src/main/api/openapi/paths/Refunds.yaml b/examples/api-only-transcriberj/hand-written-contract-tests/src/main/api/openapi/paths/Refunds.yaml new file mode 100644 index 0000000..bae71eb --- /dev/null +++ b/examples/api-only-transcriberj/hand-written-contract-tests/src/main/api/openapi/paths/Refunds.yaml @@ -0,0 +1,45 @@ +post: + operationId: createRefund + summary: Refund part or all of an order. + description: >- + A refund goes back the way the order was paid, and never exceeds what is + left of the order to refund. Neither rule can be written in the schema; a + request that breaks one is answered with 422. + parameters: + - name: orderId + in: path + required: true + description: The order's identifier. + schema: + type: string + requestBody: + required: true + content: + application/json: + schema: + $ref: ../components/schemas/RefundRequestV1.yaml + responses: + '201': + description: The refund was accepted. + content: + application/json: + schema: + $ref: ../components/schemas/RefundV1.yaml + '400': + description: The request does not match the contract. + content: + application/problem+json: + schema: + $ref: ../components/schemas/ProblemV1.yaml + '404': + description: No order has that identifier. + content: + application/problem+json: + schema: + $ref: ../components/schemas/ProblemV1.yaml + '422': + description: The request matches the contract, but breaks a business rule. + content: + application/problem+json: + schema: + $ref: ../components/schemas/ProblemV1.yaml diff --git a/examples/api-only-transcriberj/hand-written-contract-tests/src/main/java/com/example/refunds/FieldError.java b/examples/api-only-transcriberj/hand-written-contract-tests/src/main/java/com/example/refunds/FieldError.java new file mode 100644 index 0000000..eef823c --- /dev/null +++ b/examples/api-only-transcriberj/hand-written-contract-tests/src/main/java/com/example/refunds/FieldError.java @@ -0,0 +1,10 @@ +package com.example.refunds; + +/** + * What is wrong with one member of a request. + * + * @param field the member + * @param message what is wrong with it + */ +public record FieldError(String field, String message) { +} diff --git a/examples/api-only-transcriberj/hand-written-contract-tests/src/main/java/com/example/refunds/Orders.java b/examples/api-only-transcriberj/hand-written-contract-tests/src/main/java/com/example/refunds/Orders.java new file mode 100644 index 0000000..67217e4 --- /dev/null +++ b/examples/api-only-transcriberj/hand-written-contract-tests/src/main/java/com/example/refunds/Orders.java @@ -0,0 +1,66 @@ +package com.example.refunds; + +import org.springframework.stereotype.Component; + +import java.util.Map; +import java.util.Optional; +import java.util.concurrent.ConcurrentHashMap; + +/** + * The orders a refund can be for, and how much of each is refunded so far. + * Two orders, in memory: enough for an example. + */ +@Component +public class Orders { + + /** An order of 5000 cents, paid by card. */ + public static final String PAID_BY_CARD = "order-paid-by-card"; + + /** An order of 5000 cents, paid by bank transfer. */ + public static final String PAID_BY_BANK_TRANSFER = "order-paid-by-bank-transfer"; + + /** + * An order. + * + * @param id its identifier + * @param paidWith how it was paid: {@code card} or {@code bank-account} + * @param total what it cost, in cents + * @param refunded how much of that is refunded so far + */ + public record Order(String id, String paidWith, int total, int refunded) { + + /** + * What is left to refund. + * + * @return the amount, in cents + */ + public int left() { + return total - refunded; + } + } + + private final Map orders = new ConcurrentHashMap<>(Map.of( + PAID_BY_CARD, new Order(PAID_BY_CARD, "card", 5000, 0), + PAID_BY_BANK_TRANSFER, new Order(PAID_BY_BANK_TRANSFER, "bank-account", 5000, 0))); + + /** + * An order, if there is one with that identifier. + * + * @param id the identifier + * @return the order + */ + public Optional find(String id) { + return Optional.ofNullable(orders.get(id)); + } + + /** + * Records a refund against an order. + * + * @param id the order's identifier + * @param amount the amount refunded, in cents + */ + public void refund(String id, int amount) { + orders.computeIfPresent(id, (key, order) -> + new Order(order.id(), order.paidWith(), order.total(), order.refunded() + amount)); + } +} diff --git a/examples/api-only-transcriberj/hand-written-contract-tests/src/main/java/com/example/refunds/Outcome.java b/examples/api-only-transcriberj/hand-written-contract-tests/src/main/java/com/example/refunds/Outcome.java new file mode 100644 index 0000000..63cd360 --- /dev/null +++ b/examples/api-only-transcriberj/hand-written-contract-tests/src/main/java/com/example/refunds/Outcome.java @@ -0,0 +1,37 @@ +package com.example.refunds; + +import java.util.List; + +/** + * What became of a refund request. + */ +public sealed interface Outcome { + + /** + * The refund was accepted. + * + * @param refund the refund + */ + record Accepted(Refund refund) implements Outcome { + } + + /** + * The request is not one the contract allows. + * + * @param errors what is wrong with it, member by member + */ + record Invalid(List errors) implements Outcome { + } + + /** No order has the identifier the request names. */ + record UnknownOrder() implements Outcome { + } + + /** + * The request is one the contract allows, and it breaks a business rule. + * + * @param detail which rule, and how + */ + record Rejected(String detail) implements Outcome { + } +} diff --git a/examples/api-only-transcriberj/hand-written-contract-tests/src/main/java/com/example/refunds/Problem.java b/examples/api-only-transcriberj/hand-written-contract-tests/src/main/java/com/example/refunds/Problem.java new file mode 100644 index 0000000..f5baeb5 --- /dev/null +++ b/examples/api-only-transcriberj/hand-written-contract-tests/src/main/java/com/example/refunds/Problem.java @@ -0,0 +1,17 @@ +package com.example.refunds; + +import com.fasterxml.jackson.annotation.JsonInclude; + +import java.util.List; + +/** + * What went wrong with a request, as RFC 9457 describes it. + * + * @param title a short summary + * @param status the HTTP status code + * @param detail what went wrong with this request, if there is more to say + * @param errors what is wrong with each member, for an invalid request + */ +@JsonInclude(JsonInclude.Include.NON_NULL) +public record Problem(String title, int status, String detail, List errors) { +} diff --git a/examples/api-only-transcriberj/hand-written-contract-tests/src/main/java/com/example/refunds/Refund.java b/examples/api-only-transcriberj/hand-written-contract-tests/src/main/java/com/example/refunds/Refund.java new file mode 100644 index 0000000..4d5ae87 --- /dev/null +++ b/examples/api-only-transcriberj/hand-written-contract-tests/src/main/java/com/example/refunds/Refund.java @@ -0,0 +1,12 @@ +package com.example.refunds; + +/** + * A refund the shop accepted. + * + * @param id its identifier + * @param orderId the order it is for + * @param amount the amount, in cents + * @param destination where the money goes + */ +public record Refund(String id, String orderId, int amount, String destination) { +} diff --git a/examples/api-only-transcriberj/hand-written-contract-tests/src/main/java/com/example/refunds/RefundController.java b/examples/api-only-transcriberj/hand-written-contract-tests/src/main/java/com/example/refunds/RefundController.java new file mode 100644 index 0000000..e30172c --- /dev/null +++ b/examples/api-only-transcriberj/hand-written-contract-tests/src/main/java/com/example/refunds/RefundController.java @@ -0,0 +1,54 @@ +package com.example.refunds; + +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RestController; + +/** + * {@code POST /orders/{orderId}/refunds}. + */ +@RestController +public class RefundController { + + private final Refunds refunds; + + /** + * A controller over this service. + * + * @param refunds the service + */ + public RefundController(Refunds refunds) { + this.refunds = refunds; + } + + /** + * Refunds part or all of an order. + * + * @param orderId the order + * @param request the request + * @return the refund, or the problem + */ + @PostMapping(path = "/orders/{orderId}/refunds", consumes = MediaType.APPLICATION_JSON_VALUE) + public ResponseEntity refund(@PathVariable String orderId, @RequestBody RefundRequest request) { + return switch (refunds.refund(orderId, request)) { + case Outcome.Accepted accepted -> ResponseEntity.status(HttpStatus.CREATED) + .contentType(MediaType.APPLICATION_JSON).body(accepted.refund()); + case Outcome.Invalid invalid -> problem(HttpStatus.BAD_REQUEST, + "The request does not match the contract.", null, invalid.errors()); + case Outcome.UnknownOrder unknown -> problem(HttpStatus.NOT_FOUND, + "No order has that identifier.", "There is no order " + orderId + ".", null); + case Outcome.Rejected rejected -> problem(HttpStatus.UNPROCESSABLE_CONTENT, + "The refund breaks a business rule.", rejected.detail(), null); + }; + } + + private static ResponseEntity problem(HttpStatus status, String title, String detail, + java.util.List errors) { + return ResponseEntity.status(status).contentType(MediaType.APPLICATION_PROBLEM_JSON) + .body(new Problem(title, status.value(), detail, errors)); + } +} diff --git a/examples/api-only-transcriberj/hand-written-contract-tests/src/main/java/com/example/refunds/RefundRequest.java b/examples/api-only-transcriberj/hand-written-contract-tests/src/main/java/com/example/refunds/RefundRequest.java new file mode 100644 index 0000000..5c57fe2 --- /dev/null +++ b/examples/api-only-transcriberj/hand-written-contract-tests/src/main/java/com/example/refunds/RefundRequest.java @@ -0,0 +1,13 @@ +package com.example.refunds; + +/** + * A refund request as it arrives: any member may be missing, since checking it is the + * service's job. + * + * @param destination {@code card} or {@code bank-account} + * @param amount the amount, in cents + * @param cardLast4 the card's last four digits, for a refund to a card + * @param iban the account, for a refund to a bank account + */ +public record RefundRequest(String destination, Integer amount, String cardLast4, String iban) { +} diff --git a/examples/api-only-transcriberj/hand-written-contract-tests/src/main/java/com/example/refunds/Refunds.java b/examples/api-only-transcriberj/hand-written-contract-tests/src/main/java/com/example/refunds/Refunds.java new file mode 100644 index 0000000..7e43897 --- /dev/null +++ b/examples/api-only-transcriberj/hand-written-contract-tests/src/main/java/com/example/refunds/Refunds.java @@ -0,0 +1,84 @@ +package com.example.refunds; + +import org.springframework.stereotype.Service; + +import java.util.ArrayList; +import java.util.List; +import java.util.UUID; +import java.util.regex.Pattern; + +/** + * Decides what becomes of a refund request. + */ +@Service +public class Refunds { + + private static final int MINIMUM = 1; + private static final int MAXIMUM = 1_000_000; + private static final Pattern CARD_LAST4 = Pattern.compile("^[0-9]{4}$"); + private static final Pattern IBAN = Pattern.compile("^[A-Z]{2}[0-9]{2}[A-Z0-9]{11,30}$"); + + private final Orders orders; + + /** + * A service over these orders. + * + * @param orders the orders + */ + public Refunds(Orders orders) { + this.orders = orders; + } + + /** + * Refunds part or all of an order. + * + * @param orderId the order + * @param request the request + * @return what became of it + */ + public Outcome refund(String orderId, RefundRequest request) { + List errors = check(request); + if (!errors.isEmpty()) { + return new Outcome.Invalid(errors); + } + Orders.Order order = orders.find(orderId).orElse(null); + if (order == null) { + return new Outcome.UnknownOrder(); + } + if (!order.paidWith().equals(request.destination())) { + return new Outcome.Rejected("The order was paid by " + order.paidWith() + + ", so its refunds go back to the " + order.paidWith() + "."); + } + if (request.amount() > order.left()) { + return new Outcome.Rejected("Only " + order.left() + " of the order is left to refund."); + } + orders.refund(orderId, request.amount()); + return new Outcome.Accepted(new Refund(UUID.randomUUID().toString(), orderId, request.amount(), + request.destination())); + } + + private static List check(RefundRequest request) { + List errors = new ArrayList<>(); + String destination = request.destination(); + if (!"card".equals(destination) && !"bank-account".equals(destination)) { + errors.add(new FieldError("destination", "must be card or bank-account")); + return errors; + } + if (request.amount() == null) { + errors.add(new FieldError("amount", "is required")); + } else if (request.amount() < MINIMUM || request.amount() > MAXIMUM) { + errors.add(new FieldError("amount", "must be between " + MINIMUM + " and " + MAXIMUM)); + } + if (destination.equals("card") && !matches(CARD_LAST4, request.cardLast4())) { + errors.add(new FieldError("cardLast4", "must be the last four digits of the card")); + } + if (destination.equals("bank-account") && !matches(IBAN, request.iban())) { + errors.add(new FieldError("iban", "must be an IBAN without spaces")); + } + return errors; + } + + private static boolean matches(Pattern pattern, String value) { + return value != null && pattern.matcher(value).matches(); + } +} diff --git a/examples/api-only-transcriberj/hand-written-contract-tests/src/main/java/com/example/refunds/RefundsApplication.java b/examples/api-only-transcriberj/hand-written-contract-tests/src/main/java/com/example/refunds/RefundsApplication.java new file mode 100644 index 0000000..f60f54a --- /dev/null +++ b/examples/api-only-transcriberj/hand-written-contract-tests/src/main/java/com/example/refunds/RefundsApplication.java @@ -0,0 +1,20 @@ +package com.example.refunds; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; + +/** + * A shop's refunds service: the application the contract describes, written by hand. + */ +@SpringBootApplication +public class RefundsApplication { + + /** + * Starts the service. + * + * @param args the command line + */ + public static void main(String[] args) { + SpringApplication.run(RefundsApplication.class, args); + } +} diff --git a/examples/api-only-transcriberj/hand-written-contract-tests/src/test/java/com/example/contract/RefundRequests.java b/examples/api-only-transcriberj/hand-written-contract-tests/src/test/java/com/example/contract/RefundRequests.java new file mode 100644 index 0000000..9f760e6 --- /dev/null +++ b/examples/api-only-transcriberj/hand-written-contract-tests/src/test/java/com/example/contract/RefundRequests.java @@ -0,0 +1,65 @@ +package com.example.contract; + +/** + * Refund requests, written by hand: {@link RefundRequestV1#body(Object...)} cannot be + * generated, because the contract's {@code oneOf} branches are inline. + * + *

It sits in the generated package, in the test source set, so it writes JSON with the + * same {@link ContractJson} the generated bodies do, and it takes everything else it can + * from the generated classes. The member names and the two {@code destination} values are + * the only things typed here; {@link #WRITTEN_AGAINST} is how a change to them is noticed. + */ +public final class RefundRequests { + + /** + * The {@link RefundRequestV1#FRAGMENT_SHA256} this class was written against. When the + * request schema changes, a test fails until this class is checked against it again. + */ + public static final String WRITTEN_AGAINST = + "f20b627c2a6f8effb19706bfc3bc3b2854a494c0b8bcf79598c46db8666529b6"; + + /** An amount the contract accepts: the smallest. */ + public static final int SOME_AMOUNT = AmountV1.MINIMUM; + + /** An amount the contract rejects: one more than the largest. */ + public static final int TOO_LARGE_AN_AMOUNT = AmountV1.MAXIMUM + 1; + + /** Card digits the contract accepts; checked against {@link CardLast4V1#PATTERN}. */ + public static final String SOME_CARD_LAST4 = "4242"; + + /** An account the contract accepts; checked against {@link IbanV1#PATTERN}. */ + public static final String SOME_IBAN = "NL91ABNA0417164300"; + + private RefundRequests() { + } + + /** + * A refund to the card the order was paid with. + * + * @param amount the amount, in cents; see {@link AmountV1} + * @param cardLast4 the card's last four digits; see {@link CardLast4V1#PATTERN} + * @return the request body + */ + public static String toCard(int amount, String cardLast4) { + StringBuilder json = new StringBuilder("{"); + ContractJson.member(json, "destination", ContractJson.string("card")); + ContractJson.member(json, "amount", String.valueOf(amount)); + ContractJson.member(json, "cardLast4", ContractJson.string(cardLast4)); + return ContractJson.close(json); + } + + /** + * A refund to a bank account. + * + * @param amount the amount, in cents; see {@link AmountV1} + * @param iban the account; see {@link IbanV1#PATTERN} + * @return the request body + */ + public static String toBankAccount(int amount, String iban) { + StringBuilder json = new StringBuilder("{"); + ContractJson.member(json, "destination", ContractJson.string("bank-account")); + ContractJson.member(json, "amount", String.valueOf(amount)); + ContractJson.member(json, "iban", ContractJson.string(iban)); + return ContractJson.close(json); + } +} diff --git a/examples/api-only-transcriberj/hand-written-contract-tests/src/test/java/com/example/contract/RefundRequestsTest.java b/examples/api-only-transcriberj/hand-written-contract-tests/src/test/java/com/example/contract/RefundRequestsTest.java new file mode 100644 index 0000000..9280fbb --- /dev/null +++ b/examples/api-only-transcriberj/hand-written-contract-tests/src/test/java/com/example/contract/RefundRequestsTest.java @@ -0,0 +1,46 @@ +package com.example.contract; + +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +/** + * What keeps the hand-written {@link RefundRequests} honest as the contract moves. + */ +class RefundRequestsTest { + + @Test + void theHandWrittenBodyWasWrittenAgainstTheRequestSchemaAsItIsNow() { + assertThat(RefundRequestV1.FRAGMENT_SHA256) + .as("%s changed; check RefundRequests against it, then update WRITTEN_AGAINST", + RefundRequestV1.FRAGMENT_PATH) + .isEqualTo(RefundRequests.WRITTEN_AGAINST); + } + + @Test + void theGeneratorStillCannotWriteThisBody() { + // When this fails, the contract names its branches and the generator writes the + // body itself: use the generated classes, and delete RefundRequests. + assertThatThrownBy(RefundRequestV1::body) + .isInstanceOf(UnsupportedOperationException.class) + .hasMessageContaining("ONE_OF_INLINE_BRANCHES"); + } + + @Test + void theSampleValuesAreOnesTheContractAccepts() { + // CardLast4V1 and IbanV1 are package-private: nothing public in the generated tree + // takes them, because the body that would have is the one that could not be generated. + assertThat(RefundRequests.SOME_AMOUNT).isBetween(AmountV1.MINIMUM, AmountV1.MAXIMUM); + assertThat(RefundRequests.SOME_CARD_LAST4).matches(CardLast4V1.PATTERN); + assertThat(RefundRequests.SOME_IBAN).matches(IbanV1.PATTERN); + } + + @Test + void itWritesTheMembersEachBranchRequires() { + assertThat(RefundRequests.toCard(RefundRequests.SOME_AMOUNT, RefundRequests.SOME_CARD_LAST4)) + .isEqualTo("{\"destination\":\"card\",\"amount\":1,\"cardLast4\":\"4242\"}\n"); + assertThat(RefundRequests.toBankAccount(RefundRequests.SOME_AMOUNT, RefundRequests.SOME_IBAN)) + .isEqualTo("{\"destination\":\"bank-account\",\"amount\":1,\"iban\":\"NL91ABNA0417164300\"}\n"); + } +} diff --git a/examples/api-only-transcriberj/hand-written-contract-tests/src/test/java/com/example/refunds/CreateRefundContractTest.java b/examples/api-only-transcriberj/hand-written-contract-tests/src/test/java/com/example/refunds/CreateRefundContractTest.java new file mode 100644 index 0000000..9cdb6a9 --- /dev/null +++ b/examples/api-only-transcriberj/hand-written-contract-tests/src/test/java/com/example/refunds/CreateRefundContractTest.java @@ -0,0 +1,78 @@ +package com.example.refunds; + +import com.example.contract.CreateRefundOperation; +import com.example.contract.RefundRequests; +import com.example.contract.restdocs.ProblemV1Docs; +import com.example.contract.restdocs.RefundV1Docs; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.restdocs.test.autoconfigure.AutoConfigureRestDocs; +import org.springframework.boot.webmvc.test.autoconfigure.WebMvcTest; +import org.springframework.context.annotation.Import; +import org.springframework.test.web.servlet.MockMvc; + +import static org.springframework.restdocs.mockmvc.MockMvcRestDocumentation.document; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +/** + * The contract tests of {@code createRefund}: one per response the contract lists, each + * checking the response's shape -- its status, its content type and its fields -- against + * the contract. Which requests the service rejects, and why, is behaviour, and is tested + * in {@link RefundsTest}. + */ +// The service itself, not a stand-in: the answers are the ones a client gets. +@WebMvcTest(RefundController.class) +@Import({Refunds.class, Orders.class}) +@AutoConfigureRestDocs +class CreateRefundContractTest { + + @Autowired + private MockMvc mockMvc; + + @Test + void anAcceptedRefundIsAnsweredInTheShapeTheContractDescribes() throws Exception { + mockMvc.perform(post(CreateRefundOperation.PATH, Orders.PAID_BY_CARD) + .contentType(CreateRefundOperation.REQUEST_CONTENT_TYPE) + .content(RefundRequests.toCard(RefundRequests.SOME_AMOUNT, RefundRequests.SOME_CARD_LAST4))) + .andExpect(status().is(CreateRefundOperation.STATUS_201)) + .andExpect(content().contentType(CreateRefundOperation.CONTENT_TYPE_201)) + .andDo(document("create-refund", RefundV1Docs.responseFields())); + } + + @Test + void aRequestTheContractRejectsIsAnsweredWithAProblem() throws Exception { + // One example of a request the contract does not allow is enough to see the shape + // of the answer. Every other way to be invalid is behaviour: see RefundsTest. + mockMvc.perform(post(CreateRefundOperation.PATH, Orders.PAID_BY_CARD) + .contentType(CreateRefundOperation.REQUEST_CONTENT_TYPE) + .content(RefundRequests.toCard(RefundRequests.TOO_LARGE_AN_AMOUNT, RefundRequests.SOME_CARD_LAST4))) + .andExpect(status().is(CreateRefundOperation.STATUS_400)) + .andExpect(content().contentType(CreateRefundOperation.CONTENT_TYPE_400)) + .andDo(document("create-refund-invalid", ProblemV1Docs.responseFields())); + } + + @Test + void anUnknownOrderIsAnsweredWithAProblem() throws Exception { + mockMvc.perform(post(CreateRefundOperation.PATH, "no-such-order") + .contentType(CreateRefundOperation.REQUEST_CONTENT_TYPE) + .content(RefundRequests.toCard(RefundRequests.SOME_AMOUNT, RefundRequests.SOME_CARD_LAST4))) + .andExpect(status().is(CreateRefundOperation.STATUS_404)) + .andExpect(content().contentType(CreateRefundOperation.CONTENT_TYPE_404)) + .andDo(document("create-refund-unknown-order", ProblemV1Docs.responseFields())); + } + + @Test + void aRefundThatBreaksABusinessRuleIsAnsweredWithAProblem() throws Exception { + // Valid against the schema, and still refused: the order was paid by card, so its + // refund goes back to the card. The schema cannot say that; the 422 is how the + // contract tells a client, and its shape is what this test checks. + mockMvc.perform(post(CreateRefundOperation.PATH, Orders.PAID_BY_CARD) + .contentType(CreateRefundOperation.REQUEST_CONTENT_TYPE) + .content(RefundRequests.toBankAccount(RefundRequests.SOME_AMOUNT, RefundRequests.SOME_IBAN))) + .andExpect(status().is(CreateRefundOperation.STATUS_422)) + .andExpect(content().contentType(CreateRefundOperation.CONTENT_TYPE_422)) + .andDo(document("create-refund-rejected", ProblemV1Docs.responseFields())); + } +} diff --git a/examples/api-only-transcriberj/hand-written-contract-tests/src/test/java/com/example/refunds/RefundsTest.java b/examples/api-only-transcriberj/hand-written-contract-tests/src/test/java/com/example/refunds/RefundsTest.java new file mode 100644 index 0000000..7ada168 --- /dev/null +++ b/examples/api-only-transcriberj/hand-written-contract-tests/src/test/java/com/example/refunds/RefundsTest.java @@ -0,0 +1,91 @@ +package com.example.refunds; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.CsvSource; + +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * What the service does with a refund request: which ones it refuses, and why. These are + * scenarios, one per rule, and they are the place for every way a request can be wrong -- + * {@link CreateRefundContractTest} needs only one of them to check the shape of the answer. + */ +class RefundsTest { + + private final Refunds refunds = new Refunds(new Orders()); + + private static RefundRequest toCard(Integer amount, String cardLast4) { + return new RefundRequest("card", amount, cardLast4, null); + } + + private static RefundRequest toBankAccount(Integer amount, String iban) { + return new RefundRequest("bank-account", amount, null, iban); + } + + @ParameterizedTest(name = "{0}") + @CsvSource(delimiter = '|', nullValues = "null", textBlock = """ + no amount | card | null | 4242 | null | amount | is required + a zero amount | card | 0 | 4242 | null | amount | must be between 1 and 1000000 + too large an amount | card | 1000001 | 4242 | null | amount | must be between 1 and 1000000 + no card digits | card | 100 | null | null | cardLast4 | must be the last four digits of the card + three card digits | card | 100 | 424 | null | cardLast4 | must be the last four digits of the card + letters for digits | card | 100 | abcd | null | cardLast4 | must be the last four digits of the card + no IBAN | bank-account | 100 | null | null | iban | must be an IBAN without spaces + an IBAN with spaces | bank-account | 100 | null | NL91 ABNA 0417 1643 00 | iban | must be an IBAN without spaces + no destination | null | 100 | 4242 | null | destination | must be card or bank-account + an unknown destination | cash | 100 | 4242 | null | destination | must be card or bank-account + """) + void aRequestTheContractDoesNotAllowIsInvalid(String scenario, String destination, Integer amount, + String cardLast4, String iban, String field, String message) { + Outcome outcome = refunds.refund(Orders.PAID_BY_CARD, new RefundRequest(destination, amount, cardLast4, iban)); + + assertThat(outcome).isEqualTo(new Outcome.Invalid(List.of(new FieldError(field, message)))); + } + + @Test + void everyProblemWithARequestIsReportedAtOnce() { + Outcome outcome = refunds.refund(Orders.PAID_BY_CARD, toCard(0, "abcd")); + + assertThat(outcome).isEqualTo(new Outcome.Invalid(List.of( + new FieldError("amount", "must be between 1 and 1000000"), + new FieldError("cardLast4", "must be the last four digits of the card")))); + } + + @Test + void aRefundGoesBackTheWayTheOrderWasPaid() { + assertThat(refunds.refund(Orders.PAID_BY_CARD, toBankAccount(100, "NL91ABNA0417164300"))) + .isEqualTo(new Outcome.Rejected("The order was paid by card, so its refunds go back to the card.")); + assertThat(refunds.refund(Orders.PAID_BY_BANK_TRANSFER, toCard(100, "4242"))) + .isEqualTo(new Outcome.Rejected( + "The order was paid by bank-account, so its refunds go back to the bank-account.")); + } + + @Test + void refundsNeverAddUpToMoreThanTheOrder() { + assertThat(refunds.refund(Orders.PAID_BY_CARD, toCard(3000, "4242"))).isInstanceOf(Outcome.Accepted.class); + + assertThat(refunds.refund(Orders.PAID_BY_CARD, toCard(2500, "4242"))) + .isEqualTo(new Outcome.Rejected("Only 2000 of the order is left to refund.")); + assertThat(refunds.refund(Orders.PAID_BY_CARD, toCard(2000, "4242"))).isInstanceOf(Outcome.Accepted.class); + } + + @Test + void anAcceptedRefundSaysWhatWasRefundedAndWhere() { + Outcome outcome = refunds.refund(Orders.PAID_BY_BANK_TRANSFER, toBankAccount(1250, "NL91ABNA0417164300")); + + assertThat(outcome).isInstanceOfSatisfying(Outcome.Accepted.class, accepted -> { + assertThat(accepted.refund().id()).isNotBlank(); + assertThat(accepted.refund().orderId()).isEqualTo(Orders.PAID_BY_BANK_TRANSFER); + assertThat(accepted.refund().amount()).isEqualTo(1250); + assertThat(accepted.refund().destination()).isEqualTo("bank-account"); + }); + } + + @Test + void anOrderThatDoesNotExistCannotBeRefunded() { + assertThat(refunds.refund("no-such-order", toCard(100, "4242"))).isEqualTo(new Outcome.UnknownOrder()); + } +}