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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
The table of contents is too big for display.
Diff view
Diff view
  •  
  •  
  •  
132 changes: 132 additions & 0 deletions .agents/skills/update-openapi-templates/SKILL.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
---
name: update-openapi-templates
description: Reconcile the custom OpenAPI Generator Mustache templates in sdk/java-restclient and sdk/java-resttemplate against upstream after a generator version bump, then regenerate and verify. Use when the openapi-generator version changes, when generation fails with a Mustache parse error, or when checking whether our templates have drifted from upstream.
---

# Update OpenAPI Generator templates

Four SDK modules override generator templates. When the generator version is bumped, upstream
changes must be reconciled into those overrides.

Our deliberate divergences are recorded in `sdk/custom-template-changes.md`. **Read it first** — it is
the list of things a reconciliation must preserve, and each entry says what breaks if it is lost.

## Overridden templates

```
sdk/java-restclient/templates/ (java, library restclient)
sdk/java-resttemplate/templates/ (java, library resttemplate)
sdk/swift/templates/ (swift5)
sdk/python/templates/ (python)
```

Reconcile each against the upstream directory for **its own** generator.

## Steps

### 1 — Identify the versions

```bash
git log --oneline -10 -- gradle/libs.versions.toml
grep -n 'openapi' gradle/libs.versions.toml
```

### 2 — Extract the upstream templates for the version we run

Take them from the generator jar, not GitHub, so they match the running version exactly:

```bash
V=7.22.0
JAR=$(find ~/.gradle/caches/modules-2/files-2.1/org.openapitools/openapi-generator/$V \
-name "openapi-generator-$V.jar" ! -name '*sources*' | head -1)
unzip -o "$JAR" 'Java/*' -d /tmp/upstream-$V
```

### 3 — Diff against the *library-specific* baseline

The `java` generator resolves `pojo.mustache` per library. Diffing against the generic
`Java/pojo.mustache` gives plausible but wrong results — it lacks the JSpecify type-position wiring
these libraries use, and reconciling against it silently drops nullability annotations.

| Module | Baseline |
| --- | --- |
| `sdk/java-restclient` | `Java/libraries/restclient/pojo.mustache` |
| `sdk/java-resttemplate` | `Java/libraries/resttemplate/pojo.mustache` |

The two differ from each other — reconcile each module separately.

Our copies follow upstream formatting. If a diff is unreadable because of indentation noise, compare
whitespace-insensitively (`diff -uwB`) to find the semantic changes.

### 4 — Reconcile

Prefer re-forking from the upstream baseline and re-applying the divergences in
`sdk/custom-template-changes.md` over hand-patching our copy. Re-forking picks up upstream fixes that
are dormant for our spec today (they render nothing now, but will if the spec grows into them);
hand-patching only ever finds what you already went looking for.

Classify each upstream change:

- **Cosmetic / structural** — take it as-is.
- **Touches `required` / `isNullable` / `x-is-jackson-optional-nullable`** — re-apply through the
polarity swap in change (1) of `custom-template-changes.md`.
- **New upstream behaviour we do not customise** — take it, and do not document it.
- **Superseded by one of our divergences** — skip, and check the divergence still reads correctly
against the new upstream code.

### 5 — Check tag balance before generating

A mismatched section tag fails generation with `Section close tag with mismatched open tag`, naming a
line in the *rendered* partial rather than in the file at fault. Verify balance directly:

```bash
python3 - sdk/java-restclient/templates/pojo.mustache <<'PY'
import re, sys
stack = []
for m in re.finditer(r'\{\{([#^/])([^}]+)\}\}', open(sys.argv[1]).read()):
kind, name = m.group(1), m.group(2).strip()
if kind in '#^':
stack.append(name)
else:
assert stack and stack[-1] == name, f"close {name} vs open {stack[-1] if stack else None}"
stack.pop()
assert not stack, f"unclosed: {stack}"
print("balanced")
PY
```

### 6 — Regenerate and build

Compilation depends on generation, so a build regenerates first:

```bash
source "$HOME/.sdkman/bin/sdkman-init.sh" && sdk env && \
./gradlew :java-restclient:build :java-resttemplate:build
```

### 7 — Verify against the previous output

Copy the generated models aside *before* regenerating, then compare. Whitespace will move when
upstream reformats; what matters is that these hold:

- `toString` line count unchanged — the `x-log` privacy allow-list survived
- no `_JsonNullable` occurrences — the public API shape survived
- `JsonNullable<@Nullable` present — JSpecify annotates the value, not the wrapper
- `@Nullable` in type position (`private @Nullable String x;`)
- no `@jakarta.annotation.Nullable` / `Nonnull` left in models

The `toString` check is the important one: losing it is invisible in a build and starts writing
personal data to logs.

### 8 — Update the documentation

Revise `sdk/custom-template-changes.md` so it describes only what still differs from upstream. Drop
entries that upstream has absorbed.

### 9 — Commit

Commit the templates, the regenerated sources and the documentation together:

```
Update OpenAPI generator templates to v<NEW_VERSION>
```
1 change: 1 addition & 0 deletions .claude/skills/update-openapi-templates
146 changes: 146 additions & 0 deletions .github/workflows/sdk.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,146 @@
name: SDK

on:
push:
branches: [main]
paths: ['spec/**', 'sdk/**', 'buildSrc/**', 'gradle/**', 'build.gradle', 'settings.gradle', '.github/workflows/sdk.yml']
pull_request:
paths: ['spec/**', 'sdk/**', 'buildSrc/**', 'gradle/**', 'build.gradle', 'settings.gradle', '.github/workflows/sdk.yml']
workflow_dispatch:

permissions:
contents: read
checks: write

concurrency:
group: sdk-${{ github.event.pull_request.head.ref || github.ref_name }}
cancel-in-progress: true

jobs:
generated-sources:
name: Generated sources
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6

- uses: troii-actions/setup-jvm@v1
with:
java-version-file: .sdkmanrc

- name: Generate SDKs
run: ./gradlew openApiGenerate --scan

- name: Verify generated sources are committed
run: |
# `git diff` alone ignores untracked files, so a newly emitted generated
# file would pass while missing from the repository.
git add --all --intent-to-add .
git diff --exit-code || (
Comment on lines +33 to +38

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Include untracked generated files in the drift check

Plain git diff --exit-code ignores untracked files, so when a specification change generates a new API or model file without modifying an existing tracked file, this verification step succeeds even though that generated source was never committed. Check the porcelain status or otherwise include untracked output so CI enforces the documented no-drift invariant.

AGENTS.md reference: AGENTS.md:L56-L58

Useful? React with 👍 / 👎.

echo "Generated sources are out of date. Run ./gradlew openApiGenerate and commit the results."
exit 1
)

jvm:
name: ${{ matrix.name }}
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
include:
- name: Java RestClient
module: java-restclient
path: sdk/java-restclient
- name: Java RestTemplate
module: java-resttemplate
path: sdk/java-resttemplate
- name: Kotlin OkHttp
module: kotlin-okhttp
path: sdk/kotlin-okhttp
steps:
- uses: actions/checkout@v6

- uses: troii-actions/setup-jvm@v1
with:
java-version-file: .sdkmanrc

- name: Assemble and test
run: ./gradlew :${{ matrix.module }}:assemble :${{ matrix.module }}:test --scan

- name: Publish test report
if: failure()
uses: mikepenz/action-junit-report@v6
with:
report_paths: '${{ matrix.path }}/build/test-results/test/TEST-*.xml'
check_name: Tests ${{ matrix.name }}
detailed_summary: true

dotnet:
name: .NET
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6

- uses: troii-actions/setup-jvm@v1
with:
java-version-file: .sdkmanrc

- uses: actions/setup-dotnet@v5
with:
dotnet-version: |
8.x
10.x

- name: Build .NET SDK
run: ./gradlew :dotnet:dotnetBuild --scan

swift:
name: Swift
runs-on: macos-latest
steps:
- uses: actions/checkout@v6

- uses: troii-actions/setup-jvm@v1
with:
java-version-file: .sdkmanrc

- name: Assemble Swift SDK
run: ./gradlew :swift:assemble --scan

python:
name: Python
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6

- uses: actions/setup-python@v6
with:
python-version: '3.9'

- name: Install SDK
working-directory: sdk/python
run: |
python -m pip install --upgrade pip
pip install .

# Importing the package exercises every module, so a generator change that
# emits invalid Python fails here. mypy is not run yet: the generator emits a
# duplicate "OAuth2" AuthSettings key for our two-flow OAuth2 scheme.
- name: Verify the package imports
working-directory: sdk/python
run: python -c "import timr_api; print(timr_api.__name__)"

typescript:
name: TypeScript
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6

- uses: actions/setup-node@v4
with:
node-version: '22'

- name: Build TypeScript SDK
working-directory: sdk/typescript-fetch
run: |
npm install
npm run build
6 changes: 5 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -11,11 +11,15 @@ site/.spec.overlaid.yaml
!src/**/build/
.gradletasknamecache

# Python packaging metadata, written next to the package by setuptools and pip install -e
*.egg-info/

# IntelliJ
.idea/*
!.idea/codeStyles
!.idea/runConfigurations
*.iws
out/

.claude/
.claude/*
!.claude/skills
3 changes: 3 additions & 0 deletions .sdkmanrc
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
# Enable auto-env through the sdkman_auto_env config
# Add key=value pairs of SDKs to use below
java=25-tem
73 changes: 63 additions & 10 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,20 +15,28 @@ finished until the README says so.
which overwrites the file. Editing it in this repository has no effect and no lifetime; a contract
problem is raised as an issue and corrected at the source.

Everything a tool needs on top of the contract belongs in an overlay, not in the specification: the
try-it console settings live in `spec/overlay.yaml`, applied to the contract by the site build.
Everything a generator needs on top of the contract belongs in an overlay, not in the specification:
try-it console settings, environment-specific servers, and generator capabilities such as `x-parent`.
Whether a generator can express inheritance is a property of that generator — the Java modules ask
for it in their own overlay, while Kotlin cannot extend a final data class and flattens instead.

## Overlays

Overlays are [OpenAPI Overlay 1.0](https://spec.openapis.org/overlay/v1.0.0) documents. The site
applies `spec/overlay.yaml` with bump-cli through `site/scripts/compose-spec.mjs`.
Overlays are [OpenAPI Overlay 1.0](https://spec.openapis.org/overlay/v1.0.0) documents. The format is
what the two builds share; each applies it with the tool native to its own toolchain, because there
is no implementation that serves both. The documentation site applies `spec/overlay.yaml` with
bump-cli through `site/scripts/compose-spec.mjs`. SDK generation applies `sdk/overlay.yaml` to every module and `sdk/<module>/openapi-overlay.yaml` to one, in that order, through `ApplyOpenApiOverlaysTask` — OpenAPI Generator has no overlay support of its own, and its normalizer only offers a fixed set of rules.

**An action whose target matches nothing fails the build.** This is deliberate and stricter than the
specification allows, which asks only that such an action be skipped — and a silently skipped action
is how an overlay drifts away from the contract it patches. bump-cli reports it as a warning and
exits successfully, so the site build turns that warning into an error itself. When a specification
sync breaks an overlay, repoint the action; do not delete it without establishing what its target
became.
**An action whose target matches nothing fails the build, on both sides.** This is deliberate and
stricter than the specification allows, which asks only that such an action be skipped — and a
silently skipped action is how an overlay drifts away from the contract it patches. bump-cli reports
it as a warning and exits successfully, so the site build turns that warning into an error itself.
When a specification sync breaks an overlay, repoint the action; do not delete it without
establishing what its target became.

`x-log` is an allow-list: a field is absent from generated `toString` output unless it is named
there, and `x-log-count` records a collection's size instead of its contents. Adding a field is a
deliberate decision about what may reach a log, not a formality.

## The documentation site

Expand All @@ -45,3 +53,48 @@ A pull request builds the site and attaches it as an artifact; it is not deploye
console only completes its flow on a redirect URI registered with the authorization server, and a
preview URL is not one.

## Generated sources are committed

Regenerate and commit whenever the specification or an overlay changes; CI fails on any drift.

```bash
./gradlew openApiGenerate
```

Generation is wired into the build: every task that reads the generated sources declares a dependency
on `openApiGenerate`, so `./gradlew build` regenerates first and a single invocation is fine. This is
what makes a green build meaningful — before the dependency existed, `build` compiled the *previous*
generation's output and passed even when the current templates or specification did not.

Gradle modules are named after their directories under `sdk/`:

```bash
./gradlew :kotlin-okhttp:assemble
```

Each SDK carries its own version in `sdk/versions.properties`; `VERSIONING.md` says when which one
moves.

## Custom generator templates

The Java modules override generator templates under `sdk/java-*/templates`. Every deliberate
divergence from upstream is recorded in `sdk/custom-template-changes.md` — read it before touching a
template, and keep it accurate afterwards.

Two things there are easy to break silently: `toString` is an `x-log` allow-list that keeps personal
data out of logs, and the `java` generator resolves `pojo.mustache` **per library**, so the upstream
baseline is `Java/libraries/<library>/pojo.mustache`, not `Java/pojo.mustache`.

Use the `update-openapi-templates` skill when the generator version changes.

## Skills

Reusable agent workflows live in `.agents/skills/<skill-name>/SKILL.md` — the single source of truth.
Claude Code picks them up through a symlink:

```
.claude/commands/<skill-name>.md -> ../../.agents/skills/<skill-name>/SKILL.md
```

When adding a skill, create the `SKILL.md` (with `name:` and `description:` frontmatter) and the
symlink. Never duplicate the content.
Loading
Loading