diff --git a/.github/workflows/example-api-only-transcriberj-description-bundles-build.yml b/.github/workflows/example-api-only-transcriberj-description-bundles-build.yml new file mode 100644 index 0000000..4dcc9a8 --- /dev/null +++ b/.github/workflows/example-api-only-transcriberj-description-bundles-build.yml @@ -0,0 +1,48 @@ +name: Example - API-Only TranscriberJ Description Bundles Build +on: + push: + paths: + - 'examples/api-only-transcriberj/description-bundles/**/*.java' + - 'examples/api-only-transcriberj/description-bundles/**/*.yaml' + - 'examples/api-only-transcriberj/description-bundles/**/*.gradle' + - 'examples/api-only-transcriberj/description-bundles/**/*.properties' + - 'examples/api-only-transcriberj/description-bundles/**/libs.versions.toml' + - 'examples/api-only-transcriberj/description-bundles/package.json' + - 'examples/api-only-transcriberj/description-bundles/package-lock.json' + - '.github/workflows/example-api-only-transcriberj-description-bundles-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/description-bundles + chmod +x ./gradlew + ./gradlew test --no-daemon diff --git a/examples/api-only-transcriberj/description-bundles/.gitignore b/examples/api-only-transcriberj/description-bundles/.gitignore new file mode 100644 index 0000000..1facb38 --- /dev/null +++ b/examples/api-only-transcriberj/description-bundles/.gitignore @@ -0,0 +1,3 @@ +node_modules/ +build/ +.gradle/ diff --git a/examples/api-only-transcriberj/description-bundles/.redocly.yaml b/examples/api-only-transcriberj/description-bundles/.redocly.yaml new file mode 100644 index 0000000..c2373f5 --- /dev/null +++ b/examples/api-only-transcriberj/description-bundles/.redocly.yaml @@ -0,0 +1,2 @@ +extends: + - recommended diff --git a/examples/api-only-transcriberj/description-bundles/README.adoc b/examples/api-only-transcriberj/description-bundles/README.adoc new file mode 100644 index 0000000..c164a1f --- /dev/null +++ b/examples/api-only-transcriberj/description-bundles/README.adoc @@ -0,0 +1,84 @@ += Example: descriptions from a ResourceBundle, in English and Dutch +:toc: left +:toc-title: Contents +:icons: font +:source-highlighter: rouge + +A small, complete project that documents one contract in two languages from one generated class tree. + +The API-Only TranscriberJ generates the contract's classes; this project supplies the descriptions its documentation shows, in a `ResourceBundle`, and leaves the rest to the contract. +The guide that walks through it step by step is https://github.com/Arc-E-Tect/SoftwareEngineeringDoneRight-API/blob/main/docs/guides/description-bundles/tutorial.adoc[the description bundles tutorial ↗]; the full reference is https://github.com/Arc-E-Tect/SoftwareEngineeringDoneRight-API/blob/main/api-only-transcriberj/README.adoc#description-bundles[the TranscriberJ's README ↗]. + +== What is in it + +[cols="2,3", options="header"] +|=== +|File |What it is + +|`src/main/api/openapi/` +|The `orders` contract: `OrderV1`, with a nested `customer.address` and an array of `lines`, and `ProblemV1`, an error with an array of `errors`. +Every field is described in the contract's own words. + +|`src/test/resources/docs/Descriptions.properties` +|The project's own English text -- for *some* fields only, so the rest visibly fall back to the contract. + +|`src/test/resources/docs/Descriptions_nl.properties` +|The Dutch text, also deliberately incomplete, so a missing translation visibly falls back too. + +|`build.gradle` +|The Publisher builds the contract into `build/`, the Subscriber fetches it, and the TranscriberJ generates from it, with `descriptionBundle = 'docs.Descriptions'`. + +|`src/test/java/com/example/orders/DescriptionsTest.java` +|Renders the documentation in both languages and checks the text, the fallbacks, and the keys the bundles miss or no longer need. +|=== + +== Run it + +You need Java 21 and Node.js (`^22.14.0`, `^24.10.0` or `>=26.0.0`). + +[source,console] +---- +./gradlew test +---- + +The build installs the Publisher, builds and publishes the contract, generates the classes, and runs the tests. + +The tests run in English unless the build asks for another language, which `build.gradle` passes to the test JVM: + +[source,console] +---- +./gradlew test -PdocsLocale=nl +---- + +== What to look at + +`build/generated/sources/transcriberj/orders/com/example/contract/ContractDescriptions.java`:: +Every key a bundle for this contract may carry, in `KEYS`. +`OrderV1.customer.address.street` is a nested field; `OrderV1.lines[].sku` and `ProblemV1.errors[].message` are fields of an array's items. + +`build/generated/sources/transcriberj/orders/com/example/contract/OrderV1.java`:: +Every description is a call to `ContractDescriptions.of(key, text)`, where the text is what the contract says -- the fallback. + +`build/reports/tests/test/index.html`:: +The tests, one per behaviour: the bundle winning where it has a key, the contract where it has none, the same tree in Dutch, the language a run asks for, REST Docs rendering each language, and the keys the bundles miss or no longer need. + +`build/reports/transcriberj/orders.txt`:: +The generation report, which names the bundle and how many keys it may carry. + +== Try it + +* Add `OrderV1.customer.address.city=De plaats.` to `Descriptions_nl.properties` and run the tests again. +Two fail, and both for the right reason: `theSameTreeDocumentsTheContractInDutch` expected the city to fall back to the contract's English, and `whatTheBundlesDoNotCoverCanBeListed` expected it to be untranslated. +* Rename `OrderV1.yaml` to `PurchaseOrderV1.yaml`, update the `$ref` in `paths/Order.yaml`, and raise `version` and `apiContractVersion` to `2.0.0`. +The test no longer compiles: `OrderV1` does not exist any more, which is the first thing you see. +Remove every test that names it, as a class or in a key, and `everyKeyInTheBundlesNamesSomethingTheContractDescribes` lists the `OrderV1` keys the bundles still carry, which nothing uses now: ++ +[source,text] +---- +[keys the contract no longer describes, per file] +Expecting empty but was: {"docs/Descriptions.properties"=["OrderV1", "OrderV1.customer.address.street", "OrderV1.lines[].sku"], "docs/Descriptions_nl.properties"=["OrderV1", + "OrderV1.customer.address.street", + "OrderV1.customer.name", + "OrderV1.id", + "OrderV1.lines[].quantity"]} +---- diff --git a/examples/api-only-transcriberj/description-bundles/apionly.lock b/examples/api-only-transcriberj/description-bundles/apionly.lock new file mode 100644 index 0000000..5bb55e2 --- /dev/null +++ b/examples/api-only-transcriberj/description-bundles/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 orders +version 1.0.0 +channel file +sha256 caecfedabb33e61725faf0129b8cd29594efcd40d0c01f590d05f1d1ad88f357 openapi.yaml diff --git a/examples/api-only-transcriberj/description-bundles/apionly.yaml b/examples/api-only-transcriberj/description-bundles/apionly.yaml new file mode 100644 index 0000000..d2906ac --- /dev/null +++ b/examples/api-only-transcriberj/description-bundles/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: + orders: + openapi: + bundle: bundles/orders.yaml diff --git a/examples/api-only-transcriberj/description-bundles/build.gradle b/examples/api-only-transcriberj/description-bundles/build.gradle new file mode 100644 index 0000000..fb10a32 --- /dev/null +++ b/examples/api-only-transcriberj/description-bundles/build.gradle @@ -0,0 +1,87 @@ +plugins { + id 'java' + alias(libs.plugins.api.only.transcriberj) +} + +java { + toolchain { + languageVersion = JavaLanguageVersion.of(21) + } +} + +repositories { + mavenCentral() +} + +dependencies { + transcriberjEmitters libs.api.only.transcriberj.restdocs + + testImplementation platform(libs.junit.bom) + testImplementation 'org.junit.jupiter:junit-jupiter' + testImplementation libs.assertj.core + testRuntimeOnly 'org.junit.platform:junit-platform-launcher' +} + +tasks.named('test') { + useJUnitPlatform() + // The language this run documents in, English unless asked: ./gradlew test -PdocsLocale=nl + systemProperty 'apionly.descriptions.locale', providers.gradleProperty('docsLocale').getOrElse('en') +} + +// --------------------------------------------------------------------------- +// 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 orders 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/orders').withPropertyName('bundle') + commandLine npm, 'run', '--silent', 'apionly', '--', 'build', '--target', 'orders' +} + +def publishApiContract = tasks.register('publishApiContract', Exec) { + group = 'api-only' + description = 'Publishes the orders contract to the file channel in build/api-only/publish.' + dependsOn buildApiContract + inputs.dir('build/api-only/dist/orders').withPropertyName('bundle') + outputs.dir('build/api-only/publish/orders').withPropertyName('archive') + commandLine npm, 'run', '--silent', 'apionly', '--', 'publish', '--target', 'orders', '--channel', 'file' +} + +apiOnlySubscriber { + channel { + type = 'file' + directory = file('build/api-only/publish').path + } + subscribe('orders') +} + +tasks.named('fetchApiSpecOrders') { dependsOn publishApiContract } + +// --------------------------------------------------------------------------- +// The generated classes, with descriptions this project owns. +// --------------------------------------------------------------------------- + +apiOnlyTranscriberJ { + subscription('orders') { + basePackage = 'com.example.contract' + // The contract's own descriptions are the fallback for any key the bundle lacks. + generateDocs = true + // src/test/resources/docs/Descriptions.properties, and one file per language. + descriptionBundle = 'docs.Descriptions' + } +} diff --git a/examples/api-only-transcriberj/description-bundles/gradle.properties b/examples/api-only-transcriberj/description-bundles/gradle.properties new file mode 100644 index 0000000..3ddd7b7 --- /dev/null +++ b/examples/api-only-transcriberj/description-bundles/gradle.properties @@ -0,0 +1,2 @@ +# The version of the orders contract this project implements. +apiContractVersion=1.0.0 diff --git a/examples/api-only-transcriberj/description-bundles/gradle/libs.versions.toml b/examples/api-only-transcriberj/description-bundles/gradle/libs.versions.toml new file mode 100644 index 0000000..ceb5444 --- /dev/null +++ b/examples/api-only-transcriberj/description-bundles/gradle/libs.versions.toml @@ -0,0 +1,13 @@ +[versions] +api-only-transcriberj = "0.3.2" +api-only-transcriberj-restdocs = "0.1.1" +junit = "6.1.3" +assertj = "3.27.7" + +[libraries] +api-only-transcriberj-restdocs = { module = "com.arc-e-tect:api-only-transcriberj-restdocs", version.ref = "api-only-transcriberj-restdocs" } +junit-bom = { module = "org.junit:junit-bom", version.ref = "junit" } +assertj-core = { module = "org.assertj:assertj-core", version.ref = "assertj" } + +[plugins] +api-only-transcriberj = { id = "com.arc-e-tect.api-only-transcriberj", version.ref = "api-only-transcriberj" } diff --git a/examples/api-only-transcriberj/description-bundles/gradle/wrapper/gradle-wrapper.jar b/examples/api-only-transcriberj/description-bundles/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000..eddabd2 Binary files /dev/null and b/examples/api-only-transcriberj/description-bundles/gradle/wrapper/gradle-wrapper.jar differ diff --git a/examples/api-only-transcriberj/description-bundles/gradle/wrapper/gradle-wrapper.properties b/examples/api-only-transcriberj/description-bundles/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..ad7845b --- /dev/null +++ b/examples/api-only-transcriberj/description-bundles/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/description-bundles/gradlew b/examples/api-only-transcriberj/description-bundles/gradlew new file mode 100755 index 0000000..249efbb --- /dev/null +++ b/examples/api-only-transcriberj/description-bundles/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/description-bundles/gradlew.bat b/examples/api-only-transcriberj/description-bundles/gradlew.bat new file mode 100644 index 0000000..a51ec4f --- /dev/null +++ b/examples/api-only-transcriberj/description-bundles/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/description-bundles/package-lock.json b/examples/api-only-transcriberj/description-bundles/package-lock.json new file mode 100644 index 0000000..8809a7f --- /dev/null +++ b/examples/api-only-transcriberj/description-bundles/package-lock.json @@ -0,0 +1,45 @@ +{ + "name": "api-only-transcriberj-description-bundles", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "api-only-transcriberj-description-bundles", + "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/description-bundles/package.json b/examples/api-only-transcriberj/description-bundles/package.json new file mode 100644 index 0000000..3ef903c --- /dev/null +++ b/examples/api-only-transcriberj/description-bundles/package.json @@ -0,0 +1,10 @@ +{ + "name": "api-only-transcriberj-description-bundles", + "private": true, + "scripts": { + "apionly": "api-only-publisher" + }, + "devDependencies": { + "@arc-e-tect/api-only-publisher": "0.5.0" + } +} diff --git a/examples/api-only-transcriberj/description-bundles/settings.gradle b/examples/api-only-transcriberj/description-bundles/settings.gradle new file mode 100644 index 0000000..69009e8 --- /dev/null +++ b/examples/api-only-transcriberj/description-bundles/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-description-bundles' diff --git a/examples/api-only-transcriberj/description-bundles/src/main/api/openapi/bundles/orders.bundle.properties b/examples/api-only-transcriberj/description-bundles/src/main/api/openapi/bundles/orders.bundle.properties new file mode 100644 index 0000000..6aa8318 --- /dev/null +++ b/examples/api-only-transcriberj/description-bundles/src/main/api/openapi/bundles/orders.bundle.properties @@ -0,0 +1,2 @@ +# The version of the orders contract. +version=1.0.0 diff --git a/examples/api-only-transcriberj/description-bundles/src/main/api/openapi/bundles/orders.yaml b/examples/api-only-transcriberj/description-bundles/src/main/api/openapi/bundles/orders.yaml new file mode 100644 index 0000000..3c4fd63 --- /dev/null +++ b/examples/api-only-transcriberj/description-bundles/src/main/api/openapi/bundles/orders.yaml @@ -0,0 +1,13 @@ +openapi: 3.1.1 +info: + title: Orders API + version: 0.0.0 + license: + name: MIT + identifier: MIT +servers: + - url: https://api.orders.test +security: [] +paths: + /orders/{orderId}: + $ref: ../paths/Order.yaml diff --git a/examples/api-only-transcriberj/description-bundles/src/main/api/openapi/components/schemas/OrderV1.yaml b/examples/api-only-transcriberj/description-bundles/src/main/api/openapi/components/schemas/OrderV1.yaml new file mode 100644 index 0000000..a564857 --- /dev/null +++ b/examples/api-only-transcriberj/description-bundles/src/main/api/openapi/components/schemas/OrderV1.yaml @@ -0,0 +1,44 @@ +type: object +description: An order a customer placed. +required: + - id + - customer + - lines +properties: + id: + type: string + description: The order's identifier. + customer: + type: object + description: Who placed the order. + required: + - name + properties: + name: + type: string + description: The customer's name. + address: + type: object + description: Where the order is delivered. + properties: + street: + type: string + description: The street and house number. + city: + type: string + description: The city. + lines: + type: array + description: What was ordered, one line per product. + items: + type: object + required: + - sku + - quantity + properties: + sku: + type: string + description: The product's stock-keeping unit. + quantity: + type: integer + description: How many were ordered. diff --git a/examples/api-only-transcriberj/description-bundles/src/main/api/openapi/components/schemas/ProblemV1.yaml b/examples/api-only-transcriberj/description-bundles/src/main/api/openapi/components/schemas/ProblemV1.yaml new file mode 100644 index 0000000..8c15514 --- /dev/null +++ b/examples/api-only-transcriberj/description-bundles/src/main/api/openapi/components/schemas/ProblemV1.yaml @@ -0,0 +1,24 @@ +type: object +description: What went wrong with a request. +required: + - title + - status +properties: + title: + type: string + description: A short summary of the problem. + status: + type: integer + description: The HTTP status code. + errors: + type: array + description: The individual problems, one per field. + items: + type: object + 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/description-bundles/src/main/api/openapi/paths/Order.yaml b/examples/api-only-transcriberj/description-bundles/src/main/api/openapi/paths/Order.yaml new file mode 100644 index 0000000..20ef3a7 --- /dev/null +++ b/examples/api-only-transcriberj/description-bundles/src/main/api/openapi/paths/Order.yaml @@ -0,0 +1,23 @@ +get: + operationId: getOrder + summary: Get one order. + parameters: + - name: orderId + in: path + required: true + description: The order's identifier. + schema: + type: string + responses: + '200': + description: The order. + content: + application/json: + schema: + $ref: ../components/schemas/OrderV1.yaml + '404': + description: No order has that identifier. + content: + application/problem+json: + schema: + $ref: ../components/schemas/ProblemV1.yaml diff --git a/examples/api-only-transcriberj/description-bundles/src/test/java/com/example/orders/DescriptionsTest.java b/examples/api-only-transcriberj/description-bundles/src/test/java/com/example/orders/DescriptionsTest.java new file mode 100644 index 0000000..2e55265 --- /dev/null +++ b/examples/api-only-transcriberj/description-bundles/src/test/java/com/example/orders/DescriptionsTest.java @@ -0,0 +1,150 @@ +package com.example.orders; + +import com.example.contract.ContractDescriptions; +import com.example.contract.ContractField; +import com.example.contract.OrderV1; +import com.example.contract.ProblemV1; +import com.example.contract.restdocs.OrderV1Docs; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; +import org.springframework.restdocs.payload.FieldDescriptor; + +import java.util.Arrays; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Set; +import java.util.TreeMap; +import java.util.TreeSet; +import java.util.stream.Collectors; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * The orders contract's documentation, in English and in Dutch, from one generated tree. + */ +class DescriptionsTest { + + private static final Locale ENGLISH = Locale.ENGLISH; + private static final Locale DUTCH = Locale.of("nl"); + + /** The locale the build asked for, which a test that sets its own gives back. */ + private final String runLocale = System.getProperty(ContractDescriptions.LOCALE_PROPERTY); + + @AfterEach + void restoreTheLocale() { + if (runLocale == null) { + System.clearProperty(ContractDescriptions.LOCALE_PROPERTY); + } else { + System.setProperty(ContractDescriptions.LOCALE_PROPERTY, runLocale); + } + } + + /** Every field of a body, as path and description. */ + private static Map described(List fields) { + return fields.stream().collect(Collectors.toMap(ContractField::path, ContractField::description)); + } + + @Test + void theBundleDescribesWhatItCarriesAndTheContractDescribesTheRest() { + Map english = described(OrderV1.fields("", ENGLISH)); + + // In the bundle: the project's own words. + assertThat(OrderV1.description(ENGLISH)) + .isEqualTo("An order, as the shop's customer service team explains it."); + assertThat(english.get("customer.address.street")) + .isEqualTo("The street and house number, exactly as the customer typed them."); + assertThat(english.get("lines[].sku")).isEqualTo("The product code printed on the shelf label."); + + // Not in the bundle: the contract's words. + assertThat(english.get("id")).isEqualTo("The order's identifier."); + assertThat(english.get("customer.address.city")).isEqualTo("The city."); + } + + @Test + void theSameTreeDocumentsTheContractInDutch() { + Map english = described(OrderV1.fields("", ENGLISH)); + Map dutch = described(OrderV1.fields("", DUTCH)); + + assertThat(OrderV1.description(DUTCH)) + .isEqualTo("Een bestelling, zoals de klantenservice van de winkel die uitlegt.") + .isNotEqualTo(OrderV1.description(ENGLISH)); + assertThat(dutch.get("customer.address.street")) + .isEqualTo("De straat en het huisnummer, precies zoals de klant ze intypte.") + .isNotEqualTo(english.get("customer.address.street")); + assertThat(dutch.get("lines[].quantity")).isEqualTo("Hoeveel stuks er besteld zijn."); + + // Not translated: the English bundle's text, and after that the contract's. + assertThat(dutch.get("lines[].sku")).isEqualTo("The product code printed on the shelf label."); + assertThat(dutch.get("customer.address.city")).isEqualTo("The city."); + } + + @Test + void anErrorSchemaIsDescribedTheSameWay() { + Map dutch = described(ProblemV1.fields("", DUTCH)); + + assertThat(dutch.get("errors[].message")) + .isEqualTo("Een zin die de client kan tonen aan wie het formulier invulde."); + assertThat(dutch.get("title")).isEqualTo("A short summary of the problem."); + } + + @Test + void restDocsRendersEachLanguageTheRunAsksFor() { + System.setProperty(ContractDescriptions.LOCALE_PROPERTY, "en"); + Map english = restDocs(OrderV1Docs.fields()); + + System.setProperty(ContractDescriptions.LOCALE_PROPERTY, "nl"); + Map dutch = restDocs(OrderV1Docs.fields()); + + assertThat(english.get("customer.name")).isEqualTo("The customer's name."); + assertThat(dutch.get("customer.name")).isEqualTo("De naam van de klant."); + } + + @Test + void aClassWithoutALocaleDocumentsInTheLanguageTheBuildAsksFor() { + Map order = Map.of( + "en", "An order, as the shop's customer service team explains it.", + "nl", "Een bestelling, zoals de klantenservice van de winkel die uitlegt."); + + assertThat(OrderV1.description()).isEqualTo(order.get(ContractDescriptions.locale().getLanguage())); + } + + @Test + void whatTheBundlesDoNotCoverCanBeListed() { + // A reader's question: what falls back to the contract's own text. + assertThat(ContractDescriptions.missing(ENGLISH)) + .contains("OrderV1.id", "OrderV1.customer.address.city", "ProblemV1.title") + .doesNotContain("OrderV1", "OrderV1.lines[].sku"); + + // A translator's question: what Dutch has no text of its own for. + assertThat(ContractDescriptions.untranslated(DUTCH)) + .contains("OrderV1.lines[].sku", "OrderV1.customer.address.city", "ProblemV1.title") + .doesNotContain("OrderV1", "OrderV1.customer.name", "OrderV1.lines[].quantity"); + } + + @Test + void everyKeyInTheBundlesNamesSomethingTheContractDescribes() throws Exception { + // A renamed schema or a removed field leaves its keys behind, and nothing uses + // them again. This is how a project finds them: against the keys the tree asks for. + Map> orphans = new TreeMap<>(); + for (String file : List.of("docs/Descriptions.properties", "docs/Descriptions_nl.properties")) { + java.util.Properties bundle = new java.util.Properties(); + try (var in = getClass().getClassLoader().getResourceAsStream(file); + var reader = new java.io.InputStreamReader(in, java.nio.charset.StandardCharsets.UTF_8)) { + bundle.load(reader); + } + Set unused = new TreeSet<>(bundle.stringPropertyNames()); + unused.removeAll(ContractDescriptions.keys()); + if (!unused.isEmpty()) { + orphans.put(file, unused); + } + } + assertThat(orphans).as("keys the contract no longer describes, per file").isEmpty(); + } + + /** REST Docs field descriptors, as path and description. */ + private static Map restDocs(FieldDescriptor[] descriptors) { + return Arrays.stream(descriptors).collect(Collectors.toMap(FieldDescriptor::getPath, + FieldDescriptor::getDescription)); + } +} diff --git a/examples/api-only-transcriberj/description-bundles/src/test/resources/docs/Descriptions.properties b/examples/api-only-transcriberj/description-bundles/src/test/resources/docs/Descriptions.properties new file mode 100644 index 0000000..2365e9c --- /dev/null +++ b/examples/api-only-transcriberj/description-bundles/src/test/resources/docs/Descriptions.properties @@ -0,0 +1,16 @@ +# The descriptions this project writes itself, in English, the contract's language. +# Only some keys are here on purpose: every other description is the contract's own. +# +# A class's key is its name; a field's is ., with the path the field reports. + +# The class itself. +OrderV1=An order, as the shop's customer service team explains it. + +# A field inside a nested object. +OrderV1.customer.address.street=The street and house number, exactly as the customer typed them. + +# A field of the objects in an array: the array's name, then [], then the field. +OrderV1.lines[].sku=The product code printed on the shelf label. + +# The same, in an error schema. +ProblemV1.errors[].message=A sentence the client can show to the person who filled in the form. diff --git a/examples/api-only-transcriberj/description-bundles/src/test/resources/docs/Descriptions_nl.properties b/examples/api-only-transcriberj/description-bundles/src/test/resources/docs/Descriptions_nl.properties new file mode 100644 index 0000000..6bdb93a --- /dev/null +++ b/examples/api-only-transcriberj/description-bundles/src/test/resources/docs/Descriptions_nl.properties @@ -0,0 +1,9 @@ +# Dezelfde beschrijvingen, in het Nederlands. Ook deze vertaling is bewust onvolledig: +# een sleutel die hier ontbreekt, valt terug op de Engelse bundel en daarna op het contract. + +OrderV1=Een bestelling, zoals de klantenservice van de winkel die uitlegt. +OrderV1.id=De unieke code van de bestelling. +OrderV1.customer.name=De naam van de klant. +OrderV1.customer.address.street=De straat en het huisnummer, precies zoals de klant ze intypte. +OrderV1.lines[].quantity=Hoeveel stuks er besteld zijn. +ProblemV1.errors[].message=Een zin die de client kan tonen aan wie het formulier invulde.