diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml
index dace954..04ccfdf 100644
--- a/.github/workflows/build.yml
+++ b/.github/workflows/build.yml
@@ -32,44 +32,45 @@ jobs:
distribution: "temurin"
cache: maven
- # -----------------------------
- # π§ Core modules (publishable)
- # -----------------------------
- name: Build core modules
run: mvn -B -q -ntp clean verify
- # -----------------------------
- # π§ͺ Samples (tests + coverage)
- # -----------------------------
- - name: Build samples (tests + coverage)
+ - name: Build samples
run: mvn -B -q -ntp -f samples/pom.xml clean verify
- # -----------------------------
- # π¦ Generated Client Sources
- # -----------------------------
- - name: Upload generated client sources
+ - name: Upload generated client sources (SB3)
uses: actions/upload-artifact@v4
with:
- name: generated-client-sources
- path: samples/customer-service-client/target/generated-sources/openapi/src/gen/java
+ name: generated-client-sources-sb3
+ path: samples/spring-boot-3/customer-service-client/target/generated-sources/openapi/src/gen/java
if-no-files-found: warn
retention-days: 7
- # -----------------------------
- # π OpenAPI Spec
- # -----------------------------
- - name: Upload OpenAPI spec (YAML)
+ - name: Upload generated client sources (SB4)
uses: actions/upload-artifact@v4
with:
- name: customer-api-docs
- path: samples/customer-service-client/target/classes/customer-api-docs.yaml
+ name: generated-client-sources-sb4
+ path: samples/spring-boot-4/customer-service-client/target/generated-sources/openapi/src/gen/java
if-no-files-found: warn
retention-days: 7
- # -----------------------------
- # π Coverage (samples only)
- # -----------------------------
- - name: Upload coverage (samples)
+ - name: Upload OpenAPI spec (SB3)
+ uses: actions/upload-artifact@v4
+ with:
+ name: customer-api-docs-sb3
+ path: samples/spring-boot-3/customer-service-client/src/main/resources/customer-api-docs.yaml
+ if-no-files-found: warn
+ retention-days: 7
+
+ - name: Upload OpenAPI spec (SB4)
+ uses: actions/upload-artifact@v4
+ with:
+ name: customer-api-docs-sb4
+ path: samples/spring-boot-4/customer-service-client/src/main/resources/customer-api-docs.yaml
+ if-no-files-found: warn
+ retention-days: 7
+
+ - name: Upload coverage (samples only)
uses: codecov/codecov-action@v4
with:
token: ${{ secrets.CODECOV_TOKEN }}
diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml
index 26865ce..f14ab38 100644
--- a/.github/workflows/codeql.yml
+++ b/.github/workflows/codeql.yml
@@ -28,17 +28,11 @@ jobs:
language: [java-kotlin]
steps:
- # -----------------------------
- # π₯ Checkout
- # -----------------------------
- name: Checkout repository
uses: actions/checkout@v4
with:
fetch-depth: 0
- # -----------------------------
- # β JDK
- # -----------------------------
- name: Set up JDK 21
uses: actions/setup-java@v4
with:
@@ -46,28 +40,16 @@ jobs:
distribution: "temurin"
cache: maven
- # -----------------------------
- # π Initialize CodeQL
- # -----------------------------
- name: Initialize CodeQL
uses: github/codeql-action/init@v3
with:
languages: ${{ matrix.language }}
- # -----------------------------
- # π Build core (publishable modules)
- # -----------------------------
- name: Build core modules
- run: mvn -B -ntp -DskipTests clean package
+ run: mvn -B -ntp -DskipTests clean verify
- # -----------------------------
- # π§ͺ Build samples (runtime coverage)
- # -----------------------------
- name: Build samples
- run: mvn -B -ntp -DskipTests -f samples/pom.xml clean package
+ run: mvn -B -ntp -DskipTests -f samples/pom.xml clean verify
- # -----------------------------
- # π Analyze
- # -----------------------------
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@v3
\ No newline at end of file
diff --git a/README.md b/README.md
index 5df6b2a..762b849 100644
--- a/README.md
+++ b/README.md
@@ -1,11 +1,11 @@
# OpenAPI Generics for Spring Boot β Keep Your API Contract Intact End-to-End
[](https://github.com/blueprint-platform/openapi-generics/actions/workflows/build.yml)
-[](https://github.com/blueprint-platform/openapi-generics/releases/latest)
+[](https://github.com/blueprint-platform/openapi-generics/releases/latest)
[](https://github.com/blueprint-platform/openapi-generics/actions/workflows/codeql.yml)
[](https://codecov.io/gh/blueprint-platform/openapi-generics)
[](https://openjdk.org/)
-[](https://spring.io/projects/spring-boot)
+[](https://spring.io/projects/spring-boot)
[](https://openapi-generator.tech/)
[](LICENSE)
@@ -25,326 +25,219 @@
## π Table of Contents
-* π§ [Architectural Thesis](#architectural-thesis)
-* β‘ [Real Usage (What you actually do)](#-real-usage-what-you-actually-do-in-your-project)
-* β‘ [Quick Start](#-quick-start-2-minutes)
-* π§© [What This Repository Is (and Is Not)](#what-this-repository-is-and-is-not)
-* π [Contract Lifecycle Model](#contract-lifecycle-model)
-* π¨ [The Problem](#the-problem)
-* π§ [Where This Architecture Helps](#where-this-architecture-helps)
-* π‘ [Core Architectural Idea](#core-architectural-idea)
-* π§± [Canonical Contract Scope](#canonical-contract-scope)
-* π« [Architectural Non-Goals](#architectural-non-goals)
+* β‘ [Why this exists (practical impact)](#why-this-exists-practical-impact)
+* π [Whatβs new in 0.9.x](#whats-new-in-09x)
+* β‘ [Real usage (what you actually do)](#real-usage-what-you-actually-do)
+* β‘ [Quick start (2 minutes)](#quick-start-2-minutes)
+* π [Contract lifecycle model](#contract-lifecycle-model)
+* π‘ [Core idea](#core-idea)
* π [System Architecture Overview](#system-architecture-overview)
-* π [Proof β Generated Client Models (Before / After)](#proof--generated-client-models-before--after)
-* π§ [Design Guarantees](#design-guarantees)
+* π [Proof β before vs after](#proof--before-vs-after)
+* π§ [Design guarantees](#design-guarantees)
* π¦ [Modules](#modules)
-* π¦ [Maven Central](#-maven-central)
-* π§ [Architecture](#-architecture)
-* π [Adoption Guides](#adoption-guides)
+* π [Adoption guides](#adoption-guides)
* π [References](#references)
* π€ [Contributing](#contributing)
* π‘ [License](#license)
---
-## Architectural Thesis
+## Why this exists (practical impact)
-Most teams treat OpenAPI as a source of truth.
+In most OpenAPI-based systems:
-That decision quietly shifts ownership of your API contract from your code to a schema generator.
+* generics are flattened or lost
+* response envelopes are regenerated per endpoint
+* client models drift from server contracts over time
-This repository takes the opposite stance:
+This creates **hidden long-term cost**:
-> **The Java contract is the source of truth. OpenAPI is only a projection.**
-
-When this boundary is preserved, client generation stops being a lossy transformation and becomes a deterministic extension of your runtime model.
-
-The result is simple but important:
-
-* no duplicated envelope models
-* no generic type loss
-* no schema drift between producer and consumer
+* duplicated DTO hierarchies
+* fragile client regeneration
+* broken assumptions across services
----
+This project removes that entire class of problems.
-> **Background reading**
->
-> This repository demonstrates the implementation pattern.
-> For the architectural reasoning and real-world context behind it:
->
-> * [We Made OpenAPI Generator Think in Generics](https://medium.com/@baris.sayli/type-safe-generic-api-responses-with-spring-boot-3-4-openapi-generator-and-custom-templates-ccd93405fb04)
+> **Define your contract once in Java β reuse it everywhere without drift.**
---
-## β‘ Real Usage (What you actually do in your project)
+## Whatβs new in 0.9.x
-You do **NOT** copy anything from this repository.
+This is no longer a βtemplate tweakβ.
-You only add the required building blocks:
+It is now a **contract-aligned generation system with progressive adoption**.
-### Server (producer)
+### 1. Bring Your Own Contract (BYOC)
-Add the starter:
+Reuse your own domain models instead of generating them:
```xml
->` | Default OpenAPI behavior | Treated as a regular schema |
-| Arbitrary nested generics | Default OpenAPI behavior | No contract-level guarantees |
-
----
-
-### Mental model
+The response envelope is a **shared contract**, not a generated model.
```text
-Use anything β OpenAPI works
-Use contract-aware shapes β platform guarantees stability
+ServiceResponse
@@ -402,13 +266,7 @@ class ServiceResponsePageCustomerDto {
}
```
-* envelope duplicated
-* generics lost
-* drift risk introduced
-
----
-
-### After β contract-bound wrappers
+### After
Share one canonical envelope
Used for wrapper models (e.g. ServiceResponse<T>) where T is an external contract type
+ * and must be imported instead of generated.
+ *
+ * Flow:
+ *
+ * Used to prevent generation of shared contract models and reference them instead.
+ *
+ * Configuration format:
+ *
+ * Models marked with {@code x-ignore-model: true} are:
+ * Responsibilities:
*
* This ensures that:
- *
- * Design Principle: Design note: This class only orchestrates the flow. Actual decisions (ignore, import
+ * resolution) are delegated to dedicated components.
*/
public class GenericAwareJavaCodegen extends JavaClientCodegen {
- private static final Logger log =
- LoggerFactory.getLogger(GenericAwareJavaCodegen.class);
-
- /**
- * Vendor extension key used in OpenAPI schemas to mark models as non-generatable.
- */
- private static final String EXT_IGNORE_MODEL = "x-ignore-model";
+ private static final Logger log = LoggerFactory.getLogger(GenericAwareJavaCodegen.class);
- /**
- * Holds model names that should be excluded from generation.
- */
- private final Set This phase does NOT remove models yet. It only records intent.
- */
- @Override
- public CodegenModel fromModel(String name, Schema model) {
+ log.debug("Generic-aware codegen initialized with external model registry");
+ }
- CodegenModel codegenModel = super.fromModel(name, model);
+ /** Marks models that should be ignored and cleans their imports. */
+ @Override
+ public CodegenModel fromModel(String name, Schema model) {
+ CodegenModel cm = super.fromModel(name, model);
- Map This prevents template-level generation for those models.
- */
- @Override
- public ModelsMap postProcessModels(ModelsMap modelsMap) {
+ result
+ .getModels()
+ .removeIf(
+ m -> {
+ CodegenModel model = m.getModel();
+ return model != null && ignoreDecider.isIgnored(model.name);
+ });
- ModelsMap result = super.postProcessModels(modelsMap);
+ int after = result.getModels().size();
- if (result == null || result.getModels() == null) {
- return result;
- }
-
- result.getModels().removeIf(modelMap -> {
- CodegenModel model = modelMap.getModel();
- return model != null && shouldIgnore(model.name);
- });
-
- return result;
+ if (before != after) {
+ log.debug("Filtered ignored models: {} -> {}", before, after);
}
- // ================================
- // PHASE 3 β GLOBAL REMOVE (CRITICAL)
- // ================================
-
- /**
- * Completely removes ignored models from the global model map.
- *
- * This is the critical phase which ensures:
- * A model is ignored if:
+ *
+ * Also keeps track of ignored models for later filtering steps.
+ */
+public class ModelIgnoreDecider {
+
+ private static final Logger log = LoggerFactory.getLogger(ModelIgnoreDecider.class);
+
+ private static final String EXT_IGNORE_MODEL = "x-ignore-model";
+
+ private final Set
@@ -419,93 +277,31 @@ public class ServiceResponsePageCustomerDto
extends ServiceResponse>`
+- arbitrary nested generics
+- maps
+- generic combinations outside the guaranteed scope
+
+It also unwraps common outer wrappers such as:
+- `ResponseEntity`
+- `CompletionStage`
+- `Future`
+- `DeferredResult`
+- `WebAsyncTask`
+
+---
+
+## Base schema model
+`BaseSchemaRegistrar` ensures the canonical base schemas exist:
+
+- `Sort`
+- `Meta`
+- `ServiceResponse`
+- `ServiceResponseVoid`
+
+Properties:
+- idempotent
+- non-invasive
+- contract-aligned
+
+So base schemas are treated as part of the projection engine, not as accidental Springdoc output.
+
+---
+
+## Wrapper schema generation
+`WrapperSchemaProcessor` + `ServiceResponseSchemaFactory` handle wrapper creation.
+
+Behavior:
+- wrapper schemas are **authoritatively rebuilt**
+- existing schemas are replaced, not patched
+- wrapper structure is always normalized from contract rules
+
+Wrapper model:
+- base `ServiceResponse`
+- composed with `allOf`
+- `data` field overridden with concrete target schema
+
+Examples:
+- `ServiceResponseCustomerDto`
+- `ServiceResponsePageCustomerDto`
+
+---
+
+## Vendor extension model
+The server side now defines a clear OpenAPI semantic layer using vendor extensions:
+
+- `x-api-wrapper`
+- `x-api-wrapper-datatype`
+- `x-class-extra-annotation`
+- `x-data-container`
+- `x-data-item`
+- `x-ignore-model`
+
+This is effectively a **custom projection DSL** used by client generation.
+
+---
+
+## Container enrichment
+`WrapperSchemaEnricher` adds semantic metadata for supported containers.
+
+Current default container:
+- `Page`
+
+It enriches wrappers with:
+- `x-data-container`
+- `x-data-item`
+
+This makes `Page
ServiceResponse<T>
- across server + client β generate thin wrappers like
+ across server + client β reuse your own shared contract models and generate thin wrappers like
ServiceResponse<Page<FooDto>>
with zero duplication.
-
* duplicated envelope per endpoint
* generics flattened or lost
* unstable and verbose model graph
@@ -172,18 +185,18 @@ public class ServiceResponsePageCustomerDto
* no envelope duplication
* generics preserved end-to-end
-* contract types reused directly
+* externally owned contract models reused directly
---
-
## What changed
Instead of generating new models from OpenAPI:
-* the contract is preserved
+* the server projects the Java contract into OpenAPI deterministically
+* external contract models can be explicitly mapped and reused
* wrappers are generated as thin type bindings
-* the client reuses existing domain semantics
+* the generator enforces contract alignment instead of passively materializing schemas
Result:
@@ -194,7 +207,7 @@ OpenAPI (projection, not authority)
β
Generator (deterministic reconstruction)
β
-Client (canonical contract types)
+Client (contract-aligned types)
```
No reinterpretation.
@@ -207,7 +220,9 @@ No drift.
The client does **not** recreate your models.
-Instead, it generates **thin wrapper classes** that bind OpenAPI responses back to your canonical contract.
+If you provide shared contract models, they are reused directly via explicit mapping. If you do not, the platform still preserves the generic response structure and generates the required wrappers deterministically.
+
+Instead, the generator produces **thin wrapper classes** that bind OpenAPI responses back to your canonical contract shape.
Example:
@@ -226,6 +241,7 @@ Key properties:
* no envelope duplication
* no structural redefinition
* no generic type loss
+* shared contract reuse is supported, but not mandatory
These classes exist only to bridge OpenAPI β Java type system.
@@ -235,7 +251,7 @@ These classes exist only to bridge OpenAPI β Java type system.
You never interact with generated wrappers directly.
-Instead, you define an adapter layer:
+Instead, you define an adapter boundary:
```java
public interface CustomerClientAdapter {
@@ -249,7 +265,7 @@ public interface CustomerClientAdapter {
}
```
-Implementation delegates to generated API:
+Implementation delegates to the generated API:
```java
@Service
@@ -273,6 +289,8 @@ public class CustomerClientAdapterImpl implements CustomerClientAdapter {
}
```
+If you choose to keep shared DTO ownership outside the generated client, this adapter boundary is also where you isolate generated request/response details from the rest of your application.
+
---
## What this gives you
@@ -286,26 +304,31 @@ ServiceResponse
+ *
+ */
+public class ExternalImportResolver {
+
+ private static final Logger log = LoggerFactory.getLogger(ExternalImportResolver.class);
+
+ private static final String EXT_API_WRAPPER = "x-api-wrapper";
+ private static final String EXT_DATA_ITEM = "x-data-item";
+ private static final String EXT_WRAPPER_DATATYPE = "x-api-wrapper-datatype";
+ private static final String EXT_EXTRA_IMPORTS = "x-extra-imports";
+
+ private final ExternalModelRegistry registry;
+
+ public ExternalImportResolver(ExternalModelRegistry registry) {
+ this.registry = registry;
+ }
+
+ /** Injects external import if the model uses an external type. */
+ public void apply(CodegenModel model) {
+ if (!isWrapperModel(model)) return;
+
+ Map
+ * openapiGenerics.externalModel.CustomerDto=io.example.contract.CustomerDto
+ *
+ */
+public class ExternalModelRegistry {
+
+ private static final Logger log = LoggerFactory.getLogger(ExternalModelRegistry.class);
+
+ private static final String PREFIX = "openapiGenerics.responseContract.";
+
+ private final Map
- *
*
- *
- *
- *
- *
- * Java contract is the authority, OpenAPI is a projection. This generator enforces that
- * projection must not re-materialize platform-owned types.
+ *
- *
- */
- @Override
- public Map
+ *
+ *
+ *