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 [![Build](https://github.com/blueprint-platform/openapi-generics/actions/workflows/build.yml/badge.svg)](https://github.com/blueprint-platform/openapi-generics/actions/workflows/build.yml) -[![Release](https://img.shields.io/github/v/release/blueprint-platform/openapi-generics?label=release&logo=github)](https://github.com/blueprint-platform/openapi-generics/releases/latest) +[![Release](https://img.shields.io/github/v/release/blueprint-platform/openapi-generics?label=release\&logo=github)](https://github.com/blueprint-platform/openapi-generics/releases/latest) [![CodeQL](https://github.com/blueprint-platform/openapi-generics/actions/workflows/codeql.yml/badge.svg)](https://github.com/blueprint-platform/openapi-generics/actions/workflows/codeql.yml) [![codecov](https://codecov.io/gh/blueprint-platform/openapi-generics/branch/main/graph/badge.svg)](https://codecov.io/gh/blueprint-platform/openapi-generics) [![Java](https://img.shields.io/badge/Java-17%2B-red?logo=openjdk)](https://openjdk.org/) -[![Spring Boot](https://img.shields.io/badge/Spring%20Boot-3.4.x%20%7C%203.5.x-green?logo=springboot)](https://spring.io/projects/spring-boot) +[![Spring Boot](https://img.shields.io/badge/Spring%20Boot-3.4.x%20%7C%203.5.x%20%7C%204.x-green?logo=springboot)](https://spring.io/projects/spring-boot) [![OpenAPI Generator](https://img.shields.io/badge/OpenAPI%20Generator-7.x-blue?logo=openapiinitiative)](https://openapi-generator.tech/) [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](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 - - io.github.blueprintplatform - openapi-generics-server-starter - 0.8.x - -``` - -### Client (consumer) - -Inherit the parent: - -```xml - - io.github.blueprintplatform - openapi-generics-java-codegen-parent - 0.8.x - + + + openapiGenerics.responseContract.CustomerDto= + io.example.contract.CustomerDto + + ``` -That’s it. +Result: -Everything else in this repository is a **reference implementation of this setup**. -For detailed setup and advanced usage, see the [Adoption Guides](#adoption-guides). +* no duplicated DTOs +* full control over model ownership --- -## ⚑ Quick Start (2 minutes) - -> Requires Java 17+, Spring Boot 3.4.x / 3.5.x, and OpenAPI Generator 7.x -> See module documentation for full compatibility details. - -This repository demonstrates a **contract-first, generics-aware API lifecycle**. - -The fastest way to understand it is to: +### 2. Progressive adoption (no lock-in) -1. run the producer service -2. inspect the generated OpenAPI -3. generate a client -4. verify that contract semantics are preserved +Switch between modes safely: ---- - -### 1. Run the sample producer - -```bash -cd samples/customer-service -mvn clean package -java -jar target/customer-service-*.jar +```xml +true ``` -Verify the API is running: +| Mode | Behavior | +| ----------------- | --------------------------- | +| `false` (default) | Contract-aware generation | +| `true` | Standard OpenAPI generation | -* Swagger UI: [http://localhost:8084/customer-service/swagger-ui/index.html](http://localhost:8084/customer-service/swagger-ui/index.html) -* OpenAPI: [http://localhost:8084/customer-service/v3/api-docs.yaml](http://localhost:8084/customer-service/v3/api-docs.yaml) +> Adopt incrementally β€” not all-or-nothing. --- -### 2. Observe the contract shape +### 3. Deterministic build pipeline -```bash -curl http://localhost:8084/customer-service/v1/customers/1 -``` - -```json -{ - "data": { ... }, - "meta": { ... } -} -``` +Client generation is a **controlled execution system**: -This shape is defined by the **Java contract**, not OpenAPI. +* upstream templates are patched safely +* contract semantics are injected +* upstream drift fails the build early --- -### 3. Generate the client +### 4. End-to-end samples (SB3 + SB4) -```bash -cd samples/customer-service-client +Full pipelines are included: -curl -s http://localhost:8084/customer-service/v3/api-docs.yaml \ - -o src/main/resources/customer-api-docs.yaml +* Spring Boot 3 +* Spring Boot 4 +* producer β†’ client β†’ consumer -mvn clean install -``` +Browse: ---- +* [samples/](samples/) -### 4. Inspect generated sources +--- -```java -public class ServiceResponsePageCustomerDto - extends ServiceResponse> {} -``` +## Real usage (what you actually do) -* βœ” No duplicated envelope -* βœ” Generics preserved -* βœ” Contract reused +You do **NOT** copy code from this repo. ---- +You only add two building blocks. -### What just happened? +### Server (producer) -```text -Java Contract (SSOT) - ↓ -Server (projection) - ↓ -OpenAPI (projection artifact) - ↓ -Generator (enforcement) - ↓ -Client (contract-aligned) +```xml + + io.github.blueprintplatform + openapi-generics-server-starter + 0.9.x + ``` ---- - -### 🧠 How this maps to real usage +### Client (consumer) -```text -Add dependency (server) - ↓ -Add parent (client) - ↓ -Run your service - ↓ -Generate client +```xml + + io.github.blueprintplatform + openapi-generics-java-codegen-parent + 0.9.x + ``` -There is no manual model copying. -There is no schema tweaking. -There is no duplication. +Optional: -> The entire system works by aligning build-time and runtime around a single contract. +```xml +true +``` --- -## What This Repository Is (and Is Not) +## Quick start (2 minutes) -This repository is a **reference architecture**, not a framework. +1. Run a sample producer (Spring Boot 3 or 4): -It demonstrates how to: - -* keep API contracts as runtime-first abstractions -* publish OpenAPI as a deterministic projection -* generate clients without redefining models -* isolate generated code behind stable boundaries +```bash +cd samples/spring-boot-3/customer-service +mvn clean package +java -jar target/customer-service-*.jar +``` -It does **not** attempt to provide: +Verify: -* a complete platform solution -* a universal OpenAPI replacement -* a prescriptive architecture for all systems +* Swagger UI: [http://localhost:8084/customer-service/swagger-ui/index.html](http://localhost:8084/customer-service/swagger-ui/index.html) +* OpenAPI: [http://localhost:8084/customer-service/v3/api-docs.yaml](http://localhost:8084/customer-service/v3/api-docs.yaml) --- -## Contract Lifecycle Model +2. Generate a client from the same pipeline: +```bash +cd samples/spring-boot-3/customer-service-client +mvn clean install ``` -β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” -β”‚ Canonical Contract (SSOT) β”‚ -β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ - ↓ -β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” -β”‚ OpenAPI Projection (deterministic) β”‚ -β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ - ↓ -β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” -β”‚ Client Generation (enforced) β”‚ -β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ - ↓ -β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” -β”‚ Application Usage (adapter-bound) β”‚ -β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ -``` - -Each step transforms the contract β€” none of them redefine it. --- -## The Problem +3. Inspect generated output: -Conventional OpenAPI pipelines tend to: - -* regenerate envelope models per endpoint -* flatten generic structures -* introduce unstable schema naming -* drift away from runtime contracts - -This leads to: +```java +public class ServiceResponsePageCustomerDto + extends ServiceResponse> {} +``` -* duplicated DTO hierarchies -* fragile client regeneration -* unclear integration boundaries +βœ” No duplicated envelope +βœ” Generics preserved +βœ” Contract reused end-to-end --- -## Where This Architecture Helps - -This approach is useful when: - -* multiple services share a response envelope -* pagination and metadata must remain consistent -* clients are regenerated frequently -* contract drift is a real risk - -In these cases, **contract identity becomes an architectural concern**. +> Note: Equivalent pipelines exist under `samples/spring-boot-4/...` for Spring Boot 4. --- -## Core Architectural Idea - -The response envelope is treated as a **shared contract**, not a generated artifact. +## Contract lifecycle model ```text -ServiceResponse +Java Contract (SSOT) + ↓ +OpenAPI (projection) + ↓ +Generator (enforcement) + ↓ +Client (contract-aligned) ``` -Key rules: - -* the server publishes semantics, not shapes -* the client reuses contract types -* OpenAPI carries metadata, not authority +> OpenAPI is a projection β€” not the source of truth. --- -## Canonical Contract Scope +## Core idea -| Shape | Behavior | Description | -| -------------------------- | ------------------------ | ------------------------------------ | -| `ServiceResponse` | Contract-aware | Canonical success envelope | -| `ServiceResponse>` | Contract-aware | Deterministic nested generic support | -| `ServiceResponse>` | 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 ``` ---- - -## Architectural Non-Goals +Supported: -This architecture does not attempt to solve: - -* arbitrary generic preservation -* cross-language generator parity -* API versioning strategy -* runtime service concerns - -Focus remains on: +```text +ServiceResponse +ServiceResponse> +``` -> preserving contract semantics during client generation +Everything else falls back to default OpenAPI behavior. --- @@ -356,40 +249,11 @@ Focus remains on: style="max-width:900px; width:100%;"/>

-``` -Contract (SSOT) - ↓ -Projection (runtime) - ↓ -OpenAPI (projection artifact) - ↓ -Generation (enforced) - ↓ -Client (contract-aligned) - ↓ -Application (adapter boundary) -``` - -This system is a **deterministic contract pipeline**. - -* **Contract defines semantics** β†’ (`ServiceResponse`, `Meta`, `Page`) -* **Server projects** β†’ OpenAPI is produced without redefining the model -* **OpenAPI carries structure** β†’ not ownership -* **Generator enforces** β†’ prevents duplication and preserves generics -* **Client reuses contract** β†’ thin wrappers only -* **Application stays isolated** β†’ no generator coupling - -> Each layer transforms the contract β€” none reinterpret it - -**Key idea:** - -> OpenAPI is not the source of truth. It is a projection of the contract. - --- -## Proof β€” Generated Client Models (Before / After) +## Proof β€” before vs after -### Before β€” flattened models +### Before

@@ -402,13 +266,7 @@ class ServiceResponsePageCustomerDto { } ``` -* envelope duplicated -* generics lost -* drift risk introduced - ---- - -### After β€” contract-bound wrappers +### After

@@ -419,93 +277,31 @@ public class ServiceResponsePageCustomerDto extends ServiceResponse> {} ``` -* no duplication -* generics preserved -* contract reused - --- -## Design Guarantees +## Design guarantees -- βœ” Contract identity is preserved -- βœ” Schema naming is deterministic -- βœ” Generator behavior is controlled -- βœ” Client regeneration is safe -- βœ” Error model remains consistent (RFC 9457) +* βœ” Contract identity is preserved +* βœ” Generics are preserved (within supported scope) +* βœ” Client generation is deterministic +* βœ” External models are reusable +* βœ” Upstream drift is detected early --- ## Modules -* **[openapi-generics-contract](openapi-generics-contract/README.md)** - Canonical contract (authority layer) - -* **[openapi-generics-server-starter](openapi-generics-server-starter/README.md)** - Contract β†’ OpenAPI projection - -* **[openapi-generics-java-codegen](openapi-generics-java-codegen/README.md)** - Contract-aware generator - -* **[openapi-generics-java-codegen-parent](openapi-generics-java-codegen-parent/README.md)** - Build orchestration - -* **[customer-service](samples/customer-service/README.md)** - Producer example - -* **[customer-service-client](samples/customer-service-client/README.md)** - Consumer example - ---- - -## πŸ“¦ Maven Central - -All platform modules are published to Maven Central and can be used directly as dependencies. - -**Group ID:** - -``` -io.github.blueprint-platform -``` - -**Version:** - -``` -0.8.x -``` - -### Available Artifacts - -* `openapi-generics` -* `openapi-generics-contract` -* `openapi-generics-server-starter` -* `openapi-generics-java-codegen` -* `openapi-generics-java-codegen-parent` -* `openapi-generics-platform-bom` - -πŸ”Ž Search on Maven Central: -[https://search.maven.org/](https://search.maven.org/) - ---- - -## 🧠 Architecture - -* **[Platform Architecture](docs/architecture/platform.md)** -* **[Server Architecture](docs/architecture/server.md)** -* **[Client Architecture](docs/architecture/client.md)** -* **[Error Handling Strategy](docs/architecture/error-handling.md)** - ---- - -### Key principle - -> Each layer transforms the contract, never redefines it. +* [openapi-generics-contract](openapi-generics-contract/README.md) +* [openapi-generics-server-starter](openapi-generics-server-starter/README.md) +* [openapi-generics-java-codegen](openapi-generics-java-codegen/README.md) +* [openapi-generics-java-codegen-parent](openapi-generics-java-codegen-parent/README.md) --- -## Adoption Guides +## Adoption guides -* **[Server-Side Adoption](docs/adoption/server-side-adoption.md)** -* **[Client-Side Adoption](docs/adoption/client-side-adoption.md)** +* [Server-Side Adoption](docs/adoption/server-side-adoption.md) +* [Client-Side Adoption](docs/adoption/client-side-adoption.md) --- @@ -524,13 +320,16 @@ io.github.blueprint-platform ## Contributing -Architectural discussions, real‑world usage feedback, and evolution proposals are welcome. +Contributions are welcome β€” especially: -Please open an issue or start a discussion in the repository. +* architectural discussions +* real-world usage feedback +* edge cases and integration insights -πŸ‘‰ [Issues](https://github.com/blueprint-platform/openapi-generics/issues) +If you're evaluating or using the project, your perspective is valuable. -πŸ‘‰ [Discussions](https://github.com/blueprint-platform/openapi-generics/discussions) +πŸ‘‰ Open an issue: https://github.com/blueprint-platform/openapi-generics/issues +πŸ‘‰ Start a discussion: https://github.com/blueprint-platform/openapi-generics/discussions --- diff --git a/SECURITY.md b/SECURITY.md index 4629969..51ee7c6 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -28,7 +28,7 @@ We currently provide security fixes for the latest minor release line and the `m | Version | Status | | --------- | --------------- | | `main` | βœ… Supported | -| `0.8.x` | βœ… Supported | +| `0.9.x` | βœ… Supported | | `< 0.8.0` | ❌ Not supported | > **Note** diff --git a/codecov.yml b/codecov.yml index 016a406..f292715 100644 --- a/codecov.yml +++ b/codecov.yml @@ -2,15 +2,11 @@ coverage: status: project: default: - target: 60% - threshold: 1% - patch: - default: - enabled: false + target: 50% + threshold: 2% + patch: off -comment: - layout: "reach, diff, flags, files" - behavior: default +comment: false ignore: - "**/target/**" diff --git a/docs/adoption/client-side-adoption.md b/docs/adoption/client-side-adoption.md index 06025ec..7811871 100644 --- a/docs/adoption/client-side-adoption.md +++ b/docs/adoption/client-side-adoption.md @@ -7,26 +7,26 @@ nav_order: 2 # Client-Side Adoption β€” Contract-First Client Integration -> Generate a Java client that **preserves contract semantics exactly as published** β€” no duplication, no reinterpretation, no drift. +> Generate a Java client that **preserves contract semantics exactly as published** β€” with **progressive adoption**, **zero duplication**, and **no drift**. This is **not a typical OpenAPI client setup**. -It defines a **controlled build-time system** where: +It defines a **controlled but optional build-time system** where: * OpenAPI is treated as input (not authority) * the contract is preserved (not regenerated) -* and the output is deterministic across environments +* the output is deterministic when enabled +* and the system can be **selectively bypassed when needed** This guide defines the **correct client-side integration model** for the platform. -It focuses on three things only: +It focuses on four things: * consuming OpenAPI as input * executing a controlled build pipeline +* optionally aligning with an external contract * using the generated client safely -Everything else is intentionally handled by the platform. - --- ## πŸ“‘ Table of Contents @@ -35,6 +35,7 @@ Everything else is intentionally handled by the platform. * [🎯 What the client actually does](#-what-the-client-actually-does) * [πŸ“₯ Input: OpenAPI (not your contract)](#-input-openapi-not-your-contract) * [πŸ“¦ Minimal setup](#-minimal-setup) +* [🧠 Progressive adoption modes (0.9.x)](#-progressive-adoption-modes-09x) * [πŸ— Build pipeline (what really happens)](#-build-pipeline-what-really-happens) * [🧠 Output: what gets generated](#-output-what-gets-generated) * [πŸš€ Usage: how the client enters your system](#-usage-how-the-client-enters-your-system) @@ -62,6 +63,7 @@ Do this: io.github.blueprintplatform openapi-generics-java-codegen-parent + 0.9.0 ``` @@ -116,7 +118,7 @@ Critical distinction: Implication: * structure comes from OpenAPI -* semantics come from shared contract types +* semantics come from contract types (shared or external) --- @@ -132,36 +134,34 @@ You provide exactly two inputs. Everything else is handled by the platform. io.github.blueprintplatform openapi-generics-java-codegen-parent - 0.8.2 + 0.9.0 ``` This is the **entry point of the system**. -It provides everything required for generation: +It provides: * generator binding (`java-generics-contract`) * template pipeline (extract β†’ patch β†’ overlay) -* contract-aware import mappings * deterministic execution model * generated sources registration -You do NOT add or configure internal dependencies. The parent already wires the system. - -This includes the contract dependency. - -If it is needed, it is already managed by the parent/BOM β€” not by you. - --- ### 2. OpenAPI Generator plugin (USER INPUT ONLY) -Provide: +You control the **input and integration surface only**. + +At minimum: -* input OpenAPI spec -* desired HTTP client (`library`) +* OpenAPI input (`inputSpec`) +* generator (`java-generics-contract`) +* HTTP client (`library`) * package structure +--- + Expand the example below if you need a full configuration.

@@ -182,6 +182,8 @@ Expand the example below if you need a full configuration. + java-generics-contract + ${project.basedir}/src/main/resources/your-api-docs.yaml your-library-choice @@ -191,11 +193,34 @@ Expand the example below if you need a full configuration. your.invoker.package + + true + + + + your-choice false + + + + true + false + + false + false + false + false + @@ -207,134 +232,148 @@ Expand the example below if you need a full configuration. --- -What you control here: +### What you control here -* input OpenAPI spec -* HTTP client (`library`) +* which OpenAPI spec is used +* which HTTP client is generated (`library`) * package structure * serialization strategy +* optional external contract mappings -What you do NOT control: +--- + +### What you do NOT control * generator internals * template system -* contract mappings +* contract semantics +* wrapper generation rules --- -## πŸ— Build pipeline (what really happens) - -This system is **not configuration-driven**. -It is a **controlled execution pipeline**. +### Reference implementations -Full pipeline: +For concrete, working setups: ```text -OpenAPI spec (input) - ↓ -Parent POM (orchestration) - ↓ -Template extraction (upstream) - ↓ -Template patch (api_wrapper injection) - ↓ -Template overlay (custom templates) - ↓ -Custom generator (java-generics-contract) - ↓ -Generated sources (contract-aligned) +samples/ + spring-boot-3/customer-service-client + spring-boot-4/customer-service-client ``` -> Each step is fixed and ordered. No user-defined hooks exist in this pipeline. +These show real configurations for both Spring Boot 3 and Spring Boot 4. --- -### What the platform enforces +## 🧠 Progressive adoption modes (0.9.x) -The build guarantees: +The system is **not all-or-nothing anymore**. -* contract models are NOT generated -* wrapper classes are generated deterministically -* generics are preserved (`ServiceResponse`, `Page`) -* OpenAPI is interpreted β€” not materialized as independent models +It supports **three explicit modes**: --- -### Generated sources integration +### 1. Full contract-aligned mode (default) -Generated code is automatically: +```xml +false +``` -* written to `target/generated-sources/openapi` -* added to the Maven compilation lifecycle +Behavior: -No manual configuration is required. +* deterministic generation enabled +* wrapper classes generated +* contract reused +* generics preserved --- -## 🧠 Configuration boundaries +### 2. Compatibility mode (fallback) -The system is intentionally split into **two control zones**. +```xml +true +``` + +Behavior: + +* falls back to default OpenAPI Generator +* no generics-aware processing +* no contract enforcement + +Use this when: + +* debugging generation differences +* comparing outputs +* gradual migration --- -### User-controlled (safe) +### 3. Bring Your Own Contract (external models) ```xml -... -... -... -... -... -... + + + openapiGenerics.responseContract.CustomerDto=io.example.CustomerDto + + ``` -These control: +Behavior: -* input specification -* HTTP transport layer -* package structure -* generator version +* external models are reused +* no duplicate DTO generation +* wrappers import your existing classes ---- +Implication: -### Platform-controlled (DO NOT override) +> The platform does not own your contract β€” it aligns with it. + +--- -The parent already provides: +### Key idea -* generator name (`java-generics-contract`) -* template directory -* import mappings -* template patching pipeline -* model suppression rules +```text +You can opt in gradually: -These ensure: +Default β†’ External models β†’ Full contract alignment +``` -* contract preservation (`ServiceResponse`, `Page`) -* deterministic wrapper generation -* zero duplication of platform models +This enables **progressive adoption without lock-in**. --- -### Critical rule - -> If you override platform-controlled settings, you leave the contract-safe execution path. +## πŸ— Build pipeline (what really happens) ---- +This system is a **controlled execution pipeline**. -## 🚫 What users should NOT do +```text +OpenAPI spec (input) + ↓ +Parent POM (orchestration) + ↓ +Template extraction (upstream) + ↓ +Template patch (api_wrapper injection) + ↓ +Template overlay (custom templates) + ↓ +Custom generator (java-generics-contract) + ↓ +Generated sources (contract-aligned) +``` -Do NOT: +> Each step is fixed and ordered. -* add internal platform dependencies manually -* override templates -* change generator name -* modify import mappings -* inject custom model logic +--- -Reason: +### What the platform enforces -> The system is intentionally controlled to guarantee determinism and contract alignment. +* contract models are NOT generated +* wrapper classes are deterministic +* generics are preserved +* OpenAPI is interpreted β€” not materialized +--- ## 🧠 Output: what gets generated @@ -356,16 +395,6 @@ Properties: * no duplicated envelopes * direct reuse of contract types -> The wrapper exists only to rebind generics β€” it does not introduce new structure. - -Implication: - -* no additional fields are created -* no behavior is added -* no contract semantics are modified - -The generated layer is purely structural β€” it restores type information that OpenAPI cannot represent directly. - --- ## πŸš€ Usage: how the client enters your system @@ -382,13 +411,23 @@ Usage: ServiceResponse ``` -At this point: +--- + +### Reference implementation + +See sample consumer services: + +```text +samples/ + spring-boot-3/customer-service-consumer + spring-boot-4/customer-service-consumer +``` -No additional mapping layer is required for correctness. +These show: -* type system is preserved -* contract is intact -* client is aligned with producer +* how generated clients are used in real services +* how adapters isolate generated code +* how contract flows end-to-end --- @@ -396,8 +435,6 @@ No additional mapping layer is required for correctness. Do not expose generated APIs directly. -Define a boundary: - ```java public interface CustomerClient { ServiceResponse getCustomer(Long id); @@ -420,34 +457,16 @@ After generation: * no duplicate envelope classes exist * generics are preserved -If true: - -```text -Client is correctly aligned -``` - --- ## ⚠️ Error handling -Errors are not generated models. - -They follow a runtime protocol: +Errors follow: ```text ProblemDetail (RFC 9457) ``` -Example: - -```java -try { - client.call(); -} catch (ApiProblemException ex) { - var pd = ex.getProblem(); -} -``` - --- ## 🧠 Mental model @@ -457,10 +476,9 @@ Think of the client as: > A deterministic build-time compiler > that maps OpenAPI β†’ contract-aligned Java code -Not: +But also: -* a DTO generator -* a modeling tool +> An optional layer that can be bypassed when needed --- @@ -468,15 +486,16 @@ Not: ```text Input = OpenAPI -Process = controlled build pipeline +Modes = optional + progressive +Process = controlled pipeline Output = thin wrappers over contract ``` The system works because: * contract is never regenerated -* generation is deterministic -* boundaries are strictly enforced +* generation is deterministic when enabled +* adoption is progressive --- diff --git a/docs/adoption/server-side-adoption.md b/docs/adoption/server-side-adoption.md index 2ebb139..d4d0b2b 100644 --- a/docs/adoption/server-side-adoption.md +++ b/docs/adoption/server-side-adoption.md @@ -9,24 +9,26 @@ nav_order: 1 > Publish a **deterministic, generics-aware OpenAPI** from Spring Boot with **one contract and zero duplication**. -A **practical, minimal, and deterministic** guide for exposing a **contract-aligned OpenAPI** from a Spring Boot (WebMVC) service. +This is a **contract-first projection system**, not a documentation tool. -This guide is intentionally **action-oriented**: you implement a small set of rules, and the platform guarantees the rest. +You define your contract in Java. +The platform guarantees a **stable, generator-ready OpenAPI projection**. --- ## πŸ“‘ Table of Contents * [⚑ 60-second quick start](#-60-second-quick-start) -* [🎯 What the server is responsible for](#-what-the-server-is-responsible-for) +* [🎯 What the server actually does](#-what-the-server-actually-does) * [🧩 The only rule that matters](#-the-only-rule-that-matters) * [πŸ“¦ Minimal dependencies](#-minimal-dependencies) * [✍️ What you actually write](#-what-you-actually-write) * [🧠 What gets published to OpenAPI](#-what-gets-published-to-openapi) +* [πŸ” Projection pipeline (what really happens)](#-projection-pipeline-what-really-happens) * [⚠️ Rules (do NOT break these)](#-rules-do-not-break-these) * [πŸ” Quick verification](#-quick-verification) +* [πŸ“¦ Samples (recommended)](#-samples-recommended) * [🧠 Mental model](#-mental-model) -* [🚫 What this guide does NOT cover](#-what-this-guide-does-not-cover) * [🧾 Summary](#-summary) --- @@ -47,6 +49,7 @@ Do this: io.github.blueprintplatform openapi-generics-server-starter + 0.9.0 ``` @@ -67,25 +70,25 @@ Done. --- -## 🎯 What the server is responsible for +## 🎯 What the server actually does -The server has **exactly one responsibility**: +The server has **one responsibility**: -> Publish a **correct, deterministic projection** of the runtime contract. +> Project the runtime Java contract into a **deterministic OpenAPI representation**. It does **not**: * generate clients -* define alternative response models -* adapt for specific generators +* define alternative models +* adapt output for specific generators It only performs: ```text -Contract β†’ OpenAPI (projection) +Java Contract β†’ OpenAPI (projection) ``` -Everything else (generation, typing, reuse) happens downstream. +Everything else happens downstream. --- @@ -104,17 +107,17 @@ ServiceResponse ServiceResponse> ``` -This constraint is what enables: +This constraint enables: -* deterministic schema generation -* stable naming +* deterministic schema naming +* stable projection * type-safe client reconstruction --- ## πŸ“¦ Minimal dependencies -No custom configuration is required. +No configuration required. ```xml @@ -123,10 +126,10 @@ No custom configuration is required. ``` -Requires: +Requirements: * Spring Boot (WebMVC) -* An OpenAPI endpoint at `/v3/api-docs` (e.g. via springdoc-openapi) +* OpenAPI endpoint (`/v3/api-docs`) via springdoc --- @@ -134,7 +137,7 @@ Requires: You write **only your domain contract**. -### Controller example +### Example ```java @GetMapping("/{id}") @@ -143,7 +146,7 @@ public ResponseEntity> getCustomer(...) { } ``` -### Pagination example +### Pagination ```java @GetMapping @@ -154,21 +157,23 @@ public ResponseEntity>> getCustomers(...) { That’s it. -- No annotations -- No schema configuration -- No wrapper DTOs +No: + +* annotations +* schema config +* wrapper DTOs --- ## 🧠 What gets published to OpenAPI -From this runtime type: +From: ```java ServiceResponse ``` -The system produces a deterministic schema: +The system produces: ```text ServiceResponseCustomerDto @@ -176,19 +181,61 @@ ServiceResponseCustomerDto Characteristics: -* stable, predictable naming -* `allOf`-based composition -* vendor extensions for downstream generation (e.g. `x-api-wrapper`) +* deterministic naming +* `allOf` composition +* vendor extensions for generation + +Example extensions: + +```text +x-api-wrapper +x-data-container +x-data-item +x-ignore-model +``` + +> OpenAPI is a **projection artifact**, not the source of truth. + +--- + +## πŸ” Projection pipeline (what really happens) + +The server is not "configurable". +It is a **fixed execution pipeline**. + +```text +Controller return types + ↓ +Response type discovery + ↓ +Contract-aware introspection + ↓ +Base schema registration + ↓ +Wrapper schema generation + ↓ +Container enrichment + ↓ +Duplicate model marking + ↓ +Contract validation (fail-fast) + ↓ +OpenAPI output +``` + +### Key properties -> OpenAPI is a **projection artifact** β€” not the source of truth. +* single orchestrator (no ordering issues) +* no patching / no overrides +* deterministic output --- -## ⚠️ Rules +## ⚠️ Rules (do NOT break these) -These are **architectural constraints**, not conventions. +These are **hard architectural constraints**. -### 1. Only constrain the envelope +### 1. Only use the canonical envelope ```text ServiceResponse @@ -207,21 +254,20 @@ ApiResponse PagedResult ``` -Replacing the envelope breaks cross-layer consistency and determinism. +Breaks determinism and cross-layer alignment. --- -### 3. Payload is completely free +### 3. Payload is fully flexible βœ” Valid: ```text ServiceResponse -ServiceResponse ServiceResponse ``` -The system constrains structure β€” not domain models. +The system constrains structure β€” not domain. --- @@ -231,7 +277,7 @@ The system constrains structure β€” not domain models. ProblemDetail (RFC 9457) ``` -Errors are handled as a protocol, separate from success responses. +Separate protocol. --- @@ -240,22 +286,20 @@ Errors are handled as a protocol, separate from success responses. No: * manual schemas -* custom annotations +* annotations * overrides -The starter owns the projection. +The starter owns projection. --- ## πŸ” Quick verification -Run a request: - ```bash curl http://localhost:8084/.../v1/.../1 ``` -Expected shape: +Expected: ```json { @@ -264,55 +308,60 @@ Expected shape: } ``` -If this is correct, then: +If correct: ```text -Server β†’ OpenAPI β†’ Client will remain consistent +Server β†’ OpenAPI β†’ Client is aligned ``` --- -## 🧠 Mental model +## πŸ“¦ Samples (recommended) -Think of the server as: +Full end-to-end examples are provided: -> A deterministic compiler from runtime contract β†’ OpenAPI +* Spring Boot 3 samples +* Spring Boot 4 samples +* client generation examples +* consumer services -Not: +These demonstrate: -* a schema designer -* a generator configuration layer +* how the contract is produced +* how the client is generated +* how it is consumed safely + +> If anything is unclear, inspect samples instead of guessing. --- -## 🚫 What this guide does NOT cover +## 🧠 Mental model -This guide intentionally excludes: +Think of the server as: -* client generation -* template customization -* generator internals +> A deterministic compiler from runtime contract β†’ OpenAPI -These belong to the **client-side adoption guide**. +Not: + +* a schema designer +* a customization layer --- ## 🧾 Summary -If you remember only this: - ```text -Return ServiceResponse -Add the starter -Do nothing else +Input = Java contract +Process = projection pipeline +Output = deterministic OpenAPI ``` -The platform handles: +The system works because: -* OpenAPI projection -* schema stability -* downstream compatibility +* contract is never redefined +* projection is deterministic +* downstream generation is predictable --- -πŸ›‘ MIT License +πŸ›‘ MIT License \ No newline at end of file diff --git a/docs/architecture/client.md b/docs/architecture/client.md index c42a031..bf2a23b 100644 --- a/docs/architecture/client.md +++ b/docs/architecture/client.md @@ -1,9 +1,9 @@ --- -title: openapi-generics-java-client β€” Architecture & Usage (0.8.x) +title: openapi-generics-java-client β€” Architecture & Usage (0.9.x) nav_exclude: true --- -# openapi-generics-java-client β€” Architecture & Usage (0.8.x) +# openapi-generics-java-client β€” Architecture & Usage (0.9.x) This document explains the **client-side architecture, build pipeline, and usage model** of the generics-aware OpenAPI system. diff --git a/docs/architecture/platform.md b/docs/architecture/platform.md index 8037e66..00977e0 100644 --- a/docs/architecture/platform.md +++ b/docs/architecture/platform.md @@ -1,9 +1,9 @@ --- -title: openapi-generics-platform β€” Architecture (0.8.x) +title: openapi-generics-platform β€” Architecture (0.9.x) nav_exclude: true --- -# openapi-generics-platform β€” Architecture (0.8.x) +# openapi-generics-platform β€” Architecture (0.9.x) This document defines the **complete architectural model of the OpenAPI Generics Platform**. diff --git a/docs/architecture/server.md b/docs/architecture/server.md index 554cdb7..45c4df6 100644 --- a/docs/architecture/server.md +++ b/docs/architecture/server.md @@ -1,9 +1,9 @@ --- -title: openapi-generics-server β€” Architecture & Internals (0.8.x) +title: openapi-generics-server β€” Architecture & Internals (0.9.x) nav_exclude: true --- -# openapi-generics-server β€” Architecture & Internals (0.8.x) +# openapi-generics-server β€” Architecture & Internals (0.9.x) This document explains **how the server starter operates at runtime** and clarifies its **exact architectural role inside the platform**. diff --git a/docs/architecture/summary/client-code-generation.md b/docs/architecture/summary/client-code-generation.md new file mode 100644 index 0000000..15a07cd --- /dev/null +++ b/docs/architecture/summary/client-code-generation.md @@ -0,0 +1,211 @@ +# openapi-generics Client Side β€” Summary + +## What the client side is now +The client side is no longer just a template tweak. + +It is now a **deterministic Java client generation layer** built on top of OpenAPI Generator, designed to keep generated clients aligned with the external contract model. + +Its job is to ensure that shared contract models are **reused**, not regenerated, while wrapper semantics remain visible and usable in generated client code. + +--- + +## Client-side architecture +The client side is split into **two distinct layers**: + +### 1. `openapi-generics-java-codegen` +This is the **custom generator extension**. + +It extends `JavaClientCodegen` and adds generics-aware behavior: +- registers external contract models +- ignores models that must not be generated +- injects required imports for external types +- removes ignored models from the generation graph + +So this module is the **codegen behavior layer**. + +--- + +### 2. `openapi-generics-java-codegen-parent` +This is the **build orchestration layer**. + +It standardizes how generation is executed: +- extracts upstream OpenAPI Generator templates +- patches `model.mustache` +- overlays local wrapper templates +- wires the custom generator into Maven +- adds generated sources to compilation automatically + +So this module is the **generation infrastructure layer**. + +--- + +## Core generator behavior + +### `GenericAwareJavaCodegen` +This is the main custom generator. + +Responsibilities: +- initialize external model registry from `additionalProperties` +- detect models that should be ignored +- remove ignored models from generated output +- inject external imports into wrapper models +- clean invalid/self imports + +This means the generator is no longer passive. +It actively enforces the contract-aligned generation model. + +--- + +### `ExternalModelRegistry` +This holds mappings like: + +`openapiGenerics.externalModel.CustomerDto=io.example.contract.CustomerDto` + +Purpose: +- declare that a schema name maps to an already existing Java class +- treat that model as externally provided +- allow wrappers to import that real class instead of generating duplicates + +--- + +### `ModelIgnoreDecider` +This decides whether a model must be excluded from generation. + +A model is ignored if: +- `x-ignore-model=true` exists +- or the model is registered as external + +This is the client-side continuation of the server-side anti-duplication contract. + +--- + +### `ExternalImportResolver` +This handles wrapper imports. + +Flow: +- detect wrapper model via `x-api-wrapper` +- resolve inner type from: + - `x-data-item` + - or `x-api-wrapper-datatype` +- look up external FQCN in the registry +- inject it into `x-extra-imports` for template usage + +This is what allows generated wrappers to **reference external contract classes correctly**. + +--- + +## Wrapper generation model +The actual wrapper class generation is completed by the custom template layer, especially `api_wrapper.mustache`. + +That template: +- imports externally mapped model types via `x-extra-imports` +- imports canonical shared contract types such as `ServiceResponse` +- imports container types such as `Page` when `x-data-container` exists +- optionally applies extra annotations via `x-class-extra-annotation` +- generates the wrapper as a thin subclass of the canonical envelope + +Effective result: + +- plain shape: + - `ServiceResponseCustomerDto extends ServiceResponse` + +- container-aware shape: + - `ServiceResponsePageCustomerDto extends ServiceResponse>` + +So the wrapper is **not a duplicated structural model**. +It becomes a **thin typed extension of the shared canonical contract**. + +This is a critical part of the design: +- structure lives in the shared contract +- wrapper typing lives in generated code +- external DTO ownership remains external + +--- + +## Template strategy +The parent POM builds a controlled generation pipeline: + +1. unpack upstream `model.mustache` +2. unpack local openapi-generics templates +3. patch upstream template to recognize `x-api-wrapper` +4. overlay local wrapper template files +5. run OpenAPI Generator with the custom generator +6. add generated Java sources to the Maven compile path + +This means: +- upstream OpenAPI Generator is still used +- but wrapper handling is inserted in a controlled, verifiable way + +--- + +## Safety model +The parent build includes a structural safety check. + +After patching `model.mustache`, it verifies that the patch marker exists. +If upstream template structure changes, the build fails. + +That means template customization is not β€œbest effort”. +It is **guarded against silent upstream breakage**. + +--- + +## How a sample client uses it +A sample client project inherits from: + +`openapi-generics-java-codegen-parent` + +Then it configures the OpenAPI plugin with: + +- `generatorName=java-generics-contract` +- input OpenAPI spec +- normal Java client package settings +- `library=restclient` +- external model mappings via `additionalProperties` + +Example: +- `CustomerDto` is declared as external +- generator does not regenerate it +- generated wrappers import and use the contract-provided `CustomerDto` + +So the sample proves the intended usage model: +- spec is generated from server side +- client generation uses generics-aware codegen +- shared DTOs come from contract dependency +- wrapper classes are generated as thin typed extensions +- duplicated infrastructure/shared models are suppressed + +--- + +## What this client side achieves +The client side now guarantees: + +- external shared models are reused, not duplicated +- wrapper semantics survive generation +- wrapper classes extend the canonical shared contract types +- generated client code stays aligned with the published contract +- template behavior is deterministic and build-controlled +- upstream generator drift is detected early + +--- + +## Current identity of the client side +The client side is best understood as: + +> A deterministic Java code generation layer that turns vendor-extended OpenAPI into contract-aligned clients by reusing shared contract models and generating thin typed wrapper subclasses. + +--- + +## Final synthesis +The client side now does the following: + +- consumes the semantic extensions produced by the server side +- suppresses generation of shared/infrastructure models +- injects imports for externally owned contract types +- generates wrapper classes as thin extensions of canonical contract envelopes +- preserves container semantics such as `Page` +- standardizes generation through a reusable parent POM +- protects template customization against upstream breakage + +In short: + +> The client side has evolved from β€œtemplate customization” into the **contract-aligned Java generation engine** of openapi-generics. \ No newline at end of file diff --git a/docs/architecture/summary/server-openapi-projection.md b/docs/architecture/summary/server-openapi-projection.md new file mode 100644 index 0000000..2495e62 --- /dev/null +++ b/docs/architecture/summary/server-openapi-projection.md @@ -0,0 +1,223 @@ +# openapi-generics Server Side β€” Summary + +## What this module is now +`openapi-generics-server-starter` is no longer just a Springdoc helper. + +It is now a **Spring Boot starter that installs a deterministic, contract-aware OpenAPI projection pipeline**. + +Its job is to project Java contract semantics into OpenAPI while preserving supported generic response shapes. + +--- + +## Core architectural role +The server side is built around a **single pipeline**: + +1. Register canonical base schemas +2. Discover controller response types +3. Introspect supported contract-aware shapes +4. Build wrapper schemas deterministically +5. Mark infrastructure/container schemas to avoid duplicate generation +6. Validate final OpenAPI output fail-fast + +This means: +- no scattered customizers +- no ordering tricks +- no patch-based schema fixing +- one explicit execution path + +--- + +## Main components + +### `OpenApiGenericsAutoConfiguration` +Wires the whole system. + +Creates: +- response type discovery strategy +- response type introspector +- base schema registrar +- schema generation control marker +- wrapper schema enricher +- wrapper schema processor +- contract guard +- pipeline orchestrator +- single `OpenApiCustomizer` entry point + +--- + +### `OpenApiPipelineOrchestrator` +The central execution coordinator. + +Responsibilities: +- runs the pipeline once per `OpenAPI` instance +- enforces deterministic execution order +- delegates all actual work to focused components + +--- + +### `ResponseTypeDiscoveryStrategy` +Framework-specific discovery abstraction. + +Current implementation: +- `MvcResponseTypeDiscoveryStrategy` +- scans Spring MVC handler mappings +- collects method return types as `ResolvableType` + +This isolates framework scanning from core contract logic. + +--- + +### `ResponseTypeIntrospector` +Framework-independent type analysis. + +Supported contract-aware shapes: +- `ServiceResponse` +- `ServiceResponse>` + +It explicitly does **not** own: +- `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` semantics visible to downstream generators without relying on ad hoc logic. + +--- + +## Duplicate model prevention +`SchemaGenerationControlMarker` marks schemas with `x-ignore-model`. + +Ignored schemas include: +- `ServiceResponse` +- `ServiceResponseVoid` +- `Meta` +- `Sort` +- derived container schemas such as `PageCustomerDto` + +Purpose: +- keep schemas in OpenAPI +- prevent code generators from re-generating infrastructure/shared models + +This directly supports the project’s anti-duplication goal. + +--- + +## Validation model +`OpenApiContractGuard` performs final fail-fast validation. + +It checks: +- required base schemas exist +- wrapper schemas contain required extensions +- wrapper schemas use `allOf` +- wrapper schemas define `data` + +This protects only the critical invariants of the contract-aware projection model. + +--- + +## Starter behavior +The module is packaged as a reusable Spring Boot starter: +- Java 17 baseline +- Spring Boot 3.5.13 +- Spring WebMVC 6.2.17 +- BOM-managed dependency alignment + +It also includes a fallback auto-configuration: +- if Springdoc is missing, the starter stays inactive +- logs a warning instead of failing startup + +So the starter is safe and non-intrusive. + +--- + +## Current identity of the server side +The server module is now best understood as: + +> A deterministic server-side OpenAPI projection engine for `ServiceResponse` and `ServiceResponse>`. + +Not just a customization helper. + +--- + +## Final synthesis +The server side now does the following: + +- owns the OpenAPI projection pipeline +- preserves only explicitly supported generic contract shapes +- builds canonical wrapper schemas deterministically +- exposes semantics through stable vendor extensions +- suppresses duplicate infrastructure model generation +- validates final contract integrity fail-fast + +In short: + +> The server side has evolved from β€œSpringdoc customization” into the **core projection engine** of openapi-generics. \ No newline at end of file diff --git a/docs/images/architecture/openapi-generics-architecture.drawio b/docs/images/architecture/openapi-generics-architecture.drawio index 8d17bca..e5f2fac 100644 --- a/docs/images/architecture/openapi-generics-architecture.drawio +++ b/docs/images/architecture/openapi-generics-architecture.drawio @@ -4,16 +4,16 @@ - + - + - + @@ -22,7 +22,7 @@ - + diff --git a/docs/images/architecture/openapi-generics-architecture.svg b/docs/images/architecture/openapi-generics-architecture.svg index b230280..f872dae 100644 --- a/docs/images/architecture/openapi-generics-architecture.svg +++ b/docs/images/architecture/openapi-generics-architecture.svg @@ -1,4 +1,4 @@ -
Authority layer β€” single source of truth
openapi-generics-contract Β· ServiceResponse<T> Β· Meta Β· Page<T>
Projection phase
Spring Boot + springdoc customizer
Internal DSL (generation protocol)
x-api-wrapper Β· x-data-container Β· x-ignore-model
OpenAPI 3.1 spec
projection artifact β€” not authority
Enforcement phase
codegen-parent Β· java-generics-contract generator
Template pipeline
extract β†’ patch β†’ overlay
Generated client
thin wrappers Β· generics preserved Β· contract reused
no duplication Β· no drift
Adapter boundary
stability boundary (anti-corruption)
isolates generated client
Application
uses contract types only Β· no generator coupling
{ data, meta }
deterministic contract output Β· end-to-end
Each layer transforms the contract β€” none reinterpret it
OpenAPI is a projection β€” not a source of truth
\ No newline at end of file +
Authority layer β€” single source of truth
openapi-generics-contract (shared) Β· your domain DTOs Β· ServiceResponse<T> Β· Meta Β· Page<T>
Projection phase
Spring Boot + springdoc customizer
Internal DSL (OpenAPI projection protocol)
x-api-wrapper Β· x-data-container Β· x-ignore-model
OpenAPI 3.1 spec
projection artifact β€” contract-derived, not authority
Enforcement phase
codegen-parent Β· java-generics-contract generator
Template pipeline
extract β†’ patch β†’ overlay
Generated client
thin wrappers Β· generics preserved Β· external models reused
no duplication Β· no drift Β· contract-aligned
Adapter boundary
stability boundary (anti-corruption)
isolates generated client
Application
uses contract types only Β· no generator coupling
{ data, meta }
deterministic contract output Β· end-to-end
Each layer transforms the contract β€” none reinterpret it
OpenAPI is a projection β€” not a source of truth
\ No newline at end of file diff --git a/docs/images/cover/cover.html b/docs/images/cover/cover.html index 539459c..cb32ebb 100644 --- a/docs/images/cover/cover.html +++ b/docs/images/cover/cover.html @@ -154,7 +154,7 @@

Share one canonical envelope 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.

@@ -166,9 +166,9 @@

- + - Spring Boot 3.4.x | 3.5.x + Spring Boot 3.4.x | 3.5.x | 4.x diff --git a/docs/images/cover/cover.png b/docs/images/cover/cover.png index 1e0d38d..a8cf26f 100644 Binary files a/docs/images/cover/cover.png and b/docs/images/cover/cover.png differ diff --git a/docs/images/proof/generated-client-wrapper-after.png b/docs/images/proof/generated-client-wrapper-after.png index 57abc77..7fda64b 100644 Binary files a/docs/images/proof/generated-client-wrapper-after.png and b/docs/images/proof/generated-client-wrapper-after.png differ diff --git a/docs/images/proof/generated-client-wrapper-before.png b/docs/images/proof/generated-client-wrapper-before.png index 737b503..593aa12 100644 Binary files a/docs/images/proof/generated-client-wrapper-before.png and b/docs/images/proof/generated-client-wrapper-before.png differ diff --git a/docs/index.md b/docs/index.md index 3d354f6..eaf90ae 100644 --- a/docs/index.md +++ b/docs/index.md @@ -6,32 +6,34 @@ nav_order: 1 # OpenAPI Generics β€” Keep Your API Contract Intact End-to-End -> Define your API once in Java. +> Define your API once in Java. > Preserve it across OpenAPI and generated clients β€” without duplication or drift. --- ## Table of Contents -- [Why this exists](#why-this-exists) -- [What you actually do](#what-you-actually-do) -- [Quick Start](#-quick-start) - - [1. Server (producer)](#1-server-producer) - - [2. Client (consumer)](#2-client-consumer) -- [Result](#result) -- [Compatibility Matrix](#-compatibility-matrix) -- [Proof β€” Generated Client (Before vs After)](#proof--generated-client-before-vs-after) - - [Before (default OpenAPI behavior)](#before-default-openapi-behavior) - - [After (contract-aligned generation)](#after-contract-aligned-generation) -- [What changed](#what-changed) -- [What is actually generated](#what-is-actually-generated) -- [How you actually use it](#how-you-actually-use-it) -- [What this gives you](#what-this-gives-you) -- [Why this matters](#why-this-matters) -- [Mental model](#mental-model) -- [Next steps](#next-steps) -- [References & External Links](#-references--external-links) -- [Final note](#final-note) +* [Why this exists](#why-this-exists) +* [What you actually do](#what-you-actually-do) +* [Quick Start](#-quick-start) + + * [1. Server (producer)](#1-server-producer) + * [2. Client (consumer)](#2-client-consumer) +* [Result](#result) +* [Compatibility Matrix](#-compatibility-matrix) +* [Proof β€” Generated Client (Before vs After)](#proof--generated-client-before-vs-after) + + * [Before (default OpenAPI behavior)](#before-default-openapi-behavior) + * [After (contract-aligned generation)](#after-contract-aligned-generation) +* [What changed](#what-changed) +* [What is actually generated](#what-is-actually-generated) +* [How you actually use it](#how-you-actually-use-it) +* [What this gives you](#what-this-gives-you) +* [Why this matters](#why-this-matters) +* [Mental model](#mental-model) +* [Next steps](#next-steps) +* [References & External Links](#-references--external-links) +* [Final note](#final-note) --- @@ -41,6 +43,7 @@ In most OpenAPI-based workflows: * generics are flattened or lost * response envelopes are regenerated per endpoint +* shared models are duplicated on the client side * clients gradually drift from server-side contracts Over time, this creates a gap between what your API **defines** and what your clients **consume**. @@ -55,14 +58,15 @@ This platform removes that entire class of problems. ## What you actually do -You don’t configure OpenAPI. -You don’t maintain templates. +You don’t configure OpenAPI. +You don’t maintain templates. You don’t fight generator behavior. -You only do two things: +You only do three things: 1. return your contract from controllers -2. generate clients from OpenAPI +2. optionally reuse your own shared contract models on the client side +3. generate clients from OpenAPI That’s it. @@ -80,7 +84,7 @@ Add the dependency: io.github.blueprint-platform openapi-generics-server-starter - 0.8.2 + 0.9.0 ``` @@ -100,10 +104,20 @@ Inherit the parent: io.github.blueprint-platform openapi-generics-java-codegen-parent - 0.8.2 + 0.9.0 ``` +Optionally declare externally owned shared contract models: + +```xml + + + openapiGenerics.responseContract.CustomerDto=io.github.blueprintplatform.contracts.customer.CustomerDto + + +``` + Generate the client: ```bash @@ -118,11 +132,11 @@ mvn clean install ServiceResponse ``` -The exact same contract type flows from server to client. +The exact same contract shape flows from server to client. -* no duplicated models +* no duplicated envelope models * generics preserved end-to-end -* contract types reused (not regenerated) +* external contract models reused instead of regenerated --- @@ -130,17 +144,17 @@ The exact same contract type flows from server to client. ### Runtime (Server) -| Component | Supported Versions | -|--------------------|--------------------------| -| Java | 17+ | -| Spring Boot | 3.4.x, 3.5.x | -| springdoc-openapi | 2.8.x (WebMvc starter) | +| Component | Supported Versions | +| ----------------- |-----------------------------| +| Java | 17+ | +| Spring Boot | 3.4.x, 3.5.x, 4.x | +| springdoc-openapi | 2.8.x, 3.x (WebMvc starter) | ### Build-time (Client Generation) -| Component | Supported Versions | -|--------------------|-------------------| -| OpenAPI Generator | 7.x | +| Component | Supported Versions | +| ----------------- | ------------------ | +| OpenAPI Generator | 7.x | --- @@ -152,7 +166,6 @@ The exact same contract type flows from server to client.

- * 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> Not: -* generated wrapper classes -* duplicated DTO hierarchies -* OpenAPI-specific models +* duplicated envelope classes +* flattened generic shapes +* unstable model graphs +* generator-specific envelope hierarchies -No translation layer. No reinterpretation. No drift. +And when you reuse shared contract DTOs, it also avoids regenerating models you already own. + +No reinterpretation. No duplication. No drift. --- ## Why this matters -Traditional OpenAPI generation produces: +Traditional OpenAPI generation often produces: * duplicated response envelopes * flattened generics * unstable model graphs +* regenerated shared models that the client should not own This approach guarantees: -* a single contract shared across all layers +* a single contract shape shared across all layers * stable and predictable client generation +* optional reuse of shared external contract models * zero drift between server and client semantics --- @@ -316,17 +339,17 @@ Think of generated classes as: > thin type adapters β€” not models -They exist because OpenAPI cannot express Java generics β€” not because your model requires them. +They exist because OpenAPI cannot express Java generics directly β€” not because your domain model requires duplication. -They simply reconnect OpenAPI output back to your **existing contract**, without redefining it. +When shared contract models are provided, generated wrappers reference them directly. When they are not, the platform still preserves the success-envelope structure deterministically. -Your system always operates on: +Your system always operates around: ```text ServiceResponse ``` -Everything else is just infrastructure. +Everything else is infrastructure. --- @@ -344,7 +367,6 @@ Everything else is just infrastructure. --- - ## Final note If the contract stays consistent, everything stays consistent. diff --git a/openapi-generics-contract/README.md b/openapi-generics-contract/README.md index dca8491..4e7570d 100644 --- a/openapi-generics-contract/README.md +++ b/openapi-generics-contract/README.md @@ -282,7 +282,7 @@ Those responsibilities belong to other platform layers. io.github.blueprint-platform openapi-generics-contract - 0.8.2 + 0.9.0 ``` diff --git a/openapi-generics-contract/pom.xml b/openapi-generics-contract/pom.xml index 5dd9789..1c3e294 100644 --- a/openapi-generics-contract/pom.xml +++ b/openapi-generics-contract/pom.xml @@ -7,7 +7,7 @@ io.github.blueprint-platform openapi-generics - 0.8.2 + 0.9.0 openapi-generics-contract @@ -32,7 +32,7 @@ https://github.com/blueprint-platform/openapi-generics scm:git:https://github.com/blueprint-platform/openapi-generics.git scm:git:ssh://git@github.com:blueprint-platform/openapi-generics.git - v0.8.2 + v0.9.0 @@ -47,7 +47,6 @@ UTF-8 UTF-8 17 - 2.21 3.15.0 3.4.0 @@ -56,14 +55,6 @@ 3.1.4 - - - com.fasterxml.jackson.core - jackson-annotations - ${jackson-annotations.version} - - - diff --git a/openapi-generics-contract/src/main/java/io/github/blueprintplatform/openapi/generics/contract/error/ErrorItem.java b/openapi-generics-contract/src/main/java/io/github/blueprintplatform/openapi/generics/contract/error/ErrorItem.java index 056d663..cda2af7 100644 --- a/openapi-generics-contract/src/main/java/io/github/blueprintplatform/openapi/generics/contract/error/ErrorItem.java +++ b/openapi-generics-contract/src/main/java/io/github/blueprintplatform/openapi/generics/contract/error/ErrorItem.java @@ -1,7 +1,5 @@ package io.github.blueprintplatform.openapi.generics.contract.error; -import com.fasterxml.jackson.annotation.JsonInclude; - /** * Structured error detail item used inside problem extensions. * @@ -11,5 +9,4 @@ * @param resource related resource name, if applicable * @param id related resource identifier, if applicable */ -@JsonInclude(JsonInclude.Include.NON_NULL) public record ErrorItem(String code, String message, String field, String resource, String id) {} \ No newline at end of file diff --git a/openapi-generics-contract/src/main/java/io/github/blueprintplatform/openapi/generics/contract/error/ProblemExtensions.java b/openapi-generics-contract/src/main/java/io/github/blueprintplatform/openapi/generics/contract/error/ProblemExtensions.java index e65b363..9882c01 100644 --- a/openapi-generics-contract/src/main/java/io/github/blueprintplatform/openapi/generics/contract/error/ProblemExtensions.java +++ b/openapi-generics-contract/src/main/java/io/github/blueprintplatform/openapi/generics/contract/error/ProblemExtensions.java @@ -1,6 +1,5 @@ package io.github.blueprintplatform.openapi.generics.contract.error; -import com.fasterxml.jackson.annotation.JsonInclude; import java.util.List; /** @@ -8,7 +7,6 @@ * * @param errors structured error details attached to the problem response */ -@JsonInclude(JsonInclude.Include.NON_NULL) public record ProblemExtensions(List errors) { /** diff --git a/openapi-generics-contract/src/main/java/io/github/blueprintplatform/openapi/generics/contract/paging/Sort.java b/openapi-generics-contract/src/main/java/io/github/blueprintplatform/openapi/generics/contract/paging/Sort.java index 5373ad0..17d1fd3 100644 --- a/openapi-generics-contract/src/main/java/io/github/blueprintplatform/openapi/generics/contract/paging/Sort.java +++ b/openapi-generics-contract/src/main/java/io/github/blueprintplatform/openapi/generics/contract/paging/Sort.java @@ -1,14 +1,11 @@ package io.github.blueprintplatform.openapi.generics.contract.paging; -import com.fasterxml.jackson.annotation.JsonInclude; - /** * Sorting descriptor included in response metadata. * * @param field field name used for sorting * @param direction sorting direction */ -@JsonInclude(JsonInclude.Include.NON_NULL) public record Sort(String field, SortDirection direction) { /** diff --git a/openapi-generics-contract/src/main/java/io/github/blueprintplatform/openapi/generics/contract/paging/SortDirection.java b/openapi-generics-contract/src/main/java/io/github/blueprintplatform/openapi/generics/contract/paging/SortDirection.java index 9c9d9a8..9928d47 100644 --- a/openapi-generics-contract/src/main/java/io/github/blueprintplatform/openapi/generics/contract/paging/SortDirection.java +++ b/openapi-generics-contract/src/main/java/io/github/blueprintplatform/openapi/generics/contract/paging/SortDirection.java @@ -1,7 +1,5 @@ package io.github.blueprintplatform.openapi.generics.contract.paging; -import com.fasterxml.jackson.annotation.JsonValue; - /** * Supported sorting directions. */ @@ -39,7 +37,6 @@ public static SortDirection from(String s) { * * @return serialized direction value */ - @JsonValue public String value() { return value; } diff --git a/openapi-generics-java-codegen-parent/README.md b/openapi-generics-java-codegen-parent/README.md index 61c55bd..4283beb 100644 --- a/openapi-generics-java-codegen-parent/README.md +++ b/openapi-generics-java-codegen-parent/README.md @@ -238,7 +238,7 @@ Ensures generated code is compiled as part of the project lifecycle. io.github.blueprintplatform openapi-generics-java-codegen-parent - 0.8.2 + 0.9.0 ``` diff --git a/openapi-generics-java-codegen-parent/pom.xml b/openapi-generics-java-codegen-parent/pom.xml index 8308058..c032473 100644 --- a/openapi-generics-java-codegen-parent/pom.xml +++ b/openapi-generics-java-codegen-parent/pom.xml @@ -8,7 +8,7 @@ io.github.blueprint-platform openapi-generics - 0.8.2 + 0.9.0 openapi-generics-java-codegen-parent @@ -30,7 +30,7 @@ https://github.com/blueprint-platform/openapi-generics scm:git:https://github.com/blueprint-platform/openapi-generics.git scm:git:ssh://git@github.com:blueprint-platform/openapi-generics.git - v0.8.2 + v0.9.0 @@ -45,7 +45,7 @@ UTF-8 UTF-8 - 0.8.2 + 0.9.0 7.21.0 3.15.0 @@ -62,6 +62,7 @@ ${openapi.generator.output}/${openapi.generator.sourceFolder} ${project.build.directory}/codegen-templates + false @@ -76,7 +77,7 @@ io.github.blueprint-platform openapi-generics-platform-bom - 0.8.2 + 0.9.0 pom import @@ -102,6 +103,7 @@ + ${openapi.generics.skip} org.openapitools @@ -123,6 +125,7 @@ + ${openapi.generics.skip} io.github.blueprint-platform @@ -142,8 +145,19 @@ org.apache.maven.plugins maven-antrun-plugin ${maven-antrun-plugin.version} - + + ensure-template-dir + generate-sources + + run + + + + + + + patch-openapi-model-template generate-sources @@ -152,6 +166,7 @@ + ${openapi.generics.skip} + ${openapi.generics.skip} ${openapi.templates.effective}/Java @@ -231,9 +247,6 @@ - - java-generics-contract - ${openapi.generator.output} ${openapi.templates.effective}/Java @@ -242,14 +255,6 @@ ${openapi.generator.sourceFolder} - - Meta=io.github.blueprintplatform.openapi.generics.contract.envelope.Meta - ServiceResponse=io.github.blueprintplatform.openapi.generics.contract.envelope.ServiceResponse - Page=io.github.blueprintplatform.openapi.generics.contract.paging.Page - Sort=io.github.blueprintplatform.openapi.generics.contract.paging.Sort - SortDirection=io.github.blueprintplatform.openapi.generics.contract.paging.SortDirection - - diff --git a/openapi-generics-java-codegen/README.md b/openapi-generics-java-codegen/README.md index afadb82..e7e32d3 100644 --- a/openapi-generics-java-codegen/README.md +++ b/openapi-generics-java-codegen/README.md @@ -1,35 +1,35 @@ # openapi-generics-java-codegen -> Generics-aware OpenAPI Generator extension for contract-aligned Java clients +> Generics-aware OpenAPI Generator extension for **contract-aligned Java clients** `openapi-generics-java-codegen` is a **custom OpenAPI Generator extension** that enforces **contract-first client generation**. It does not try to be smarter than your contract. It ensures the generator **does not break it**. -The role of this module is strict: - > Prevent OpenAPI Generator from redefining platform-owned models and enforce contract-aligned output. -It is a **build-time component** and is typically used via `openapi-generics-java-codegen-parent`. +This module is **build-time only** and is typically consumed via `openapi-generics-java-codegen-parent`. --- -## Table of Contents +## πŸ“‘ Table of Contents + +* 🎯 [Purpose](#-purpose) +* 🧠 [Core Idea](#-core-idea) +* βš™οΈ [What It Does](#️-what-it-does) +* 🧱 [Result](#-result) +* 🧩 [External Model Mapping (BYOC)](#-external-model-mapping-byoc) +* 🧩 [Template Integration](#-template-integration) +* πŸ”— [How It Is Used](#-how-it-is-used) +* 🚫 [Not Intended For Direct Use](#-not-intended-for-direct-use) +* πŸ”— [Compatibility Matrix](#-compatibility-matrix) +* πŸ”’ [Determinism Guarantees](#-determinism-guarantees) +* ⚠️ [Design Constraints](#️-design-constraints) +* 🧠 [Mental Model](#-mental-model) +* πŸ”— [Related Modules](#-related-modules) +* πŸ“œ [License](#-license) -1. [Purpose](#-purpose) -2. [Core Idea](#-core-idea) -3. [What It Does](#-what-it-does) -4. [Result](#-result) -5. [Template Integration](#-template-integration) -6. [How It Is Used](#-how-it-is-used) -7. [Not Intended For Direct Use](#-not-intended-for-direct-use) -8. [Compatibility Matrix](#-compatibility-matrix) -9. [Determinism Guarantees](#-determinism-guarantees) -10. [Design Constraints](#-design-constraints) -11. [Mental Model](#-mental-model) -12. [Related Modules](#-related-modules) -13. [License](#-license) --- ## 🎯 Purpose @@ -69,38 +69,60 @@ If OpenAPI contains structure that already exists in the contract: ## βš™οΈ What It Does -### 1. Detects non-generatable models +### 1) External model registry (BYOC) -Models marked with: +Reads `additionalProperties` and registers mappings: +```text +openapiGenerics.responseContract.CustomerDto=io.example.contract.CustomerDto ``` -x-ignore-model: true -``` -are treated as **platform-owned**. +Backed by: `ExternalModelRegistry` + +Effect: + +* marks models as **externally provided** +* prevents generation of those models +* enables import injection in wrappers + +--- + +### 2) Ignore decision (dual source) + +A model is ignored if: + +* `x-ignore-model=true` is present in schema extensions +* OR it is registered as an external model + +Backed by: `ModelIgnoreDecider` --- -### 2. Suppresses model generation (3-phase strategy) +### 3) 3-phase suppression strategy -#### Phase 1 β€” MARK +#### Phase A β€” MARK -* intercepts `fromModel` -* collects ignored model names +* during `fromModel` +* mark models as ignored -#### Phase 2 β€” LOCAL FILTER +#### Phase B β€” LOCAL FILTER -* removes models from current processing batch +* in `postProcessModels` +* remove ignored models from the current batch -#### Phase 3 β€” GLOBAL REMOVE +#### Phase C β€” GLOBAL REMOVE -* removes models from full generation graph +* in `postProcessAllModels` +* remove ignored models from the full graph --- -### 3. Cleans imports +### 4) Import hygiene -* removes references to ignored models from generated classes +* removes imports that reference ignored models +* injects correct imports for external types into wrapper models + +Backed by: `ExternalImportResolver` --- @@ -115,45 +137,74 @@ Generated code: --- -## 🧩 Template Integration +## 🧩 External Model Mapping (BYOC) -This module also provides custom templates under: +Optional but powerful. +### Configuration + +```xml + + + openapiGenerics.responseContract.CustomerDto= + io.example.contract.CustomerDto + + ``` + +### Behavior + +* prevents generation of `CustomerDto` +* injects correct import into wrappers +* reuses your domain model directly + +### Important + +* mapping key must match **OpenAPI model name** +* value must be **fully-qualified class name (FQCN)** + +--- + +## 🧩 Template Integration + +Templates live under: + +```text META-INF/openapi-generics/templates ``` ### Core template: `api_wrapper.mustache` -This template: +Responsibilities: -* wraps generated models -* injects `ServiceResponse` -* handles container types (`Page`) +* generate thin wrapper classes +* extend `ServiceResponse` +* apply container semantics (`Page`) Example output: ```java -public class CustomerResponse extends ServiceResponse {} +public class ServiceResponsePageCustomerDto + extends ServiceResponse> {} ``` --- ## πŸ”— How It Is Used -This module is **not typically used directly**. +This module is **not used directly**. -Instead, it is wired via: +It is wired via: -``` +```text openapi-generics-java-codegen-parent ``` The parent POM: -* registers this generator +* registers this generator (`java-generics-contract`) * injects templates -* configures OpenAPI Generator plugin +* configures the OpenAPI Generator plugin --- @@ -167,32 +218,26 @@ End users should NOT: Instead: -> Use the codegen parent β€” it handles everything +> Use the codegen parent β€” it orchestrates everything --- ## πŸ”— Compatibility Matrix -This module is tested with the following versions: - -| Component | Supported Versions | -|--------------------|-------------------| -| Java | 17+ | -| OpenAPI Generator | 7.x | +| Component | Supported Versions | +| ----------------- | ------------------ | +| Java | 17+ | +| OpenAPI Generator | 7.x | Notes: -* `restclient` library is available starting from **OpenAPI Generator 7.6.0** -* If you use `restclient`, you must use **7.6.0 or newer** -* This module is designed to work across the **OpenAPI Generator 7.x series** -* This is a **build-time module** β€” no runtime dependency on Spring +* `restclient` library requires **OpenAPI Generator β‰₯ 7.6.0** +* Module is build-time only (no Spring/runtime dependency) --- ## πŸ”’ Determinism Guarantees -This generator ensures: - * βœ” No duplication of contract models * βœ” Stable model graph * βœ” Consistent generation output @@ -200,19 +245,17 @@ This generator ensures: Mechanisms: -* controlled model suppression -* explicit extension handling -* no implicit behavior +* explicit ignore rules +* controlled graph pruning +* deterministic template application --- ## ⚠️ Design Constraints -The generator: - * depends on vendor extensions (`x-*` fields) * assumes contract-first design -* is tightly coupled to platform semantics +* tightly coupled to platform semantics It is NOT a general-purpose generator. @@ -220,8 +263,6 @@ It is NOT a general-purpose generator. ## 🧠 Mental Model -Think of this module as: - > A guardrail inside OpenAPI Generator that prevents contract drift Not: @@ -245,9 +286,3 @@ Not: ## πŸ“œ License MIT License - ---- - -**Maintained by:** -**Barış SaylΔ±** -[GitHub](https://github.com/bsayli) Β· [Medium](https://medium.com/@baris.sayli) Β· [LinkedIn](https://www.linkedin.com/in/bsayli) diff --git a/openapi-generics-java-codegen/pom.xml b/openapi-generics-java-codegen/pom.xml index add5376..d8d19ce 100644 --- a/openapi-generics-java-codegen/pom.xml +++ b/openapi-generics-java-codegen/pom.xml @@ -7,7 +7,7 @@ io.github.blueprint-platform openapi-generics - 0.8.2 + 0.9.0 openapi-generics-java-codegen @@ -32,7 +32,7 @@ https://github.com/blueprint-platform/openapi-generics scm:git:https://github.com/blueprint-platform/openapi-generics.git scm:git:ssh://git@github.com:blueprint-platform/openapi-generics.git - v0.8.2 + v0.9.0 @@ -54,6 +54,7 @@ 1.7.3 2.0.17 + 5.14.3 @@ -76,6 +77,13 @@ ${slf4j-api.version} + + org.junit.jupiter + junit-jupiter-api + ${junit.jupiter.version} + test + + @@ -83,7 +91,7 @@ io.github.blueprint-platform openapi-generics-platform-bom - 0.8.2 + 0.9.0 pom import diff --git a/openapi-generics-java-codegen/src/main/java/io/github/blueprintplatform/openapi/generics/codegen/ExternalImportResolver.java b/openapi-generics-java-codegen/src/main/java/io/github/blueprintplatform/openapi/generics/codegen/ExternalImportResolver.java new file mode 100644 index 0000000..fd69df2 --- /dev/null +++ b/openapi-generics-java-codegen/src/main/java/io/github/blueprintplatform/openapi/generics/codegen/ExternalImportResolver.java @@ -0,0 +1,79 @@ +package io.github.blueprintplatform.openapi.generics.codegen; + +import java.util.Map; +import java.util.Optional; +import org.openapitools.codegen.CodegenModel; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * Resolves external model types and injects their imports into vendorExtensions. + * + *

Used for wrapper models (e.g. ServiceResponse<T>) where T is an external contract type + * and must be imported instead of generated. + * + *

Flow: + * + *

    + *
  • Detect wrapper model (x-api-wrapper) + *
  • Extract inner type (x-data-item or x-api-wrapper-datatype) + *
  • Resolve FQCN via {@link ExternalModelRegistry} + *
  • Inject into x-extra-imports for template usage + *
+ */ +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 ve = model.getVendorExtensions(); + if (ve == null) return; + + Optional typeOpt = + extract(ve, EXT_DATA_ITEM).or(() -> extract(ve, EXT_WRAPPER_DATATYPE)); + + if (typeOpt.isEmpty()) { + log.debug("Wrapper model has no resolvable inner type: {}", model.name); + return; + } + + String type = typeOpt.get(); + String fqcn = registry.getFqcn(type); + + if (fqcn == null) { + log.debug("No external mapping found for type: {} (model: {})", type, model.name); + return; + } + + ve.put(EXT_EXTRA_IMPORTS, fqcn); + + log.debug("External import applied: {} -> {}", type, fqcn); + } + + private boolean isWrapperModel(CodegenModel model) { + Map ve = model.getVendorExtensions(); + return ve != null && Boolean.TRUE.equals(ve.get(EXT_API_WRAPPER)); + } + + private Optional extract(Map ve, String key) { + Object val = ve.get(key); + if (val instanceof String s && !s.isBlank()) { + return Optional.of(s); + } + return Optional.empty(); + } +} diff --git a/openapi-generics-java-codegen/src/main/java/io/github/blueprintplatform/openapi/generics/codegen/ExternalModelRegistry.java b/openapi-generics-java-codegen/src/main/java/io/github/blueprintplatform/openapi/generics/codegen/ExternalModelRegistry.java new file mode 100644 index 0000000..f3528b4 --- /dev/null +++ b/openapi-generics-java-codegen/src/main/java/io/github/blueprintplatform/openapi/generics/codegen/ExternalModelRegistry.java @@ -0,0 +1,54 @@ +package io.github.blueprintplatform.openapi.generics.codegen; + +import java.util.HashMap; +import java.util.Map; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * Holds mappings between OpenAPI model names and external Java types (FQCN). + * + *

Used to prevent generation of shared contract models and reference them instead. + * + *

Configuration format: + * + *

+ * 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 externalModels = new HashMap<>(); + + /** Registers external models from generator additionalProperties. */ + public void register(Map additionalProperties) { + for (Map.Entry e : additionalProperties.entrySet()) { + if (e.getKey().startsWith(PREFIX)) { + String modelName = e.getKey().substring(PREFIX.length()); + String fqcn = String.valueOf(e.getValue()); + + externalModels.put(modelName, fqcn); + + log.debug("Registered external model: {} -> {}", modelName, fqcn); + } + } + } + + /** + * @return true if model is externally provided + */ + public boolean isExternal(String modelName) { + return externalModels.containsKey(modelName); + } + + /** + * @return fully-qualified class name or null + */ + public String getFqcn(String modelName) { + return externalModels.get(modelName); + } +} diff --git a/openapi-generics-java-codegen/src/main/java/io/github/blueprintplatform/openapi/generics/codegen/GenericAwareJavaCodegen.java b/openapi-generics-java-codegen/src/main/java/io/github/blueprintplatform/openapi/generics/codegen/GenericAwareJavaCodegen.java index 0fd7288..e2a7edf 100644 --- a/openapi-generics-java-codegen/src/main/java/io/github/blueprintplatform/openapi/generics/codegen/GenericAwareJavaCodegen.java +++ b/openapi-generics-java-codegen/src/main/java/io/github/blueprintplatform/openapi/generics/codegen/GenericAwareJavaCodegen.java @@ -1,10 +1,7 @@ package io.github.blueprintplatform.openapi.generics.codegen; import io.swagger.v3.oas.models.media.Schema; -import java.util.HashSet; -import java.util.Iterator; import java.util.Map; -import java.util.Set; import org.openapitools.codegen.CodegenModel; import org.openapitools.codegen.languages.JavaClientCodegen; import org.openapitools.codegen.model.ModelsMap; @@ -12,146 +9,115 @@ import org.slf4j.LoggerFactory; /** - * A custom OpenAPI Generator that introduces awareness of platform-level generic models. + * Custom Java generator that integrates external contract models and generic response wrappers into + * OpenAPI generation. * - *

Models marked with {@code x-ignore-model: true} are: + *

Responsibilities: * *

    - *
  • Detected during {@link #fromModel(String, Schema)} phase
  • - *
  • Filtered out locally in {@link #postProcessModels(ModelsMap)}
  • - *
  • Completely removed from the global model graph in {@link #postProcessAllModels(Map)}
  • + *
  • Register externally provided models (contract-first approach) + *
  • Exclude those models from generation + *
  • Inject required imports into wrapper models via vendor extensions + *
  • Keep generated code free of invalid/self imports *
* - *

This ensures that: - * - *

    - *
  • Platform-owned generic types (e.g. ServiceResponse, Meta, Sort) are not generated
  • - *
  • But still usable as referenced types in composed/generated models
  • - *
- * - *

Design Principle:
- * Java contract is the authority, OpenAPI is a projection. This generator enforces that - * projection must not re-materialize platform-owned types. + *

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 ignoredModels = new HashSet<>(); + private final ExternalModelRegistry registry = new ExternalModelRegistry(); + private final ModelIgnoreDecider ignoreDecider = new ModelIgnoreDecider(registry); + private final ExternalImportResolver importResolver = new ExternalImportResolver(registry); - // ================================ - // PHASE 1 β€” MARK - // ================================ + /** Registers external model mappings from additionalProperties. */ + @Override + public void processOpts() { + super.processOpts(); + registry.register(additionalProperties); - /** - * Intercepts model creation and marks models that should be ignored. - * - *

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 extensions = - (model != null) ? model.getExtensions() : null; + if (ignoreDecider.shouldIgnore(name, model)) { + ignoreDecider.markIgnored(name); + } - if (isIgnoredModel(extensions)) { - ignoredModels.add(name); - log.debug("Marked model as ignored: {}", name); - } + cleanImports(cm); + return cm; + } - if (codegenModel.imports != null && !codegenModel.imports.isEmpty()) { - codegenModel.imports.removeIf(this::shouldIgnore); - } + /** Removes ignored models and injects external imports into wrapper models. */ + @Override + public ModelsMap postProcessModels(ModelsMap modelsMap) { + ModelsMap result = super.postProcessModels(modelsMap); - return codegenModel; + if (result == null || result.getModels() == null) { + return result; } - // ================================ - // PHASE 2 β€” LOCAL FILTER - // ================================ + int before = result.getModels().size(); - /** - * Removes ignored models from the current processing batch. - * - *

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: - *

    - *
  • No file generation
  • - *
  • No downstream references treated as generatable models
  • - *
- */ - @Override - public Map postProcessAllModels(Map allModels) { + result + .getModels() + .forEach( + m -> { + CodegenModel model = m.getModel(); + if (model != null) { + importResolver.apply(model); + } + }); - Map result = super.postProcessAllModels(allModels); + return result; + } - Iterator> iterator = result.entrySet().iterator(); + /** Ensures ignored models are fully removed from the generation graph. */ + @Override + public Map postProcessAllModels(Map allModels) { + Map result = super.postProcessAllModels(allModels); - while (iterator.hasNext()) { - Map.Entry entry = iterator.next(); + int before = result.size(); - if (shouldIgnore(entry.getKey())) { - log.debug("Removed model from generation graph: {}", entry.getKey()); - iterator.remove(); - } - } + result.entrySet().removeIf(e -> ignoreDecider.isIgnored(e.getKey())); - return result; - } + int after = result.size(); - /** - * Name of the custom generator. - */ - @Override - public String getName() { - return "java-generics-contract"; + if (before != after) { + log.debug("Removed ignored models from global model graph: {} -> {}", before, after); } - // ================================ - // INTERNAL HELPERS - // ================================ + return result; + } - private boolean isIgnoredModel(Map extensions) { - return extensions != null && Boolean.TRUE.equals(extensions.get(EXT_IGNORE_MODEL)); - } + @Override + public String getName() { + return "java-generics-contract"; + } - private boolean shouldIgnore(String modelName) { - return ignoredModels.contains(modelName); - } -} \ No newline at end of file + /** Removes imports that reference ignored models. */ + private void cleanImports(CodegenModel model) { + if (model.imports == null || model.imports.isEmpty()) return; + model.imports.removeIf(ignoreDecider::isIgnored); + } +} diff --git a/openapi-generics-java-codegen/src/main/java/io/github/blueprintplatform/openapi/generics/codegen/ModelIgnoreDecider.java b/openapi-generics-java-codegen/src/main/java/io/github/blueprintplatform/openapi/generics/codegen/ModelIgnoreDecider.java new file mode 100644 index 0000000..ac125eb --- /dev/null +++ b/openapi-generics-java-codegen/src/main/java/io/github/blueprintplatform/openapi/generics/codegen/ModelIgnoreDecider.java @@ -0,0 +1,68 @@ +package io.github.blueprintplatform.openapi.generics.codegen; + +import io.swagger.v3.oas.models.media.Schema; +import java.util.HashSet; +import java.util.Map; +import java.util.Set; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * Decides whether a model should be excluded from generation. + * + *

A model is ignored if: + * + *

    + *
  • {@code x-ignore-model=true} is present in schema extensions + *
  • it is registered as an external model + *
+ * + *

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 ignored = new HashSet<>(); + private final ExternalModelRegistry registry; + + public ModelIgnoreDecider(ExternalModelRegistry registry) { + this.registry = registry; + } + + /** Evaluates ignore rules for a model. */ + public boolean shouldIgnore(String name, Schema model) { + boolean byExtension = isIgnoredByExtension(model); + boolean byExternal = registry.isExternal(name); + + if (byExtension) { + log.debug("Model ignored by extension (x-ignore-model): {}", name); + } else if (byExternal) { + log.debug("Model ignored as external model: {}", name); + } + + return byExtension || byExternal; + } + + /** Marks model as ignored. */ + public void markIgnored(String name) { + ignored.add(name); + log.debug("Marked model as ignored: {}", name); + } + + /** + * @return true if model is already marked as ignored + */ + public boolean isIgnored(String name) { + return ignored.contains(name); + } + + private boolean isIgnoredByExtension(Schema model) { + if (model == null) return false; + + Map ext = model.getExtensions(); + return ext != null && Boolean.TRUE.equals(ext.get(EXT_IGNORE_MODEL)); + } +} diff --git a/openapi-generics-java-codegen/src/main/resources/META-INF/openapi-generics/templates/api_wrapper.mustache b/openapi-generics-java-codegen/src/main/resources/META-INF/openapi-generics/templates/api_wrapper.mustache index 2aeeb46..36d6442 100644 --- a/openapi-generics-java-codegen/src/main/resources/META-INF/openapi-generics/templates/api_wrapper.mustache +++ b/openapi-generics-java-codegen/src/main/resources/META-INF/openapi-generics/templates/api_wrapper.mustache @@ -1,3 +1,7 @@ +{{#vendorExtensions.x-extra-imports}} +import {{.}}; +{{/vendorExtensions.x-extra-imports}} +import io.github.blueprintplatform.openapi.generics.contract.envelope.ServiceResponse; {{#vendorExtensions.x-data-container}} import io.github.blueprintplatform.openapi.generics.contract.paging.{{vendorExtensions.x-data-container}}; {{/vendorExtensions.x-data-container}} diff --git a/openapi-generics-java-codegen/src/test/java/io/github/blueprintplatform/openapi/generics/codegen/ExternalImportResolverTest.java b/openapi-generics-java-codegen/src/test/java/io/github/blueprintplatform/openapi/generics/codegen/ExternalImportResolverTest.java new file mode 100644 index 0000000..6a1f9a0 --- /dev/null +++ b/openapi-generics-java-codegen/src/test/java/io/github/blueprintplatform/openapi/generics/codegen/ExternalImportResolverTest.java @@ -0,0 +1,124 @@ +package io.github.blueprintplatform.openapi.generics.codegen; + +import static org.junit.jupiter.api.Assertions.*; + +import java.util.HashMap; +import java.util.Map; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; +import org.openapitools.codegen.CodegenModel; + +@Tag("unit") +@DisplayName("Unit Test: ExternalImportResolver") +class ExternalImportResolverTest { + + @Test + @DisplayName("apply -> does nothing when model is not a wrapper") + void apply_shouldDoNothing_whenNotWrapper() { + ExternalModelRegistry registry = new ExternalModelRegistry(); + ExternalImportResolver resolver = new ExternalImportResolver(registry); + + CodegenModel model = new CodegenModel(); + model.vendorExtensions = new HashMap<>(); + + resolver.apply(model); + + assertFalse(model.vendorExtensions.containsKey("x-extra-imports")); + } + + @Test + @DisplayName("apply -> injects import when wrapper and mapping exists (x-data-item)") + void apply_shouldInjectImport_fromDataItem() { + ExternalModelRegistry registry = new ExternalModelRegistry(); + registry.register( + Map.of( + "openapiGenerics.responseContract.CustomerDto", + "io.example.CustomerDto")); + + ExternalImportResolver resolver = new ExternalImportResolver(registry); + + CodegenModel model = new CodegenModel(); + model.name = "ServiceResponseCustomerDto"; + model.vendorExtensions = new HashMap<>(); + + model.vendorExtensions.put("x-api-wrapper", true); + model.vendorExtensions.put("x-data-item", "CustomerDto"); + + resolver.apply(model); + + assertEquals( + "io.example.CustomerDto", + model.vendorExtensions.get("x-extra-imports")); + } + + @Test + @DisplayName("apply -> injects import when wrapper and mapping exists (x-api-wrapper-datatype)") + void apply_shouldInjectImport_fromWrapperDatatype() { + ExternalModelRegistry registry = new ExternalModelRegistry(); + registry.register( + Map.of( + "openapiGenerics.responseContract.CustomerDto", + "io.example.CustomerDto")); + + ExternalImportResolver resolver = new ExternalImportResolver(registry); + + CodegenModel model = new CodegenModel(); + model.name = "ServiceResponseCustomerDto"; + model.vendorExtensions = new HashMap<>(); + + model.vendorExtensions.put("x-api-wrapper", true); + model.vendorExtensions.put("x-api-wrapper-datatype", "CustomerDto"); + + resolver.apply(model); + + assertEquals( + "io.example.CustomerDto", + model.vendorExtensions.get("x-extra-imports")); + } + + @Test + @DisplayName("apply -> does nothing when mapping does not exist") + void apply_shouldDoNothing_whenMappingMissing() { + ExternalModelRegistry registry = new ExternalModelRegistry(); + ExternalImportResolver resolver = new ExternalImportResolver(registry); + + CodegenModel model = new CodegenModel(); + model.vendorExtensions = new HashMap<>(); + + model.vendorExtensions.put("x-api-wrapper", true); + model.vendorExtensions.put("x-data-item", "CustomerDto"); + + resolver.apply(model); + + assertFalse(model.vendorExtensions.containsKey("x-extra-imports")); + } + + @Test + @DisplayName("apply -> does nothing when no type info present") + void apply_shouldDoNothing_whenNoTypeInfo() { + ExternalModelRegistry registry = new ExternalModelRegistry(); + ExternalImportResolver resolver = new ExternalImportResolver(registry); + + CodegenModel model = new CodegenModel(); + model.vendorExtensions = new HashMap<>(); + + model.vendorExtensions.put("x-api-wrapper", true); + + resolver.apply(model); + + assertFalse(model.vendorExtensions.containsKey("x-extra-imports")); + } + + @Test + @DisplayName("apply -> safe when vendorExtensions is null") + void apply_shouldBeSafe_whenVendorExtensionsNull() { + ExternalModelRegistry registry = new ExternalModelRegistry(); + ExternalImportResolver resolver = new ExternalImportResolver(registry); + + CodegenModel model = new CodegenModel(); + model.vendorExtensions = null; + + assertDoesNotThrow(() -> resolver.apply(model)); + } +} \ No newline at end of file diff --git a/openapi-generics-java-codegen/src/test/java/io/github/blueprintplatform/openapi/generics/codegen/ExternalModelRegistryTest.java b/openapi-generics-java-codegen/src/test/java/io/github/blueprintplatform/openapi/generics/codegen/ExternalModelRegistryTest.java new file mode 100644 index 0000000..a1dc760 --- /dev/null +++ b/openapi-generics-java-codegen/src/test/java/io/github/blueprintplatform/openapi/generics/codegen/ExternalModelRegistryTest.java @@ -0,0 +1,80 @@ +package io.github.blueprintplatform.openapi.generics.codegen; + +import static org.junit.jupiter.api.Assertions.*; + +import java.util.Map; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; + +@Tag("unit") +@DisplayName("Unit Test: ExternalModelRegistry") +class ExternalModelRegistryTest { + + @Test + @DisplayName("register -> should store external model mappings") + void register_shouldStoreMappings() { + ExternalModelRegistry registry = new ExternalModelRegistry(); + + Map props = + Map.of( + "openapiGenerics.responseContract.CustomerDto", + "io.example.CustomerDto", + "openapiGenerics.responseContract.OrderDto", + "io.example.OrderDto"); + + registry.register(props); + + assertTrue(registry.isExternal("CustomerDto")); + assertTrue(registry.isExternal("OrderDto")); + + assertEquals("io.example.CustomerDto", registry.getFqcn("CustomerDto")); + assertEquals("io.example.OrderDto", registry.getFqcn("OrderDto")); + } + + @Test + @DisplayName("register -> should ignore unrelated properties") + void register_shouldIgnoreUnrelatedKeys() { + ExternalModelRegistry registry = new ExternalModelRegistry(); + + Map props = + Map.of( + "some.other.key", "value", + "openapiGenerics.wrongPrefix.CustomerDto", "io.example.CustomerDto"); + + registry.register(props); + + assertFalse(registry.isExternal("CustomerDto")); + assertNull(registry.getFqcn("CustomerDto")); + } + + @Test + @DisplayName("isExternal -> returns false for unknown model") + void isExternal_shouldReturnFalse_whenUnknown() { + ExternalModelRegistry registry = new ExternalModelRegistry(); + + assertFalse(registry.isExternal("Unknown")); + } + + @Test + @DisplayName("getFqcn -> returns null for unknown model") + void getFqcn_shouldReturnNull_whenUnknown() { + ExternalModelRegistry registry = new ExternalModelRegistry(); + + assertNull(registry.getFqcn("Unknown")); + } + + @Test + @DisplayName("register -> should override existing mapping") + void register_shouldOverrideExistingMapping() { + ExternalModelRegistry registry = new ExternalModelRegistry(); + + registry.register( + Map.of("openapiGenerics.responseContract.CustomerDto", "io.example.CustomerDtoV1")); + + registry.register( + Map.of("openapiGenerics.responseContract.CustomerDto", "io.example.CustomerDtoV2")); + + assertEquals("io.example.CustomerDtoV2", registry.getFqcn("CustomerDto")); + } +} diff --git a/openapi-generics-java-codegen/src/test/java/io/github/blueprintplatform/openapi/generics/codegen/GenericAwareJavaCodegenTest.java b/openapi-generics-java-codegen/src/test/java/io/github/blueprintplatform/openapi/generics/codegen/GenericAwareJavaCodegenTest.java new file mode 100644 index 0000000..68ba7fd --- /dev/null +++ b/openapi-generics-java-codegen/src/test/java/io/github/blueprintplatform/openapi/generics/codegen/GenericAwareJavaCodegenTest.java @@ -0,0 +1,84 @@ +package io.github.blueprintplatform.openapi.generics.codegen; + +import static org.junit.jupiter.api.Assertions.*; + +import io.swagger.v3.oas.models.media.Schema; +import java.util.ArrayList; +import java.util.List; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; +import org.openapitools.codegen.CodegenModel; +import org.openapitools.codegen.model.ModelMap; +import org.openapitools.codegen.model.ModelsMap; + +@Tag("unit") +@DisplayName("Smoke Test: GenericAwareJavaCodegen") +class GenericAwareJavaCodegenTest { + + @Test + @DisplayName("processOpts + fromModel + postProcessModels -> should filter external model") + void shouldFilterExternalModel_andKeepOthers() { + GenericAwareJavaCodegen codegen = new GenericAwareJavaCodegen(); + + codegen.additionalProperties().put( + "openapiGenerics.responseContract.CustomerDto", + "io.example.CustomerDto"); + + codegen.processOpts(); + + Schema externalSchema = new Schema<>(); + Schema normalSchema = new Schema<>(); + + CodegenModel externalModel = codegen.fromModel("CustomerDto", externalSchema); + CodegenModel normalModel = codegen.fromModel("OrderDto", normalSchema); + + ModelMap mm1 = new ModelMap(); + mm1.setModel(externalModel); + + ModelMap mm2 = new ModelMap(); + mm2.setModel(normalModel); + + ModelsMap modelsMap = new ModelsMap(); + + // FIX: mutable list + List modelList = new ArrayList<>(); + modelList.add(mm1); + modelList.add(mm2); + + modelsMap.setModels(modelList); + + ModelsMap result = codegen.postProcessModels(modelsMap); + + assertNotNull(result); + assertNotNull(result.getModels()); + + assertEquals(1, result.getModels().size()); + assertEquals("OrderDto", result.getModels().get(0).getModel().name); + } + + @Test + @DisplayName("fromModel -> should clean imports of ignored models") + void shouldCleanImports_ofIgnoredModels() { + GenericAwareJavaCodegen codegen = new GenericAwareJavaCodegen(); + + codegen.additionalProperties().put( + "openapiGenerics.responseContract.CustomerDto", + "io.example.CustomerDto"); + + codegen.processOpts(); + + Schema schema = new Schema<>(); + + CodegenModel model = codegen.fromModel("CustomerDto", schema); + + model.imports = new java.util.HashSet<>(List.of("CustomerDto", "OtherDto")); + + CodegenModel processed = codegen.fromModel("CustomerDto", schema); + + assertNotNull(processed); + if (processed.imports != null) { + assertFalse(processed.imports.contains("CustomerDto")); + } + } +} \ No newline at end of file diff --git a/openapi-generics-java-codegen/src/test/java/io/github/blueprintplatform/openapi/generics/codegen/ModelIgnoreDeciderTest.java b/openapi-generics-java-codegen/src/test/java/io/github/blueprintplatform/openapi/generics/codegen/ModelIgnoreDeciderTest.java new file mode 100644 index 0000000..b49feaf --- /dev/null +++ b/openapi-generics-java-codegen/src/test/java/io/github/blueprintplatform/openapi/generics/codegen/ModelIgnoreDeciderTest.java @@ -0,0 +1,97 @@ +package io.github.blueprintplatform.openapi.generics.codegen; + +import static org.junit.jupiter.api.Assertions.*; + +import io.swagger.v3.oas.models.media.Schema; +import java.util.Map; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; + +@Tag("unit") +@DisplayName("Unit Test: ModelIgnoreDecider") +class ModelIgnoreDeciderTest { + + @Test + @DisplayName("shouldIgnore -> true when x-ignore-model=true") + void shouldIgnore_byExtension() { + ExternalModelRegistry registry = new ExternalModelRegistry(); + ModelIgnoreDecider decider = new ModelIgnoreDecider(registry); + + Schema schema = new Schema<>(); + schema.setExtensions(Map.of("x-ignore-model", true)); + + boolean result = decider.shouldIgnore("CustomerDto", schema); + + assertTrue(result); + } + + @Test + @DisplayName("shouldIgnore -> true when model is registered as external") + void shouldIgnore_byExternalRegistry() { + ExternalModelRegistry registry = new ExternalModelRegistry(); + registry.register( + Map.of("openapiGenerics.responseContract.CustomerDto", "io.example.CustomerDto")); + + ModelIgnoreDecider decider = new ModelIgnoreDecider(registry); + + Schema schema = new Schema<>(); + + boolean result = decider.shouldIgnore("CustomerDto", schema); + + assertTrue(result); + } + + @Test + @DisplayName("shouldIgnore -> false when no rules match") + void shouldIgnore_false_whenNoMatch() { + ExternalModelRegistry registry = new ExternalModelRegistry(); + ModelIgnoreDecider decider = new ModelIgnoreDecider(registry); + + Schema schema = new Schema<>(); + + boolean result = decider.shouldIgnore("CustomerDto", schema); + + assertFalse(result); + } + + @Test + @DisplayName("markIgnored + isIgnored -> should track ignored models") + void markIgnored_shouldTrackState() { + ExternalModelRegistry registry = new ExternalModelRegistry(); + ModelIgnoreDecider decider = new ModelIgnoreDecider(registry); + + decider.markIgnored("CustomerDto"); + + assertTrue(decider.isIgnored("CustomerDto")); + assertFalse(decider.isIgnored("OtherDto")); + } + + @Test + @DisplayName("shouldIgnore -> extension takes precedence (both true anyway)") + void shouldIgnore_extensionAndExternal() { + ExternalModelRegistry registry = new ExternalModelRegistry(); + registry.register( + Map.of("openapiGenerics.responseContract.CustomerDto", "io.example.CustomerDto")); + + ModelIgnoreDecider decider = new ModelIgnoreDecider(registry); + + Schema schema = new Schema<>(); + schema.setExtensions(Map.of("x-ignore-model", true)); + + boolean result = decider.shouldIgnore("CustomerDto", schema); + + assertTrue(result); + } + + @Test + @DisplayName("shouldIgnore -> handles null schema safely") + void shouldIgnore_nullSchema() { + ExternalModelRegistry registry = new ExternalModelRegistry(); + ModelIgnoreDecider decider = new ModelIgnoreDecider(registry); + + boolean result = decider.shouldIgnore("CustomerDto", null); + + assertFalse(result); + } +} diff --git a/openapi-generics-platform-bom/README.md b/openapi-generics-platform-bom/README.md index c95b6c3..5235404 100644 --- a/openapi-generics-platform-bom/README.md +++ b/openapi-generics-platform-bom/README.md @@ -82,7 +82,7 @@ Example (internal usage): io.github.blueprintplatform openapi-generics-platform-bom - 0.8.x + 0.9.x pom import diff --git a/openapi-generics-platform-bom/pom.xml b/openapi-generics-platform-bom/pom.xml index 421246d..b50b9a3 100644 --- a/openapi-generics-platform-bom/pom.xml +++ b/openapi-generics-platform-bom/pom.xml @@ -8,7 +8,7 @@ io.github.blueprint-platform openapi-generics - 0.8.2 + 0.9.0 openapi-generics-platform-bom @@ -32,7 +32,7 @@ https://github.com/blueprint-platform/openapi-generics scm:git:https://github.com/blueprint-platform/openapi-generics.git scm:git:ssh://git@github.com:blueprint-platform/openapi-generics.git - v0.8.2 + v0.9.0 @@ -44,7 +44,7 @@ - 0.8.2 + 0.9.0 7.21.0 2.8.16 diff --git a/openapi-generics-server-starter/README.md b/openapi-generics-server-starter/README.md index 059f5d8..04f2304 100644 --- a/openapi-generics-server-starter/README.md +++ b/openapi-generics-server-starter/README.md @@ -127,7 +127,7 @@ This module is designed to work with the following baseline while remaining forw io.github.blueprintplatform openapi-generics-server-starter - 0.8.x + 0.9.x ``` diff --git a/openapi-generics-server-starter/pom.xml b/openapi-generics-server-starter/pom.xml index 563f52d..a290664 100644 --- a/openapi-generics-server-starter/pom.xml +++ b/openapi-generics-server-starter/pom.xml @@ -8,7 +8,7 @@ io.github.blueprint-platform openapi-generics - 0.8.2 + 0.9.0 openapi-generics-server-starter @@ -33,7 +33,7 @@ https://github.com/blueprint-platform/openapi-generics scm:git:https://github.com/blueprint-platform/openapi-generics.git scm:git:ssh://git@github.com:blueprint-platform/openapi-generics.git - v0.8.2 + v0.9.0 @@ -63,7 +63,7 @@ io.github.blueprint-platform openapi-generics-platform-bom - 0.8.2 + 0.9.0 pom import diff --git a/openapi-generics-server-starter/src/test/java/io/github/blueprintplatform/openapi/generics/server/core/introspection/ResponseTypeIntrospectorTest.java b/openapi-generics-server-starter/src/test/java/io/github/blueprintplatform/openapi/generics/server/core/introspection/ResponseTypeIntrospectorTest.java new file mode 100644 index 0000000..5010da4 --- /dev/null +++ b/openapi-generics-server-starter/src/test/java/io/github/blueprintplatform/openapi/generics/server/core/introspection/ResponseTypeIntrospectorTest.java @@ -0,0 +1,87 @@ +package io.github.blueprintplatform.openapi.generics.server.core.introspection; + +import static org.junit.jupiter.api.Assertions.*; + +import io.github.blueprintplatform.openapi.generics.contract.envelope.ServiceResponse; +import io.github.blueprintplatform.openapi.generics.contract.paging.Page; +import java.util.Optional; +import org.junit.jupiter.api.Test; +import org.springframework.core.ResolvableType; +import org.springframework.http.ResponseEntity; + +class ResponseTypeIntrospectorTest { + + private final ResponseTypeIntrospector introspector = new ResponseTypeIntrospector(); + + @Test + void shouldExtractSimpleDataType() { + ResolvableType type = + ResolvableType.forClassWithGenerics(ServiceResponse.class, CustomerDto.class); + + Optional result = introspector.extractDataRefName(type); + + assertTrue(result.isPresent()); + assertEquals("CustomerDto", result.get()); + } + + @Test + void shouldExtractPageDataType() { + ResolvableType pageType = + ResolvableType.forClassWithGenerics(Page.class, CustomerDto.class); + + ResolvableType type = + ResolvableType.forClassWithGenerics(ServiceResponse.class, pageType); + + Optional result = introspector.extractDataRefName(type); + + assertTrue(result.isPresent()); + assertEquals("PageCustomerDto", result.get()); + } + + @Test + void shouldUnwrapResponseEntity() { + ResolvableType inner = + ResolvableType.forClassWithGenerics(ServiceResponse.class, CustomerDto.class); + + ResolvableType type = + ResolvableType.forClassWithGenerics(ResponseEntity.class, inner); + + Optional result = introspector.extractDataRefName(type); + + assertTrue(result.isPresent()); + assertEquals("CustomerDto", result.get()); + } + + @Test + void shouldReturnEmptyForNonServiceResponse() { + ResolvableType type = ResolvableType.forClass(CustomerDto.class); + + Optional result = introspector.extractDataRefName(type); + + assertTrue(result.isEmpty()); + } + + @Test + void shouldReturnEmptyForMissingGeneric() { + ResolvableType type = ResolvableType.forClass(ServiceResponse.class); + + Optional result = introspector.extractDataRefName(type); + + assertTrue(result.isEmpty()); + } + + @Test + void shouldReturnEmptyForUnsupportedNestedGenerics() { + ResolvableType listType = + ResolvableType.forClassWithGenerics(java.util.List.class, CustomerDto.class); + + ResolvableType type = + ResolvableType.forClassWithGenerics(ServiceResponse.class, listType); + + Optional result = introspector.extractDataRefName(type); + + assertTrue(result.isEmpty()); + } + + static class CustomerDto {} +} \ No newline at end of file diff --git a/openapi-generics-server-starter/src/test/java/io/github/blueprintplatform/openapi/generics/server/core/pipeline/OpenApiPipelineOrchestratorTest.java b/openapi-generics-server-starter/src/test/java/io/github/blueprintplatform/openapi/generics/server/core/pipeline/OpenApiPipelineOrchestratorTest.java new file mode 100644 index 0000000..6abbadc --- /dev/null +++ b/openapi-generics-server-starter/src/test/java/io/github/blueprintplatform/openapi/generics/server/core/pipeline/OpenApiPipelineOrchestratorTest.java @@ -0,0 +1,85 @@ +package io.github.blueprintplatform.openapi.generics.server.core.pipeline; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +import io.github.blueprintplatform.openapi.generics.server.core.introspection.ResponseTypeDiscoveryStrategy; +import io.github.blueprintplatform.openapi.generics.server.core.introspection.ResponseTypeIntrospector; +import io.github.blueprintplatform.openapi.generics.server.core.schema.WrapperSchemaProcessor; +import io.github.blueprintplatform.openapi.generics.server.core.schema.base.BaseSchemaRegistrar; +import io.github.blueprintplatform.openapi.generics.server.core.schema.base.SchemaGenerationControlMarker; +import io.github.blueprintplatform.openapi.generics.server.core.validation.OpenApiContractGuard; +import io.swagger.v3.oas.models.Components; +import io.swagger.v3.oas.models.OpenAPI; +import java.util.Set; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.core.ResolvableType; + +class OpenApiPipelineOrchestratorTest { + + private final BaseSchemaRegistrar baseSchemaRegistrar = mock(BaseSchemaRegistrar.class); + private final SchemaGenerationControlMarker marker = mock(SchemaGenerationControlMarker.class); + private final ResponseTypeDiscoveryStrategy discovery = mock(ResponseTypeDiscoveryStrategy.class); + private final ResponseTypeIntrospector introspector = mock(ResponseTypeIntrospector.class); + private final WrapperSchemaProcessor processor = mock(WrapperSchemaProcessor.class); + private final OpenApiContractGuard guard = mock(OpenApiContractGuard.class); + + private final OpenApiPipelineOrchestrator orchestrator = + new OpenApiPipelineOrchestrator( + baseSchemaRegistrar, marker, discovery, introspector, processor, guard); + + @Test + @DisplayName("run -> should execute full pipeline") + void shouldRunFullPipeline() { + OpenAPI openApi = new OpenAPI().components(new Components()); + + ResolvableType type = + ResolvableType.forClassWithGenerics( + io.github.blueprintplatform.openapi.generics.contract.envelope.ServiceResponse.class, + CustomerDto.class); + + when(discovery.discover()).thenReturn(Set.of(type)); + when(introspector.extractDataRefName(type)).thenReturn(java.util.Optional.of("CustomerDto")); + + orchestrator.run(openApi); + + verify(baseSchemaRegistrar).register(openApi); + verify(discovery).discover(); + verify(introspector).extractDataRefName(type); + verify(processor).process(openApi, "CustomerDto"); + verify(marker).mark(openApi); + verify(guard).validate(openApi); + } + + @Test + @DisplayName("run -> should skip duplicate execution") + void shouldSkipDuplicateExecution() { + OpenAPI openApi = new OpenAPI().components(new Components()); + + when(discovery.discover()).thenReturn(Set.of()); + when(introspector.extractDataRefName(any())).thenReturn(java.util.Optional.empty()); + + orchestrator.run(openApi); + orchestrator.run(openApi); // ikinci Γ§ağrΔ± + + verify(baseSchemaRegistrar, times(1)).register(openApi); + verify(guard, times(1)).validate(openApi); + } + + @Test + @DisplayName("run -> should handle empty discovery") + void shouldHandleEmptyDiscovery() { + OpenAPI openApi = new OpenAPI().components(new Components()); + + when(discovery.discover()).thenReturn(Set.of()); + + orchestrator.run(openApi); + + verify(processor, never()).process(any(), any()); + verify(marker).mark(openApi); + verify(guard).validate(openApi); + } + + static class CustomerDto {} +} diff --git a/openapi-generics-server-starter/src/test/java/io/github/blueprintplatform/openapi/generics/server/core/schema/WrapperSchemaProcessorTest.java b/openapi-generics-server-starter/src/test/java/io/github/blueprintplatform/openapi/generics/server/core/schema/WrapperSchemaProcessorTest.java new file mode 100644 index 0000000..89662bf --- /dev/null +++ b/openapi-generics-server-starter/src/test/java/io/github/blueprintplatform/openapi/generics/server/core/schema/WrapperSchemaProcessorTest.java @@ -0,0 +1,79 @@ +package io.github.blueprintplatform.openapi.generics.server.core.schema; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +import io.github.blueprintplatform.openapi.generics.server.core.schema.contract.SchemaNames; +import io.swagger.v3.oas.models.Components; +import io.swagger.v3.oas.models.OpenAPI; +import io.swagger.v3.oas.models.media.Schema; +import java.util.HashMap; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +class WrapperSchemaProcessorTest { + + private final WrapperSchemaEnricher enricher = mock(WrapperSchemaEnricher.class); + + private final WrapperSchemaProcessor processor = + new WrapperSchemaProcessor(enricher, "@MyAnnotation"); + + @Test + @DisplayName("process -> should create wrapper schema") + void shouldCreateWrapperSchema() { + OpenAPI openApi = new OpenAPI().components(new Components().schemas(new HashMap<>())); + + processor.process(openApi, "CustomerDto"); + + Schema schema = + openApi.getComponents().getSchemas().get(SchemaNames.SERVICE_RESPONSE + "CustomerDto"); + + assertNotNull(schema); + assertNotNull(schema.getAllOf()); + assertFalse(schema.getAllOf().isEmpty()); + + verify(enricher).enrich(openApi, SchemaNames.SERVICE_RESPONSE + "CustomerDto", "CustomerDto"); + } + + @Test + @DisplayName("process -> should overwrite existing schema") + void shouldOverwriteExistingSchema() { + OpenAPI openApi = new OpenAPI().components(new Components().schemas(new HashMap<>())); + + String wrapperName = SchemaNames.SERVICE_RESPONSE + "CustomerDto"; + + Schema oldSchema = new Schema<>(); + openApi.getComponents().getSchemas().put(wrapperName, oldSchema); + + processor.process(openApi, "CustomerDto"); + + Schema newSchema = openApi.getComponents().getSchemas().get(wrapperName); + + assertNotSame(oldSchema, newSchema); + + verify(enricher).enrich(openApi, wrapperName, "CustomerDto"); + } + + @Test + @DisplayName("process -> should apply class extra annotation via extension") + void shouldApplyExtraAnnotation() { + OpenAPI openApi = new OpenAPI().components(new Components().schemas(new HashMap<>())); + + processor.process(openApi, "CustomerDto"); + + Schema schema = + openApi.getComponents().getSchemas().get(SchemaNames.SERVICE_RESPONSE + "CustomerDto"); + + assertNotNull(schema.getExtensions()); + assertTrue(schema.getExtensions().containsKey("x-class-extra-annotation")); + assertEquals("@MyAnnotation", schema.getExtensions().get("x-class-extra-annotation")); + } + + @Test + @DisplayName("process -> should not fail with empty schemas map") + void shouldHandleEmptySchemas() { + OpenAPI openApi = new OpenAPI().components(new Components().schemas(new HashMap<>())); + + assertDoesNotThrow(() -> processor.process(openApi, "CustomerDto")); + } +} diff --git a/openapi-generics-server-starter/src/test/java/io/github/blueprintplatform/openapi/generics/server/core/schema/base/BaseSchemaRegistrarTest.java b/openapi-generics-server-starter/src/test/java/io/github/blueprintplatform/openapi/generics/server/core/schema/base/BaseSchemaRegistrarTest.java new file mode 100644 index 0000000..063f2ed --- /dev/null +++ b/openapi-generics-server-starter/src/test/java/io/github/blueprintplatform/openapi/generics/server/core/schema/base/BaseSchemaRegistrarTest.java @@ -0,0 +1,102 @@ +package io.github.blueprintplatform.openapi.generics.server.core.schema.base; + +import static org.junit.jupiter.api.Assertions.*; + +import io.github.blueprintplatform.openapi.generics.server.core.schema.contract.PropertyNames; +import io.github.blueprintplatform.openapi.generics.server.core.schema.contract.SchemaNames; +import io.swagger.v3.oas.models.OpenAPI; +import io.swagger.v3.oas.models.media.Schema; +import java.util.Map; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +class BaseSchemaRegistrarTest { + + private final BaseSchemaRegistrar registrar = new BaseSchemaRegistrar(); + + @Test + @DisplayName("register -> should create all base schemas") + void shouldRegisterAllBaseSchemas() { + OpenAPI openApi = new OpenAPI(); + + registrar.register(openApi); + + Map schemas = openApi.getComponents().getSchemas(); + + assertNotNull(schemas); + assertTrue(schemas.containsKey(SchemaNames.SORT)); + assertTrue(schemas.containsKey(SchemaNames.META)); + assertTrue(schemas.containsKey(SchemaNames.SERVICE_RESPONSE)); + assertTrue(schemas.containsKey(SchemaNames.SERVICE_RESPONSE_VOID)); + } + + @Test + @DisplayName("register -> should be idempotent (no overwrite)") + void shouldBeIdempotent() { + OpenAPI openApi = new OpenAPI(); + + registrar.register(openApi); + Map first = openApi.getComponents().getSchemas(); + + registrar.register(openApi); + Map second = openApi.getComponents().getSchemas(); + + assertSame(first.get(SchemaNames.SORT), second.get(SchemaNames.SORT)); + assertSame(first.get(SchemaNames.META), second.get(SchemaNames.META)); + assertSame(first.get(SchemaNames.SERVICE_RESPONSE), second.get(SchemaNames.SERVICE_RESPONSE)); + assertSame( + first.get(SchemaNames.SERVICE_RESPONSE_VOID), + second.get(SchemaNames.SERVICE_RESPONSE_VOID)); + } + + @Test + @DisplayName("ServiceResponse -> should contain data and meta") + void shouldCreateServiceResponseStructure() { + OpenAPI openApi = new OpenAPI(); + + registrar.register(openApi); + + Schema schema = openApi.getComponents().getSchemas().get(SchemaNames.SERVICE_RESPONSE); + + assertNotNull(schema.getProperties()); + assertTrue(schema.getProperties().containsKey(PropertyNames.DATA)); + assertTrue(schema.getProperties().containsKey(PropertyNames.META)); + + assertNotNull(schema.getRequired()); + assertTrue(schema.getRequired().contains(PropertyNames.META)); + } + + @Test + @DisplayName("Meta -> should contain serverTime and sort") + void shouldCreateMetaStructure() { + OpenAPI openApi = new OpenAPI(); + + registrar.register(openApi); + + Schema meta = openApi.getComponents().getSchemas().get(SchemaNames.META); + + assertNotNull(meta.getProperties()); + assertTrue(meta.getProperties().containsKey("serverTime")); + assertTrue(meta.getProperties().containsKey("sort")); + } + + @Test + @DisplayName("Sort -> should contain field and direction enum") + void shouldCreateSortStructure() { + OpenAPI openApi = new OpenAPI(); + + registrar.register(openApi); + + Schema sort = openApi.getComponents().getSchemas().get(SchemaNames.SORT); + + assertNotNull(sort.getProperties()); + assertTrue(sort.getProperties().containsKey("field")); + assertTrue(sort.getProperties().containsKey("direction")); + + Schema direction = (Schema) sort.getProperties().get("direction"); + + assertNotNull(direction.getEnum()); + assertTrue(direction.getEnum().contains("asc")); + assertTrue(direction.getEnum().contains("desc")); + } +} diff --git a/pom.xml b/pom.xml index d0526f3..91b1e7d 100644 --- a/pom.xml +++ b/pom.xml @@ -6,7 +6,7 @@ io.github.blueprint-platform openapi-generics - 0.8.2 + 0.9.0 pom openapi-generics (aggregator) @@ -28,7 +28,7 @@ https://github.com/blueprint-platform/openapi-generics scm:git:https://github.com/blueprint-platform/openapi-generics.git scm:git:ssh://git@github.com:blueprint-platform/openapi-generics.git - v0.8.2 + v0.9.0 diff --git a/samples/README.md b/samples/README.md new file mode 100644 index 0000000..c7415a4 --- /dev/null +++ b/samples/README.md @@ -0,0 +1,200 @@ +# samples + +> Reference playground for the openapi-generics platform β€” demonstrating contract, projection, and generation end-to-end + +--- + +## πŸ“‘ Table of Contents + +* [🎯 What this directory is for](#-what-this-directory-is-for) +* [πŸ—οΈ Structure](#-structure) +* [🧠 Key idea](#-key-idea) +* [πŸ“¦ domain-contracts](#-domain-contracts) +* [πŸ”„ Spring Boot 3 & 4 pipelines](#-spring-boot-3--4-pipelines) +* [🐳 Running with Docker Compose](#-running-with-docker-compose) +* [πŸ§ͺ What to verify](#-what-to-verify) +* [πŸ”‘ Important notes](#-important-notes) +* [🧾 Summary](#-summary) + +--- + +## 🎯 What this directory is for + +The `samples` module provides **working, minimal reference implementations** of the full platform pipeline: + +```text +Contract β†’ Producer β†’ OpenAPI β†’ Client β†’ Consumer +``` + +It exists to answer one question: + +> How does this actually run in a real setup? + +--- + +## πŸ—οΈ Structure + +```text +samples +β”œβ”€β”€ domain-contracts +β”‚ └── customer-contract +β”‚ +β”œβ”€β”€ spring-boot-3 +β”‚ β”œβ”€β”€ customer-service +β”‚ β”œβ”€β”€ customer-service-client +β”‚ └── customer-service-consumer +β”‚ +└── spring-boot-4 + β”œβ”€β”€ customer-service + β”œβ”€β”€ customer-service-client + └── customer-service-consumer +``` + +--- + +## 🧠 Key idea + +Each stack (SB3 / SB4) contains a **complete pipeline**: + +```text +Producer β†’ OpenAPI β†’ Generated Client β†’ Consumer +``` + +All modules are intentionally minimal and focused on **correct structure**, not features. + +--- + +## πŸ“¦ domain-contracts + +Contains shared domain models. + +Example: + +```text +CustomerDto +``` + +This module represents: + +> The external contract reused across producer and client + +--- + +## πŸ”„ Spring Boot 3 & 4 pipelines + +Each version demonstrates the same architecture with different runtime stacks. + +### Producer + +```text +customer-service +``` + +* exposes API +* returns `ServiceResponse` +* produces deterministic OpenAPI + +--- + +### Client + +```text +customer-service-client +``` + +* generates contract-aligned client +* preserves generics +* reuses contract models + +--- + +### Consumer + +```text +customer-service-consumer +``` + +* consumes generated client +* exposes API again +* preserves contract end-to-end + +--- + +## 🐳 Running with Docker Compose + +Each stack includes a compose setup. + +### Spring Boot 3 + +```bash +cd spring-boot-3 + +docker compose up --build +``` + +Services: + +* customer-service β†’ [http://localhost:8084/customer-service](http://localhost:8084/customer-service) +* customer-service-consumer β†’ [http://localhost:8085/customer-service-consumer](http://localhost:8085/customer-service-consumer) + +--- + +### Spring Boot 4 + +```bash +cd spring-boot-4 + +docker compose up --build +``` + +Services: + +* customer-service β†’ [http://localhost:8094/customer-service](http://localhost:8094/customer-service) +* customer-service-consumer β†’ [http://localhost:8095/customer-service-consumer](http://localhost:8095/customer-service-consumer) + +--- + +## πŸ§ͺ What to verify + +Example request: + +```bash +curl http://localhost:8085/customer-service-consumer/customers/1 +``` + +Expected shape: + +```json +{ + "data": { ... }, + "meta": { ... } +} +``` + +This confirms: + +```text +Contract β†’ OpenAPI β†’ Client β†’ Consumer is aligned +``` + +--- + +## πŸ”‘ Important notes + +* Samples are **not production systems** +* They are intentionally minimal +* They exist only to demonstrate: + +```text +Correct architecture + correct flow +``` + +--- + +## 🧾 Summary + +```text +Samples = runnable proof of the platform +``` + +They show how contract semantics move through the system **without duplication or drift**. diff --git a/samples/customer-service/docker-compose.yml b/samples/customer-service/docker-compose.yml deleted file mode 100644 index 7908f69..0000000 --- a/samples/customer-service/docker-compose.yml +++ /dev/null @@ -1,14 +0,0 @@ -services: - customer-service: - container_name: customer-service - build: - context: .. - dockerfile: customer-service/Dockerfile - image: customer-service:latest - restart: on-failure - environment: - APP_PORT: ${APP_PORT:-8084} - SPRING_PROFILES_ACTIVE: ${SPRING_PROFILES_ACTIVE:-local} - JAVA_OPTS: ${JAVA_OPTS:-} - ports: - - "${APP_PORT:-8084}:${APP_PORT:-8084}" \ No newline at end of file diff --git a/samples/customer-service/src/main/java/io/github/blueprintplatform/samples/customerservice/api/dto/CustomerDeleteResponse.java b/samples/customer-service/src/main/java/io/github/blueprintplatform/samples/customerservice/api/dto/CustomerDeleteResponse.java deleted file mode 100644 index 33d3b95..0000000 --- a/samples/customer-service/src/main/java/io/github/blueprintplatform/samples/customerservice/api/dto/CustomerDeleteResponse.java +++ /dev/null @@ -1,3 +0,0 @@ -package io.github.blueprintplatform.samples.customerservice.api.dto; - -public record CustomerDeleteResponse(Integer customerId) {} diff --git a/samples/customer-service/src/test/java/io/github/blueprintplatform/samples/customerservice/api/dto/CustomerDtoJsonTest.java b/samples/customer-service/src/test/java/io/github/blueprintplatform/samples/customerservice/api/dto/CustomerDtoJsonTest.java deleted file mode 100644 index 5d21ea2..0000000 --- a/samples/customer-service/src/test/java/io/github/blueprintplatform/samples/customerservice/api/dto/CustomerDtoJsonTest.java +++ /dev/null @@ -1,33 +0,0 @@ -package io.github.blueprintplatform.samples.customerservice.api.dto; - -import static org.assertj.core.api.Assertions.assertThat; - -import com.fasterxml.jackson.databind.ObjectMapper; -import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; -import org.junit.jupiter.api.*; - -@Tag("unit") -@DisplayName("DTO JSON: CustomerDto") -class CustomerDtoJsonTest { - - private ObjectMapper om; - - @BeforeEach - void setUp() { - om = new ObjectMapper(); - om.registerModule(new JavaTimeModule()); - } - - @Test - @DisplayName("serialize/deserialize should preserve fields") - void roundTrip_shouldPreserveFields() throws Exception { - var dto = new CustomerDto(5, "Mark Lee", "mark.lee@example.com"); - - String json = om.writeValueAsString(dto); - CustomerDto back = om.readValue(json, CustomerDto.class); - - assertThat(back.customerId()).isEqualTo(5); - assertThat(back.name()).isEqualTo("Mark Lee"); - assertThat(back.email()).isEqualTo("mark.lee@example.com"); - } -} diff --git a/samples/domain-contracts/README.md b/samples/domain-contracts/README.md new file mode 100644 index 0000000..6264a4a --- /dev/null +++ b/samples/domain-contracts/README.md @@ -0,0 +1,110 @@ +# Domain Contracts + +This module defines **shared domain-level contract models** used across services. + +It exists to separate **domain ownership** from: + +* server-side OpenAPI projection +* client-side code generation + +--- + +## Why this exists + +In distributed systems, contract models are often: + +* duplicated across services +* regenerated from OpenAPI +* gradually drifted over time + +This module prevents that. + +> Contracts are defined once, owned by the domain, and reused everywhere. + +--- + +## Structure + +``` +domain-contracts/ + └── customer-contract/ +``` + +Each submodule represents a **domain boundary**. + +Example: + +* `customer-contract` β†’ Customer domain models + +--- + +## Usage + +### Server (producer) + +Controllers return shared contract types: + +```java +ServiceResponse +``` + +The server-side starter projects this into OpenAPI. + +--- + +### Client (consumer) + +Clients reuse the same contract dependency: + +```xml + + io.github.blueprint-platform.samples + customer-contract + +``` + +The generator maps OpenAPI schemas to these types instead of regenerating them. + +--- + +## Design principle + +```text +Domain owns the contract +OpenAPI reflects it +Clients reuse it +``` + +--- + +## Scope + +This module contains: + +* domain DTOs (e.g. `CustomerDto`) +* no framework dependencies +* no OpenAPI annotations +* no transport concerns + +It is intentionally: + +* minimal +* stable +* framework-agnostic + +--- + +## Relation to openapi-generics + +This module works together with: + +* `openapi-generics-server-starter` β†’ projects contract to OpenAPI +* `openapi-generics-java-codegen` β†’ reuses contract in generated clients + +--- + +## Summary + +```text +Define once β†’ reuse everywhere β†’ no duplication β†’ no drift +``` diff --git a/samples/domain-contracts/customer-contract/pom.xml b/samples/domain-contracts/customer-contract/pom.xml new file mode 100644 index 0000000..fa02803 --- /dev/null +++ b/samples/domain-contracts/customer-contract/pom.xml @@ -0,0 +1,20 @@ + + + + 4.0.0 + + + io.github.blueprint-platform.samples + domain-contracts + 0.9.0 + + + customer-contract + jar + + customer-contract + Customer domain shared contract + + \ No newline at end of file diff --git a/samples/customer-service/src/main/java/io/github/blueprintplatform/samples/customerservice/api/dto/CustomerDto.java b/samples/domain-contracts/customer-contract/src/main/java/io/github/blueprintplatform/contracts/customer/CustomerDto.java similarity index 52% rename from samples/customer-service/src/main/java/io/github/blueprintplatform/samples/customerservice/api/dto/CustomerDto.java rename to samples/domain-contracts/customer-contract/src/main/java/io/github/blueprintplatform/contracts/customer/CustomerDto.java index 08702be..6db74d8 100644 --- a/samples/customer-service/src/main/java/io/github/blueprintplatform/samples/customerservice/api/dto/CustomerDto.java +++ b/samples/domain-contracts/customer-contract/src/main/java/io/github/blueprintplatform/contracts/customer/CustomerDto.java @@ -1,3 +1,3 @@ -package io.github.blueprintplatform.samples.customerservice.api.dto; +package io.github.blueprintplatform.contracts.customer; public record CustomerDto(Integer customerId, String name, String email) {} diff --git a/samples/domain-contracts/pom.xml b/samples/domain-contracts/pom.xml new file mode 100644 index 0000000..02b7ab8 --- /dev/null +++ b/samples/domain-contracts/pom.xml @@ -0,0 +1,48 @@ + + + 4.0.0 + + + + io.github.blueprint-platform.samples + samples + 0.9.0 + + + + domain-contracts + pom + + domain-contracts + Domain-based shared contracts (modular structure for microservices) + + + + 17 + UTF-8 + 3.15.0 + + + + + customer-contract + + + + + + + + org.apache.maven.plugins + maven-compiler-plugin + ${maven.compiler.plugin.version} + + ${java.version} + + + + + + \ No newline at end of file diff --git a/samples/pom.xml b/samples/pom.xml index c05f17c..43d8d1c 100644 --- a/samples/pom.xml +++ b/samples/pom.xml @@ -7,7 +7,7 @@ io.github.blueprint-platform.samples samples - 0.8.2 + 0.9.0 pom @@ -15,8 +15,13 @@ - customer-service - customer-service-client + domain-contracts + spring-boot-3/customer-service + spring-boot-3/customer-service-client + spring-boot-3/customer-service-consumer + spring-boot-4/customer-service + spring-boot-4/customer-service-client + spring-boot-4/customer-service-consumer \ No newline at end of file diff --git a/samples/spring-boot-3/.env b/samples/spring-boot-3/.env new file mode 100644 index 0000000..310817d --- /dev/null +++ b/samples/spring-boot-3/.env @@ -0,0 +1,4 @@ +APP_PORT=8084 +CONSUMER_APP_PORT=8085 +SPRING_PROFILES_ACTIVE=local +JAVA_OPTS= \ No newline at end of file diff --git a/samples/spring-boot-3/customer-service-client/README.md b/samples/spring-boot-3/customer-service-client/README.md new file mode 100644 index 0000000..208fc62 --- /dev/null +++ b/samples/spring-boot-3/customer-service-client/README.md @@ -0,0 +1,465 @@ +# customer-service-client + +> **Reference integration: generating and using a contract-aligned, generics-aware OpenAPI client in a Spring Boot application** + +[![Java 21](https://img.shields.io/badge/Java-21-red?logo=openjdk)](https://openjdk.org/projects/jdk/21/) +[![Spring Boot](https://img.shields.io/badge/Spring%20Boot-3.5.x-green?logo=springboot)](https://spring.io/projects/spring-boot) +[![OpenAPI Generator](https://img.shields.io/badge/OpenAPI%20Generator-7.x-blue?logo=openapiinitiative)](https://openapi-generator.tech/) +[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](../../LICENSE) + +--- + +## πŸ“‘ Table of Contents + +* πŸš€ [TL;DR (Start Here)](#-tldr-start-here) +* 🎯 [What this module is](#-what-this-module-is) +* ❗ [The Problem (Why this exists)](#-the-problem-why-this-exists) +* πŸ’‘ [The Approach](#-the-approach) +* 🧠 [How to Use in Your Own Project (Step-by-Step)](#-how-to-use-in-your-own-project-step-by-step) + * [Step 1 β€” Add parent (REQUIRED)](#step-1--add-parent-required) + * [Step 2 β€” Provide OpenAPI spec](#step-2--provide-openapi-spec) + * [Step 3 β€” Configure generator](#step-3--configure-generator) + * [Step 4 β€” Build](#step-4--build) + * [Step 5 β€” Integrate (IMPORTANT)](#step-5--integrate-important) +* 🧩 [Adapter Pattern (Recommended)](#-adapter-pattern-recommended) +* 🌐 [HTTP Client Setup (Production Ready)](#-http-client-setup-production-ready) +* βš–οΈ [Error Handling Model](#-error-handling-model) +* 🧬 [Supported Contract Scope](#-supported-contract-scope) +* πŸ—οΈ [What Actually Controls Generation](#-what-actually-controls-generation) +* πŸ”— [Related Modules](#-related-modules) +* πŸ§ͺ [Testing](#-testing) +* πŸ›‘οΈ [License](#-license) +* 🧾 [Final Note](#-final-note) + +--- + +## πŸš€ TL;DR (Start Here) + +If you just want a working, correct client: + +### 1. Inherit the parent + +```xml + + io.github.blueprintplatform + openapi-generics-java-codegen-parent + 0.9.0 + +``` + +### 2. Add your OpenAPI spec + +```text +src/main/resources/your-api-docs.yaml +``` + +### 3. Configure generator (minimal) + +```xml + + org.openapitools + openapi-generator-maven-plugin + + + + generate-client + generate-sources + + generate + + + + + ${project.basedir}/src/main/resources/your-api-docs.yaml + + your-library-choice + your.api.package + your.model.package + your.invoker.package + + + true + your-choice + false + + + + + + + +``` + + +### 4. Generate client + +```bash +mvn clean install +``` + +### 5. Use it via adapter + +The generated client should never be used directly from application code. + +Instead, introduce a thin adapter that: + +* defines a stable interface for your application +* delegates to generated APIs +* keeps contract types (`ServiceResponse`) intact + +Minimal usage looks like: + +```java +customerClient.getCustomer(id); +``` + +Under the hood, this is backed by an adapter layer: + +```java +public interface CustomerClientAdapter { + ServiceResponse getCustomer(Integer customerId); +} +``` + +```java +@Service +public class CustomerClientAdapterImpl implements CustomerClientAdapter { + + private final CustomerControllerApi api; + + public CustomerClientAdapterImpl(CustomerControllerApi api) { + this.api = api; + } + + @Override + public ServiceResponse getCustomer(Integer customerId) { + return api.getCustomer(customerId); + } +} +``` + +Key idea: + +> The adapter owns the integration boundary. Generated code stays behind it. + +Implication: + +* you can regenerate clients safely +* your application remains stable +* contract types flow through unchanged + +--- + +## 🎯 What this module is + +This module is a **reference consumer implementation**. + +It shows how to: + +* generate a client from a generics-aware OpenAPI spec +* preserve `ServiceResponse` semantics +* integrate the generated client safely into a Spring Boot application + +> This is not a reusable SDK. +> This is a **correct integration model**. + +--- + +## ❗ The Problem (Why this exists) + +Default OpenAPI client generation: + +* duplicates envelope models +* loses generic type semantics +* produces unstable outputs across builds +* leaks generated models into application code + +Result: + +* regeneration breaks +* type safety degrades +* contract drifts between server and client + +--- + +## πŸ’‘ The Approach + +OpenAPI is treated as transport β€” not as the contract. + +This module demonstrates a **contract-aligned generation model**: + +```text +OpenAPI (projection) + ↓ +Controlled build pipeline + ↓ +Thin wrapper models + ↓ +Adapter boundary + ↓ +Application usage +``` + +Key principles: + +* contract types are reused β€” not generated +* wrappers are structural β€” not behavioral +* generation is deterministic +* application code is isolated from generated code + +--- + +## 🧠 How to Use in Your Own Project (Step-by-Step) + +### Step 1 β€” Add parent (REQUIRED) + +This activates the generation system. + +```xml + + io.github.blueprintplatform + openapi-generics-java-codegen-parent + 0.9.0 + +``` + +--- + +### Step 2 β€” Provide OpenAPI spec + +```text +src/main/resources/your-api-docs.yaml +``` + +The spec must: + +* must be produced by a compatible server (vendor extensions are required) + +--- + +### Step 3 β€” Configure generator + +Minimal configuration only: + +```xml + + org.openapitools + openapi-generator-maven-plugin + + + + generate-client + generate-sources + + generate + + + + + ${project.basedir}/src/main/resources/your-api-docs.yaml + + your-library-choice + your.api.package + your.model.package + your.invoker.package + + + true + your-choice + false + + + + + + + +``` + +Do NOT configure: + +* generatorName +* templates +* importMappings + +These are controlled by the parent. + +--- + +### Step 4 β€” Build + +```bash +mvn clean install +``` + +Generated sources: + +```text +target/generated-sources/openapi/src/gen/java +``` + +--- + +### Step 5 β€” Integrate (IMPORTANT) + +Never expose generated APIs directly. + +```text +Application β†’ Adapter β†’ Generated API +``` + +--- + +## 🧩 Adapter Pattern (Recommended) + +### Why + +Generated code is replaceable. +Your application should not be. + +The adapter is the stability boundary of your system. + +--- + +### Example + +```java +public interface CustomerClientAdapter { + ServiceResponse getCustomer(Integer id); +} +``` + +```java +@Service +public class CustomerClientAdapterImpl implements CustomerClientAdapter { + + private final CustomerControllerApi api; + + public CustomerClientAdapterImpl(CustomerControllerApi api) { + this.api = api; + } + + @Override + public ServiceResponse getCustomer(Integer id) { + return api.getCustomer(id); + } +} +``` + +--- + +## 🌐 HTTP Client Setup (Production Ready) + +This module demonstrates: + +* Apache HttpClient 5 +* connection pooling +* timeouts +* explicit behavior (no hidden retries) + +You may simplify or replace this depending on your environment. + +--- + +## βš–οΈ Error Handling Model + +Errors follow a runtime protocol: + +```text +ProblemDetail (RFC 9457) +``` + +Behavior: + +* parsed into structured objects +* surfaced via `ApiProblemException` + +Fallbacks handled: + +* empty response +* invalid JSON +* unexpected formats + +--- + +## 🧬 Supported Contract Scope + +Supported: + +* `ServiceResponse` +* `ServiceResponse>` + +Out of scope: + +* arbitrary nested generics +* maps +* custom wrappers + +Reason: + +> determinism over flexibility + +--- + +## πŸ—οΈ What Actually Controls Generation + +Mental model: + +```text +Parent (orchestration) ++ Templates (structure) ++ Generator (rules) ++ Spec (input) +``` + +NOT just: + +```text +OpenAPI Generator plugin +``` + +--- + +## πŸ”— Related Modules + +* **[openapi-generics-contract](../../openapi-generics-contract/README.md)** + Canonical response contract. + +* **[customer-service](../customer-service/README.md)** + Producer reference. + +* **[openapi-generics-java-codegen-parent](../../openapi-generics-java-codegen-parent/README.md)** + Build-time orchestration. + +* **[openapi-generics-java-codegen](../../openapi-generics-java-codegen/README.md)** + Generator enforcement layer. + +--- + +## πŸ§ͺ Testing + +```bash +mvn verify +``` + +--- + +## πŸ›‘οΈ License + +MIT License + +--- + +## 🧾 Final Note + +This module is not about generating clients. + +It is about: + +> Generating **deterministic, contract-aligned clients** and integrating them safely. + +--- + +**Maintained by:** +**Barış SaylΔ±** +[GitHub](https://github.com/bsayli) Β· [Medium](https://medium.com/@baris.sayli) Β· [LinkedIn](https://www.linkedin.com/in/bsayli) diff --git a/samples/customer-service-client/pom.xml b/samples/spring-boot-3/customer-service-client/pom.xml similarity index 88% rename from samples/customer-service-client/pom.xml rename to samples/spring-boot-3/customer-service-client/pom.xml index e3a529c..d02e19d 100644 --- a/samples/customer-service-client/pom.xml +++ b/samples/spring-boot-3/customer-service-client/pom.xml @@ -7,13 +7,13 @@ io.github.blueprint-platform openapi-generics-java-codegen-parent - 0.8.2 + 0.9.0 io.github.blueprint-platform.samples - customer-service-client - customer-service-client + customer-service-client-sb3 + customer-service-client-sb3 Generated client (RestClient) using generics-aware OpenAPI templates jar https://github.com/blueprint-platform/openapi-generics @@ -30,7 +30,7 @@ https://github.com/blueprint-platform/openapi-generics scm:git:https://github.com/blueprint-platform/openapi-generics.git scm:git:ssh://git@github.com:bsayli/spring-boot-openapi-generics-clients.git - v0.8.2 + v0.9.0 @@ -47,6 +47,8 @@ 21 3.5.13 + + 0.9.0 7.21.0 3.1.1 @@ -61,10 +63,17 @@ 2.0.17 1.5.32 + false + + io.github.blueprint-platform.samples + customer-contract + ${customer-contract.version} + + org.springframework.boot spring-boot-starter-web @@ -168,7 +177,7 @@ - + java-generics-contract ${project.basedir}/src/main/resources/customer-api-docs.yaml restclient @@ -183,6 +192,17 @@ false + + openapiGenerics.responseContract.CustomerDto=io.github.blueprintplatform.contracts.customer.CustomerDto + + + true + false + + false + false + false + false diff --git a/samples/customer-service-client/src/main/java/io/github/blueprintplatform/samples/customerservice/client/adapter/CustomerClientAdapter.java b/samples/spring-boot-3/customer-service-client/src/main/java/io/github/blueprintplatform/samples/customerservice/client/adapter/CustomerClientAdapter.java similarity index 80% rename from samples/customer-service-client/src/main/java/io/github/blueprintplatform/samples/customerservice/client/adapter/CustomerClientAdapter.java rename to samples/spring-boot-3/customer-service-client/src/main/java/io/github/blueprintplatform/samples/customerservice/client/adapter/CustomerClientAdapter.java index cfc1a2f..0a58100 100644 --- a/samples/customer-service-client/src/main/java/io/github/blueprintplatform/samples/customerservice/client/adapter/CustomerClientAdapter.java +++ b/samples/spring-boot-3/customer-service-client/src/main/java/io/github/blueprintplatform/samples/customerservice/client/adapter/CustomerClientAdapter.java @@ -1,12 +1,11 @@ package io.github.blueprintplatform.samples.customerservice.client.adapter; +import io.github.blueprintplatform.contracts.customer.CustomerDto; import io.github.blueprintplatform.openapi.generics.contract.envelope.ServiceResponse; import io.github.blueprintplatform.openapi.generics.contract.paging.Page; import io.github.blueprintplatform.openapi.generics.contract.paging.SortDirection; import io.github.blueprintplatform.samples.customerservice.client.customer.CustomerSortField; import io.github.blueprintplatform.samples.customerservice.client.generated.dto.CustomerCreateRequest; -import io.github.blueprintplatform.samples.customerservice.client.generated.dto.CustomerDeleteResponse; -import io.github.blueprintplatform.samples.customerservice.client.generated.dto.CustomerDto; import io.github.blueprintplatform.samples.customerservice.client.generated.dto.CustomerUpdateRequest; public interface CustomerClientAdapter { @@ -27,5 +26,5 @@ ServiceResponse> getCustomers( ServiceResponse updateCustomer(Integer customerId, CustomerUpdateRequest request); - ServiceResponse deleteCustomer(Integer customerId); + ServiceResponse deleteCustomer(Integer customerId); } diff --git a/samples/customer-service-client/src/main/java/io/github/blueprintplatform/samples/customerservice/client/adapter/config/CustomerApiClientConfig.java b/samples/spring-boot-3/customer-service-client/src/main/java/io/github/blueprintplatform/samples/customerservice/client/adapter/config/CustomerApiClientConfig.java similarity index 100% rename from samples/customer-service-client/src/main/java/io/github/blueprintplatform/samples/customerservice/client/adapter/config/CustomerApiClientConfig.java rename to samples/spring-boot-3/customer-service-client/src/main/java/io/github/blueprintplatform/samples/customerservice/client/adapter/config/CustomerApiClientConfig.java diff --git a/samples/customer-service-client/src/main/java/io/github/blueprintplatform/samples/customerservice/client/adapter/impl/CustomerClientAdapterImpl.java b/samples/spring-boot-3/customer-service-client/src/main/java/io/github/blueprintplatform/samples/customerservice/client/adapter/impl/CustomerClientAdapterImpl.java similarity index 87% rename from samples/customer-service-client/src/main/java/io/github/blueprintplatform/samples/customerservice/client/adapter/impl/CustomerClientAdapterImpl.java rename to samples/spring-boot-3/customer-service-client/src/main/java/io/github/blueprintplatform/samples/customerservice/client/adapter/impl/CustomerClientAdapterImpl.java index ea0c4c5..70bc2e9 100644 --- a/samples/customer-service-client/src/main/java/io/github/blueprintplatform/samples/customerservice/client/adapter/impl/CustomerClientAdapterImpl.java +++ b/samples/spring-boot-3/customer-service-client/src/main/java/io/github/blueprintplatform/samples/customerservice/client/adapter/impl/CustomerClientAdapterImpl.java @@ -1,5 +1,6 @@ package io.github.blueprintplatform.samples.customerservice.client.adapter.impl; +import io.github.blueprintplatform.contracts.customer.CustomerDto; import io.github.blueprintplatform.openapi.generics.contract.envelope.ServiceResponse; import io.github.blueprintplatform.openapi.generics.contract.paging.Page; import io.github.blueprintplatform.openapi.generics.contract.paging.SortDirection; @@ -7,8 +8,6 @@ import io.github.blueprintplatform.samples.customerservice.client.customer.CustomerSortField; import io.github.blueprintplatform.samples.customerservice.client.generated.api.CustomerControllerApi; import io.github.blueprintplatform.samples.customerservice.client.generated.dto.CustomerCreateRequest; -import io.github.blueprintplatform.samples.customerservice.client.generated.dto.CustomerDeleteResponse; -import io.github.blueprintplatform.samples.customerservice.client.generated.dto.CustomerDto; import io.github.blueprintplatform.samples.customerservice.client.generated.dto.CustomerUpdateRequest; import org.springframework.stereotype.Service; @@ -61,7 +60,8 @@ public ServiceResponse updateCustomer( } @Override - public ServiceResponse deleteCustomer(Integer customerId) { - return api.deleteCustomer(customerId); + public ServiceResponse deleteCustomer(Integer customerId) { + api.deleteCustomer(customerId); + return ServiceResponse.of(null); } } diff --git a/samples/customer-service-client/src/main/java/io/github/blueprintplatform/samples/customerservice/client/adapter/support/ProblemDetailFallbacks.java b/samples/spring-boot-3/customer-service-client/src/main/java/io/github/blueprintplatform/samples/customerservice/client/adapter/support/ProblemDetailFallbacks.java similarity index 100% rename from samples/customer-service-client/src/main/java/io/github/blueprintplatform/samples/customerservice/client/adapter/support/ProblemDetailFallbacks.java rename to samples/spring-boot-3/customer-service-client/src/main/java/io/github/blueprintplatform/samples/customerservice/client/adapter/support/ProblemDetailFallbacks.java diff --git a/samples/customer-service-client/src/main/java/io/github/blueprintplatform/samples/customerservice/client/adapter/support/ProblemDetailSupport.java b/samples/spring-boot-3/customer-service-client/src/main/java/io/github/blueprintplatform/samples/customerservice/client/adapter/support/ProblemDetailSupport.java similarity index 100% rename from samples/customer-service-client/src/main/java/io/github/blueprintplatform/samples/customerservice/client/adapter/support/ProblemDetailSupport.java rename to samples/spring-boot-3/customer-service-client/src/main/java/io/github/blueprintplatform/samples/customerservice/client/adapter/support/ProblemDetailSupport.java diff --git a/samples/customer-service-client/src/main/java/io/github/blueprintplatform/samples/customerservice/client/adapter/support/ResponseSnapshot.java b/samples/spring-boot-3/customer-service-client/src/main/java/io/github/blueprintplatform/samples/customerservice/client/adapter/support/ResponseSnapshot.java similarity index 100% rename from samples/customer-service-client/src/main/java/io/github/blueprintplatform/samples/customerservice/client/adapter/support/ResponseSnapshot.java rename to samples/spring-boot-3/customer-service-client/src/main/java/io/github/blueprintplatform/samples/customerservice/client/adapter/support/ResponseSnapshot.java diff --git a/samples/customer-service-client/src/main/java/io/github/blueprintplatform/samples/customerservice/client/common/problem/ApiProblemException.java b/samples/spring-boot-3/customer-service-client/src/main/java/io/github/blueprintplatform/samples/customerservice/client/common/problem/ApiProblemException.java similarity index 100% rename from samples/customer-service-client/src/main/java/io/github/blueprintplatform/samples/customerservice/client/common/problem/ApiProblemException.java rename to samples/spring-boot-3/customer-service-client/src/main/java/io/github/blueprintplatform/samples/customerservice/client/common/problem/ApiProblemException.java diff --git a/samples/customer-service-client/src/main/java/io/github/blueprintplatform/samples/customerservice/client/customer/CustomerSortField.java b/samples/spring-boot-3/customer-service-client/src/main/java/io/github/blueprintplatform/samples/customerservice/client/customer/CustomerSortField.java similarity index 60% rename from samples/customer-service-client/src/main/java/io/github/blueprintplatform/samples/customerservice/client/customer/CustomerSortField.java rename to samples/spring-boot-3/customer-service-client/src/main/java/io/github/blueprintplatform/samples/customerservice/client/customer/CustomerSortField.java index 80fdb34..2e5628c 100644 --- a/samples/customer-service-client/src/main/java/io/github/blueprintplatform/samples/customerservice/client/customer/CustomerSortField.java +++ b/samples/spring-boot-3/customer-service-client/src/main/java/io/github/blueprintplatform/samples/customerservice/client/customer/CustomerSortField.java @@ -11,6 +11,14 @@ public enum CustomerSortField { this.value = value; } + public static CustomerSortField from(String s) { + if (s == null) return CUSTOMER_ID; + for (var f : values()) { + if (f.value.equalsIgnoreCase(s)) return f; + } + throw new IllegalArgumentException("Unsupported sort field: " + s); + } + public String value() { return value; } diff --git a/samples/customer-service-client/src/main/resources/customer-api-docs.yaml b/samples/spring-boot-3/customer-service-client/src/main/resources/customer-api-docs.yaml similarity index 88% rename from samples/customer-service-client/src/main/resources/customer-api-docs.yaml rename to samples/spring-boot-3/customer-service-client/src/main/resources/customer-api-docs.yaml index b513bae..2b541b7 100644 --- a/samples/customer-service-client/src/main/resources/customer-api-docs.yaml +++ b/samples/spring-boot-3/customer-service-client/src/main/resources/customer-api-docs.yaml @@ -2,12 +2,12 @@ openapi: 3.1.0 info: title: Customer Service API description: Customer Service API with type-safe generic responses using OpenAPI - version: 0.8.2 + version: 0.9.0 servers: - url: http://localhost:8084/customer-service description: Local service URL paths: - /v1/customers/{customerId}: + /customers/{customerId}: get: tags: - customer-controller @@ -65,13 +65,9 @@ paths: format: int32 minimum: 1 responses: - "200": - description: OK - content: - application/json: - schema: - $ref: "#/components/schemas/ServiceResponseCustomerDeleteResponse" - /v1/customers: + "204": + description: Customer deleted + /customers: get: tags: - customer-controller @@ -119,10 +115,10 @@ paths: required: false schema: type: string - default: asc + default: ASC enum: - - asc - - desc + - ASC + - DESC responses: "200": description: OK @@ -201,8 +197,8 @@ components: direction: type: string enum: - - asc - - desc + - ASC + - DESC x-ignore-model: true CustomerCreateRequest: type: object @@ -253,21 +249,6 @@ components: x-api-wrapper-datatype: PageCustomerDto x-data-container: Page x-data-item: CustomerDto - CustomerDeleteResponse: - type: object - properties: - customerId: - type: integer - format: int32 - ServiceResponseCustomerDeleteResponse: - allOf: - - $ref: "#/components/schemas/ServiceResponse" - - type: object - properties: - data: - $ref: "#/components/schemas/CustomerDeleteResponse" - x-api-wrapper: true - x-api-wrapper-datatype: CustomerDeleteResponse ServiceResponse: type: object properties: diff --git a/samples/customer-service-client/src/test/java/io/github/blueprintplatform/samples/customerservice/client/adapter/CustomerClientErrorIT.java b/samples/spring-boot-3/customer-service-client/src/test/java/io/github/blueprintplatform/samples/customerservice/client/adapter/CustomerClientErrorIT.java similarity index 93% rename from samples/customer-service-client/src/test/java/io/github/blueprintplatform/samples/customerservice/client/adapter/CustomerClientErrorIT.java rename to samples/spring-boot-3/customer-service-client/src/test/java/io/github/blueprintplatform/samples/customerservice/client/adapter/CustomerClientErrorIT.java index 53b66eb..3089aae 100644 --- a/samples/customer-service-client/src/test/java/io/github/blueprintplatform/samples/customerservice/client/adapter/CustomerClientErrorIT.java +++ b/samples/spring-boot-3/customer-service-client/src/test/java/io/github/blueprintplatform/samples/customerservice/client/adapter/CustomerClientErrorIT.java @@ -43,8 +43,7 @@ static void stopServer() throws Exception { } @Test - @DisplayName( - "GET /v1/customers/{id} -> 404 Problem => throws ApiProblemException with parsed body") + @DisplayName("GET /customers/{id} -> 404 Problem => throws ApiProblemException with parsed body") void getCustomer_404_problem() { var problem = """ @@ -83,7 +82,7 @@ void getCustomer_404_problem() { } @Test - @DisplayName("POST /v1/customers -> 400 Problem (validation) => throws ApiProblemException") + @DisplayName("POST /customers -> 400 Problem (validation) => throws ApiProblemException") void createCustomer_400_problem() { var problem = """ @@ -127,7 +126,7 @@ void createCustomer_400_problem() { @Test @DisplayName( - "DELETE /v1/customers/{id} -> 500 (no body) => throws ApiProblemException with fallback ProblemDetail") + "DELETE /customers/{id} -> 500 (no body) => throws ApiProblemException with fallback ProblemDetail") void deleteCustomer_500_no_body() { server.enqueue( new MockResponse() @@ -152,7 +151,7 @@ void deleteCustomer_500_no_body() { } @Test - @DisplayName("GET /v1/customers/{id} -> 502 text/plain => fallback non-json problem") + @DisplayName("GET /customers/{id} -> 502 text/plain => fallback non-json problem") void getCustomer_502_nonJsonFallback() { server.enqueue( new MockResponse() @@ -174,7 +173,7 @@ void getCustomer_502_nonJsonFallback() { } @Test - @DisplayName("GET /v1/customers/{id} -> 500 invalid problem json => fallback unparsable") + @DisplayName("GET /customers/{id} -> 500 invalid problem json => fallback unparsable") void getCustomer_500_unparsableProblemFallback() { server.enqueue( new MockResponse() diff --git a/samples/spring-boot-3/customer-service-client/src/test/java/io/github/blueprintplatform/samples/customerservice/client/adapter/CustomerClientIT.java b/samples/spring-boot-3/customer-service-client/src/test/java/io/github/blueprintplatform/samples/customerservice/client/adapter/CustomerClientIT.java new file mode 100644 index 0000000..01cc99a --- /dev/null +++ b/samples/spring-boot-3/customer-service-client/src/test/java/io/github/blueprintplatform/samples/customerservice/client/adapter/CustomerClientIT.java @@ -0,0 +1,203 @@ +package io.github.blueprintplatform.samples.customerservice.client.adapter; + +import static org.junit.jupiter.api.Assertions.*; + +import com.fasterxml.jackson.databind.ObjectMapper; +import io.github.blueprintplatform.samples.customerservice.client.adapter.config.CustomerApiClientConfig; +import io.github.blueprintplatform.samples.customerservice.client.generated.api.CustomerControllerApi; +import io.github.blueprintplatform.samples.customerservice.client.generated.dto.CustomerCreateRequest; +import io.github.blueprintplatform.samples.customerservice.client.generated.dto.CustomerUpdateRequest; +import okhttp3.mockwebserver.MockResponse; +import okhttp3.mockwebserver.MockWebServer; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.http.converter.json.Jackson2ObjectMapperBuilder; +import org.springframework.test.context.junit.jupiter.SpringJUnitConfig; +import org.springframework.web.client.RestClient; + +@SpringJUnitConfig(classes = {CustomerApiClientConfig.class, CustomerClientIT.TestBeans.class}) +class CustomerClientIT { + + static MockWebServer server; + + @Autowired private CustomerControllerApi api; + + @BeforeAll + static void startServer() throws Exception { + server = new MockWebServer(); + server.start(); + System.setProperty("customer.api.base-url", server.url("/customer-service").toString()); + } + + @AfterAll + static void stopServer() throws Exception { + server.shutdown(); + System.clearProperty("customer.api.base-url"); + } + + @Test + @DisplayName("POST /customers -> 201 Created + maps {data, meta}") + void createCustomer_shouldReturn201_andMapBody() { + var body = + """ + { + "data": { "customerId": 1, "name": "Jane Doe", "email": "jane@example.com" }, + "meta": { "serverTime": "2025-01-01T12:34:56Z", "sort": [] } + } + """; + + server.enqueue( + new MockResponse() + .setResponseCode(201) + .addHeader("Content-Type", "application/json") + .setBody(body)); + + var req = new CustomerCreateRequest().name("Jane Doe").email("jane@example.com"); + var resp = api.createCustomer(req); + + assertNotNull(resp); + assertNotNull(resp.getData()); + assertEquals(1, resp.getData().customerId()); + assertEquals("Jane Doe", resp.getData().name()); + assertEquals("jane@example.com", resp.getData().email()); + + assertNotNull(resp.getMeta()); + assertNotNull(resp.getMeta().serverTime()); + } + + @Test + @DisplayName("GET /customers/{id} -> 200 OK + maps {data, meta}") + void getCustomer_shouldReturn200_andMapBody() { + var body = + """ + { + "data": { "customerId": 1, "name": "Jane Doe", "email": "jane@example.com" }, + "meta": { "requestId": "req-2", "serverTime": "2025-01-02T09:00:00Z", "sort": [] } + } + """; + + server.enqueue( + new MockResponse() + .setResponseCode(200) + .addHeader("Content-Type", "application/json") + .setBody(body)); + + var resp = api.getCustomer(1); + + assertNotNull(resp); + assertNotNull(resp.getData()); + assertEquals(1, resp.getData().customerId()); + assertEquals("Jane Doe", resp.getData().name()); + + assertNotNull(resp.getMeta()); + assertNotNull(resp.getMeta().serverTime()); + } + + @Test + @DisplayName("GET /customers -> 200 OK + maps Page in data and meta") + void getCustomers_shouldReturn200_andMapPage() { + var body = + """ + { + "data": { + "content": [ + { "customerId": 1, "name": "Jane Doe", "email": "jane@example.com" }, + { "customerId": 2, "name": "John Smith", "email": "john.smith@example.com" } + ], + "page": 0, + "size": 5, + "totalElements": 2, + "totalPages": 1, + "hasNext": false, + "hasPrev": false + }, + "meta": { "serverTime": "2025-01-03T10:00:00Z", "sort": [] } + } + """; + + server.enqueue( + new MockResponse() + .setResponseCode(200) + .addHeader("Content-Type", "application/json") + .setBody(body)); + + // generated signature accepts query params (sortBy/direction are strings at wire-level) + var resp = api.getCustomers(null, null, 0, 5, "customerId", "asc"); + + assertNotNull(resp); + assertNotNull(resp.getData()); + + var page = resp.getData(); + assertEquals(0, page.page()); + assertEquals(5, page.size()); + assertEquals(2L, page.totalElements()); + assertEquals(1, page.totalPages()); + assertFalse(page.hasNext()); + assertFalse(page.hasPrev()); + assertNotNull(page.content()); + assertEquals(2, page.content().size()); + assertEquals(1, page.content().getFirst().customerId()); + + assertNotNull(resp.getMeta()); + assertNotNull(resp.getMeta().serverTime()); + } + + @Test + @DisplayName("PUT /customers/{id} -> 200 OK + maps {data, meta}") + void updateCustomer_shouldReturn200_andMapBody() { + var body = + """ + { + "data": { "customerId": 1, "name": "Jane Updated", "email": "jane.updated@example.com" }, + "meta": { "serverTime": "2025-01-04T12:00:00Z", "sort": [] } + } + """; + + server.enqueue( + new MockResponse() + .setResponseCode(200) + .addHeader("Content-Type", "application/json") + .setBody(body)); + + var req = new CustomerUpdateRequest().name("Jane Updated").email("jane.updated@example.com"); + var resp = api.updateCustomer(1, req); + + assertNotNull(resp); + assertNotNull(resp.getData()); + assertEquals(1, resp.getData().customerId()); + assertEquals("Jane Updated", resp.getData().name()); + assertEquals("jane.updated@example.com", resp.getData().email()); + + assertNotNull(resp.getMeta()); + assertNotNull(resp.getMeta().serverTime()); + } + + @Test + @DisplayName("DELETE /customers/{id} -> 200 OK (no body expected)") + void deleteCustomer_shouldReturn200() { + + server.enqueue( + new MockResponse().setResponseCode(200).addHeader("Content-Type", "application/json")); + + assertDoesNotThrow(() -> api.deleteCustomer(1)); + } + + @Configuration + static class TestBeans { + + @Bean + RestClient.Builder restClientBuilder() { + return RestClient.builder(); + } + + @Bean + ObjectMapper objectMapper() { + return Jackson2ObjectMapperBuilder.json().build(); + } + } +} diff --git a/samples/customer-service-client/src/test/java/io/github/blueprintplatform/samples/customerservice/client/adapter/config/CustomerApiClientConfigStatusHandlerTest.java b/samples/spring-boot-3/customer-service-client/src/test/java/io/github/blueprintplatform/samples/customerservice/client/adapter/config/CustomerApiClientConfigStatusHandlerTest.java similarity index 100% rename from samples/customer-service-client/src/test/java/io/github/blueprintplatform/samples/customerservice/client/adapter/config/CustomerApiClientConfigStatusHandlerTest.java rename to samples/spring-boot-3/customer-service-client/src/test/java/io/github/blueprintplatform/samples/customerservice/client/adapter/config/CustomerApiClientConfigStatusHandlerTest.java diff --git a/samples/customer-service-client/src/test/java/io/github/blueprintplatform/samples/customerservice/client/adapter/impl/CustomerClientAdapterImplTest.java b/samples/spring-boot-3/customer-service-client/src/test/java/io/github/blueprintplatform/samples/customerservice/client/adapter/impl/CustomerClientAdapterImplTest.java similarity index 69% rename from samples/customer-service-client/src/test/java/io/github/blueprintplatform/samples/customerservice/client/adapter/impl/CustomerClientAdapterImplTest.java rename to samples/spring-boot-3/customer-service-client/src/test/java/io/github/blueprintplatform/samples/customerservice/client/adapter/impl/CustomerClientAdapterImplTest.java index 28bd742..ef26824 100644 --- a/samples/customer-service-client/src/test/java/io/github/blueprintplatform/samples/customerservice/client/adapter/impl/CustomerClientAdapterImplTest.java +++ b/samples/spring-boot-3/customer-service-client/src/test/java/io/github/blueprintplatform/samples/customerservice/client/adapter/impl/CustomerClientAdapterImplTest.java @@ -2,8 +2,10 @@ import static org.junit.jupiter.api.Assertions.*; import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.doNothing; import static org.mockito.Mockito.when; +import io.github.blueprintplatform.contracts.customer.CustomerDto; import io.github.blueprintplatform.openapi.generics.contract.envelope.Meta; import io.github.blueprintplatform.openapi.generics.contract.envelope.ServiceResponse; import io.github.blueprintplatform.openapi.generics.contract.paging.Page; @@ -32,12 +34,12 @@ class CustomerClientAdapterImplTest { @InjectMocks CustomerClientAdapterImpl adapter; @Test - @DisplayName( - "createCustomer -> delegates to API and returns ServiceClientResponse (including meta)") + @DisplayName("createCustomer -> delegates to API and returns ServiceResponse") void createCustomer_delegates_and_returns_data_meta() { + var req = new CustomerCreateRequest().name("Jane Doe").email("jane@example.com"); - var dto = new CustomerDto().customerId(1).name("Jane Doe").email("jane@example.com"); + var dto = new CustomerDto(1, "Jane Doe", "jane@example.com"); var serverOdt = OffsetDateTime.parse("2025-01-01T12:34:56Z"); var meta = new Meta(serverOdt.toInstant(), List.of()); @@ -52,20 +54,22 @@ void createCustomer_delegates_and_returns_data_meta() { assertNotNull(res); assertNotNull(res.getData()); - assertEquals(1, res.getData().getCustomerId()); - assertEquals("Jane Doe", res.getData().getName()); - assertEquals("jane@example.com", res.getData().getEmail()); + assertEquals(1, res.getData().customerId()); + assertEquals("Jane Doe", res.getData().name()); + assertEquals("jane@example.com", res.getData().email()); assertNotNull(res.getMeta()); assertEquals(serverOdt.toInstant(), res.getMeta().serverTime()); } @Test - @DisplayName("getCustomer -> returns a single CustomerDto (data + meta)") + @DisplayName("getCustomer -> returns a single CustomerDto") void getCustomer_delegates_and_returnsDto() { - var dto = new CustomerDto().customerId(42).name("John Smith").email("john.smith@example.com"); + + var dto = new CustomerDto(42, "John Smith", "john.smith@example.com"); var serverOdt = OffsetDateTime.parse("2025-02-01T10:00:00Z"); + var wrapper = new ServiceResponseCustomerDto(); wrapper.setData(dto); wrapper.setMeta(new Meta(serverOdt.toInstant(), List.of())); @@ -76,23 +80,25 @@ void getCustomer_delegates_and_returnsDto() { assertNotNull(res); assertNotNull(res.getData()); - assertEquals(42, res.getData().getCustomerId()); - assertEquals("John Smith", res.getData().getName()); - assertEquals("john.smith@example.com", res.getData().getEmail()); + assertEquals(42, res.getData().customerId()); + assertEquals("John Smith", res.getData().name()); + assertEquals("john.smith@example.com", res.getData().email()); assertNotNull(res.getMeta()); assertEquals(serverOdt.toInstant(), res.getMeta().serverTime()); } @Test - @DisplayName("getCustomers -> returns Page (data + meta)") + @DisplayName("getCustomers -> returns Page") void getCustomers_delegates_and_returnsPage() { - var d1 = new CustomerDto().customerId(1).name("A").email("a@example.com"); - var d2 = new CustomerDto().customerId(2).name("B").email("b@example.com"); + + var d1 = new CustomerDto(1, "A", "a@example.com"); + var d2 = new CustomerDto(2, "B", "b@example.com"); var page = new Page<>(List.of(d1, d2), 0, 5, 2L, 1, false, false); var serverOdt = OffsetDateTime.parse("2025-03-01T09:00:00Z"); + var wrapper = new ServiceResponsePageCustomerDto(); wrapper.setData(page); wrapper.setMeta(new Meta(serverOdt.toInstant(), List.of())); @@ -104,26 +110,29 @@ void getCustomers_delegates_and_returnsPage() { assertNotNull(res); assertNotNull(res.getData()); + assertEquals(0, res.getData().page()); assertEquals(5, res.getData().size()); assertEquals(2L, res.getData().totalElements()); + assertNotNull(res.getData().content()); assertEquals(2, res.getData().content().size()); - assertEquals(1, res.getData().content().getFirst().getCustomerId()); + assertEquals(1, res.getData().content().getFirst().customerId()); assertNotNull(res.getMeta()); assertEquals(serverOdt.toInstant(), res.getMeta().serverTime()); } @Test - @DisplayName("updateCustomer -> returns updated CustomerDto (data + meta)") + @DisplayName("updateCustomer -> returns updated CustomerDto") void updateCustomer_delegates_and_returnsUpdated() { + var req = new CustomerUpdateRequest().name("Jane Updated").email("jane.updated@example.com"); - var dto = - new CustomerDto().customerId(1).name("Jane Updated").email("jane.updated@example.com"); + var dto = new CustomerDto(1, "Jane Updated", "jane.updated@example.com"); var serverOdt = OffsetDateTime.parse("2025-04-02T12:00:00Z"); + var wrapper = new ServiceResponseCustomerDto(); wrapper.setData(dto); wrapper.setMeta(new Meta(serverOdt.toInstant(), List.of())); @@ -134,33 +143,24 @@ void updateCustomer_delegates_and_returnsUpdated() { assertNotNull(res); assertNotNull(res.getData()); - assertEquals("Jane Updated", res.getData().getName()); - assertEquals("jane.updated@example.com", res.getData().getEmail()); + assertEquals("Jane Updated", res.getData().name()); + assertEquals("jane.updated@example.com", res.getData().email()); assertNotNull(res.getMeta()); assertEquals(serverOdt.toInstant(), res.getMeta().serverTime()); } @Test - @DisplayName("deleteCustomer -> returns CustomerDeleteResponse (data + meta)") - void deleteCustomer_delegates_and_returnsDeletePayload() { - var payload = new CustomerDeleteResponse().customerId(7); - - var serverOdt = OffsetDateTime.parse("2025-05-03T08:00:00Z"); - var wrapper = new ServiceResponseCustomerDeleteResponse(); - wrapper.setData(payload); - wrapper.setMeta(new Meta(serverOdt.toInstant(), List.of())); + @DisplayName("deleteCustomer -> returns empty ServiceResponse") + void deleteCustomer_delegates_and_wrapsVoidResponse() { - when(api.deleteCustomer(any())).thenReturn(wrapper); + doNothing().when(api).deleteCustomer(any()); - ServiceResponse res = adapter.deleteCustomer(7); + ServiceResponse res = adapter.deleteCustomer(7); assertNotNull(res); - assertNotNull(res.getData()); - assertEquals(7, res.getData().getCustomerId()); - + assertNull(res.getData()); assertNotNull(res.getMeta()); - assertEquals(serverOdt.toInstant(), res.getMeta().serverTime()); } @Test diff --git a/samples/customer-service/.dockerignore b/samples/spring-boot-3/customer-service-consumer/.dockerignore similarity index 100% rename from samples/customer-service/.dockerignore rename to samples/spring-boot-3/customer-service-consumer/.dockerignore diff --git a/samples/spring-boot-3/customer-service-consumer/Dockerfile b/samples/spring-boot-3/customer-service-consumer/Dockerfile new file mode 100644 index 0000000..a737e47 --- /dev/null +++ b/samples/spring-boot-3/customer-service-consumer/Dockerfile @@ -0,0 +1,35 @@ +FROM maven:3.9.12-eclipse-temurin-21-noble AS builder +WORKDIR /app + +COPY pom.xml pom.xml +COPY src src + +RUN --mount=type=cache,target=/root/.m2,sharing=locked \ + mvn -q -T 2C -DskipTests --no-transfer-progress clean package && \ + JAR="$(ls -1 target/customer-service-consumer-*.jar | head -n 1)" && \ + java -Djarmode=layertools -jar "$JAR" extract + +FROM eclipse-temurin:21.0.10_7-jre-noble +WORKDIR /app + +RUN useradd -r -u 10001 -g root -s /bin/false -d /app appuser && \ + mkdir -p /app/logs && \ + chown -R 10001:0 /app + +LABEL org.opencontainers.image.title="customer-service-consumer-sb3" \ + org.opencontainers.image.description="Customer Service Consumer Spring Boot 3" \ + org.opencontainers.image.source="https://github.com/blueprint-platform/openapi-generics" \ + org.opencontainers.image.licenses="MIT" + +COPY --from=builder --chown=10001:0 /app/dependencies/ ./ +COPY --from=builder --chown=10001:0 /app/snapshot-dependencies/ ./ +COPY --from=builder --chown=10001:0 /app/spring-boot-loader/ ./ +COPY --from=builder --chown=10001:0 /app/application/ ./ + +USER 10001 + +ENV SPRING_PROFILES_ACTIVE=default \ + JAVA_TOOL_OPTIONS="-XX:MaxRAMPercentage=75 -XX:+UseG1GC -Dfile.encoding=UTF-8" + +EXPOSE 8085 +ENTRYPOINT ["java","org.springframework.boot.loader.launch.JarLauncher"] \ No newline at end of file diff --git a/samples/spring-boot-3/customer-service-consumer/README.md b/samples/spring-boot-3/customer-service-consumer/README.md new file mode 100644 index 0000000..cae43cd --- /dev/null +++ b/samples/spring-boot-3/customer-service-consumer/README.md @@ -0,0 +1,220 @@ +# customer-service-consumer + +> **Reference consumer: how a Spring Boot service consumes a contract-aligned, generics-aware client and exposes it safely** + +--- + +## πŸ“‘ Table of Contents + +* 🎯 [What this module shows](#-what-this-module-shows) +* 🧠 [Key idea](#-key-idea) +* πŸ—οΈ [Structure](#-structure) +* πŸ”Œ [Integration boundary (critical)](#-integration-boundary-critical) +* 🧩 [Adapter usage](#-adapter-usage) +* βš–οΈ [Error handling](#-error-handling) +* πŸ”„ [Contract preservation](#-contract-preservation) +* βš™οΈ [Configuration (important parts)](#-configuration-important-parts) +* πŸ§ͺ [Verify quickly](#-verify-quickly) +* πŸ”— [Related modules](#-related-modules) +* 🧾 [Summary](#-summary) +* πŸ›‘ [License](#-license) + +--- + +## 🎯 What this module shows + +This module demonstrates the **final stage of the pipeline**: + +```text +Producer β†’ OpenAPI β†’ Generated Client β†’ Adapter β†’ Consumer Service +``` + +It answers one practical question: + +> How do you actually use the generated client inside a real service? + +--- + +## 🧠 Key idea + +The consumer does NOT: + +* regenerate models +* reinterpret responses +* unwrap contract types + +It simply: + +```text +Delegates β†’ preserves contract β†’ exposes it +``` + +--- + +## πŸ—οΈ Structure + +```text +Controller β†’ Service β†’ Client β†’ Adapter β†’ Generated API +``` + +### Flow + +```text +HTTP Request + ↓ +Controller + ↓ +Service + ↓ +CustomerServiceClient (boundary) + ↓ +CustomerClientAdapter (generated client wrapper) + ↓ +Generated OpenAPI client +``` + +--- + +## πŸ”Œ Integration boundary (critical) + +The **real boundary** is here: + +```java +CustomerServiceClient +``` + +This layer: + +* isolates generated code +* handles exceptions +* maps requests if needed +* preserves `ServiceResponse` + +--- + +## 🧩 Adapter usage + +Generated client is never used directly. + +Instead: + +```java +adapter.getCustomer(customerId) +``` + +This ensures: + +* regeneration safety +* stable application code +* no coupling to generator internals + +--- + +## βš–οΈ Error handling + +Errors follow: + +```text +ProblemDetail (RFC 9457) +``` + +Handled via: + +```java +ApiProblemException +``` + +Mapped into domain exceptions using: + +```java +CustomerConsumerExceptionMapper +``` + +--- + +## πŸ”„ Contract preservation + +End-to-end shape remains: + +```java +ServiceResponse +ServiceResponse> +``` + +No: + +* DTO duplication +* envelope rewriting +* mapping layers for correctness + +--- + +## βš™οΈ Configuration (important parts) + +### Upstream API + +```yaml +customer: + api: + base-url: http://localhost:8084/customer-service +``` + +### HTTP client behavior + +* connection pooling +* timeouts +* explicit configuration + +--- + +## πŸ§ͺ Verify quickly + +Run consumer: + +```bash +mvn spring-boot:run +``` + +Call: + +```bash +curl http://localhost:8085/customer-service-consumer/customers/1 +``` + +Expected: + +```json +{ + "data": { ... }, + "meta": { ... } +} +``` + +--- + +## πŸ”— Related modules + +* **[customer-service](../customer-service/README.md)** + Producer reference. + +* **[customer-service-client](../customer-service-client/README.md)** + Consumer example showing how the generated client is used. + +* **[openapi-generics-java-codegen-parent](../../openapi-generics-java-codegen-parent/README.md)** + Build-time orchestration. + +--- + +## 🧾 Summary + +```text +Generated client is not the boundary +Adapter is the boundary +Contract flows through unchanged +``` + +--- + +## πŸ›‘ License + +MIT License diff --git a/samples/spring-boot-3/customer-service-consumer/pom.xml b/samples/spring-boot-3/customer-service-consumer/pom.xml new file mode 100644 index 0000000..ba0d3b2 --- /dev/null +++ b/samples/spring-boot-3/customer-service-consumer/pom.xml @@ -0,0 +1,174 @@ + + + 4.0.0 + + + org.springframework.boot + spring-boot-starter-parent + 3.5.13 + + + + io.github.blueprint-platform.samples + customer-service-consumer-sb3 + 0.9.0 + customer-service-consumer-sb3 + + Sample consumer service demonstrating end-to-end contract-aligned API consumption + using customer-service-client with type-safe generic responses. + + jar + https://github.com/blueprint-platform/openapi-generics + + + + MIT License + https://opensource.org/licenses/MIT + repo + + + + + https://github.com/blueprint-platform/openapi-generics + scm:git:https://github.com/blueprint-platform/openapi-generics.git + scm:git:ssh://git@github.com:blueprint-platform/openapi-generics.git + v0.9.0 + + + + + bsayli + Baris Sayli + https://github.com/bsayli + + + + + 21 + UTF-8 + UTF-8 + 0.9.0 + 0.9.0 + 0.9.0 + 2.8.16 + 0.8.14 + + + + + + + io.github.blueprint-platform + openapi-generics-server-starter + ${openapi-generics-server-starter.version} + + + + io.github.blueprint-platform.samples + customer-service-client-sb3 + ${customer-service-client.version} + + + + org.springdoc + springdoc-openapi-starter-webmvc-ui + ${springdoc-openapi-starter.version} + + + + org.springframework.boot + spring-boot-starter-web + + + + org.springframework.boot + spring-boot-starter-validation + + + + org.springframework.boot + spring-boot-configuration-processor + true + + + + org.springframework.boot + spring-boot-starter-test + test + + + + + + + + + org.springframework.boot + spring-boot-maven-plugin + + io.github.blueprintplatform.samples.customerservice.consumer.io.github.blueprintplatform.samples.customerservice.consumer.CustomerServiceConsumerApplication + + true + + + + + + org.apache.maven.plugins + maven-dependency-plugin + + + + properties + + + + + + + org.apache.maven.plugins + maven-compiler-plugin + + ${java.version} + ${project.build.sourceEncoding} + + + + + org.apache.maven.plugins + maven-surefire-plugin + + @{argLine} -javaagent:${org.mockito:mockito-core:jar} + + **/*Test.java + + + + + + org.jacoco + jacoco-maven-plugin + ${jacoco-maven-plugin.version} + + + prepare-agent + + prepare-agent + + + + report + verify + + report + + + + + + + + + \ No newline at end of file diff --git a/samples/spring-boot-3/customer-service-consumer/src/main/java/io/github/blueprintplatform/samples/customerservice/consumer/CustomerServiceConsumerApplication.java b/samples/spring-boot-3/customer-service-consumer/src/main/java/io/github/blueprintplatform/samples/customerservice/consumer/CustomerServiceConsumerApplication.java new file mode 100644 index 0000000..b8ae3dd --- /dev/null +++ b/samples/spring-boot-3/customer-service-consumer/src/main/java/io/github/blueprintplatform/samples/customerservice/consumer/CustomerServiceConsumerApplication.java @@ -0,0 +1,15 @@ +package io.github.blueprintplatform.samples.customerservice.consumer; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; + +@SpringBootApplication(scanBasePackages = { + "io.github.blueprintplatform.samples.customerservice.consumer", + "io.github.blueprintplatform.samples.customerservice.client" +}) +public class CustomerServiceConsumerApplication { + + public static void main(String[] args) { + SpringApplication.run(CustomerServiceConsumerApplication.class, args); + } +} \ No newline at end of file diff --git a/samples/spring-boot-3/customer-service-consumer/src/main/java/io/github/blueprintplatform/samples/customerservice/consumer/api/controller/CustomerConsumerController.java b/samples/spring-boot-3/customer-service-consumer/src/main/java/io/github/blueprintplatform/samples/customerservice/consumer/api/controller/CustomerConsumerController.java new file mode 100644 index 0000000..30c5145 --- /dev/null +++ b/samples/spring-boot-3/customer-service-consumer/src/main/java/io/github/blueprintplatform/samples/customerservice/consumer/api/controller/CustomerConsumerController.java @@ -0,0 +1,74 @@ +package io.github.blueprintplatform.samples.customerservice.consumer.api.controller; + +import io.github.blueprintplatform.contracts.customer.CustomerDto; +import io.github.blueprintplatform.openapi.generics.contract.envelope.ServiceResponse; +import io.github.blueprintplatform.openapi.generics.contract.paging.Page; +import io.github.blueprintplatform.openapi.generics.contract.paging.SortDirection; +import io.github.blueprintplatform.samples.customerservice.client.customer.CustomerSortField; +import io.github.blueprintplatform.samples.customerservice.consumer.api.dto.CustomerConsumerCreateRequest; +import io.github.blueprintplatform.samples.customerservice.consumer.api.dto.CustomerConsumerSearchCriteria; +import io.github.blueprintplatform.samples.customerservice.consumer.api.dto.CustomerConsumerUpdateRequest; +import io.github.blueprintplatform.samples.customerservice.consumer.service.CustomerConsumerService; +import jakarta.validation.Valid; +import jakarta.validation.constraints.Max; +import jakarta.validation.constraints.Min; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.*; + +/** + * Exposes customer operations sourced from the upstream customer service. Delegates all business + * logic to CustomerConsumerService. + */ +@RestController +@RequestMapping(value = "/customers", produces = MediaType.APPLICATION_JSON_VALUE) +@Validated +public class CustomerConsumerController { + + private final CustomerConsumerService customerConsumerService; + + public CustomerConsumerController(CustomerConsumerService customerConsumerService) { + this.customerConsumerService = customerConsumerService; + } + + @PostMapping(consumes = MediaType.APPLICATION_JSON_VALUE) + public ResponseEntity> createCustomer( + @Valid @RequestBody CustomerConsumerCreateRequest request) { + return ResponseEntity.status(HttpStatus.CREATED) + .body(customerConsumerService.createCustomer(request)); + } + + @GetMapping("/{customerId}") + public ResponseEntity> getCustomer( + @PathVariable @Min(1) Integer customerId) { + return ResponseEntity.ok(customerConsumerService.getCustomer(customerId)); + } + + @GetMapping + public ResponseEntity>> getCustomers( + @ModelAttribute CustomerConsumerSearchCriteria criteria, + @RequestParam(defaultValue = "0") @Min(0) int page, + @RequestParam(defaultValue = "5") @Min(1) @Max(10) int size, + @RequestParam(defaultValue = "customerId") CustomerSortField sortBy, + @RequestParam(defaultValue = "asc") SortDirection direction) { + return ResponseEntity.ok( + customerConsumerService.getCustomers( + criteria.name(), criteria.email(), page, size, sortBy, direction)); + } + + @PutMapping(path = "/{customerId}", consumes = MediaType.APPLICATION_JSON_VALUE) + public ResponseEntity> updateCustomer( + @PathVariable @Min(1) Integer customerId, + @Valid @RequestBody CustomerConsumerUpdateRequest request) { + return ResponseEntity.ok(customerConsumerService.updateCustomer(customerId, request)); + } + + @DeleteMapping("/{customerId}") + @ResponseStatus(HttpStatus.NO_CONTENT) + public ResponseEntity deleteCustomer(@PathVariable @Min(1) Integer customerId) { + customerConsumerService.deleteCustomer(customerId); + return ResponseEntity.noContent().build(); + } +} diff --git a/samples/spring-boot-3/customer-service-consumer/src/main/java/io/github/blueprintplatform/samples/customerservice/consumer/api/dto/CustomerConsumerCreateRequest.java b/samples/spring-boot-3/customer-service-consumer/src/main/java/io/github/blueprintplatform/samples/customerservice/consumer/api/dto/CustomerConsumerCreateRequest.java new file mode 100644 index 0000000..43aa169 --- /dev/null +++ b/samples/spring-boot-3/customer-service-consumer/src/main/java/io/github/blueprintplatform/samples/customerservice/consumer/api/dto/CustomerConsumerCreateRequest.java @@ -0,0 +1,8 @@ +package io.github.blueprintplatform.samples.customerservice.consumer.api.dto; + +import jakarta.validation.constraints.Email; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; + +public record CustomerConsumerCreateRequest( + @NotBlank @Size(min = 2, max = 80) String name, @NotBlank @Email String email) {} diff --git a/samples/spring-boot-3/customer-service-consumer/src/main/java/io/github/blueprintplatform/samples/customerservice/consumer/api/dto/CustomerConsumerSearchCriteria.java b/samples/spring-boot-3/customer-service-consumer/src/main/java/io/github/blueprintplatform/samples/customerservice/consumer/api/dto/CustomerConsumerSearchCriteria.java new file mode 100644 index 0000000..c87d5c1 --- /dev/null +++ b/samples/spring-boot-3/customer-service-consumer/src/main/java/io/github/blueprintplatform/samples/customerservice/consumer/api/dto/CustomerConsumerSearchCriteria.java @@ -0,0 +1,6 @@ +package io.github.blueprintplatform.samples.customerservice.consumer.api.dto; + +import org.springdoc.core.annotations.ParameterObject; + +@ParameterObject +public record CustomerConsumerSearchCriteria(String name, String email) {} diff --git a/samples/spring-boot-3/customer-service-consumer/src/main/java/io/github/blueprintplatform/samples/customerservice/consumer/api/dto/CustomerConsumerUpdateRequest.java b/samples/spring-boot-3/customer-service-consumer/src/main/java/io/github/blueprintplatform/samples/customerservice/consumer/api/dto/CustomerConsumerUpdateRequest.java new file mode 100644 index 0000000..e1d8625 --- /dev/null +++ b/samples/spring-boot-3/customer-service-consumer/src/main/java/io/github/blueprintplatform/samples/customerservice/consumer/api/dto/CustomerConsumerUpdateRequest.java @@ -0,0 +1,8 @@ +package io.github.blueprintplatform.samples.customerservice.consumer.api.dto; + +import jakarta.validation.constraints.Email; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; + +public record CustomerConsumerUpdateRequest( + @NotBlank @Size(min = 2, max = 80) String name, @NotBlank @Email String email) {} diff --git a/samples/spring-boot-3/customer-service-consumer/src/main/java/io/github/blueprintplatform/samples/customerservice/consumer/api/error/CustomerConsumerExceptionHandler.java b/samples/spring-boot-3/customer-service-consumer/src/main/java/io/github/blueprintplatform/samples/customerservice/consumer/api/error/CustomerConsumerExceptionHandler.java new file mode 100644 index 0000000..158f4b4 --- /dev/null +++ b/samples/spring-boot-3/customer-service-consumer/src/main/java/io/github/blueprintplatform/samples/customerservice/consumer/api/error/CustomerConsumerExceptionHandler.java @@ -0,0 +1,64 @@ +package io.github.blueprintplatform.samples.customerservice.consumer.api.error; + +import io.github.blueprintplatform.samples.customerservice.consumer.common.exception.CustomerConsumerException; +import jakarta.servlet.http.HttpServletRequest; +import java.net.URI; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.core.annotation.Order; +import org.springframework.http.HttpStatus; +import org.springframework.http.ProblemDetail; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.RestControllerAdvice; +import org.springframework.web.util.UriComponentsBuilder; + +/** + * Handles consumer-level exceptions and produces RFC 9457 problem responses. Only consumer-owned + * exception types are handled here. + */ +@RestControllerAdvice +@Order(1) +public class CustomerConsumerExceptionHandler { + + private static final Logger log = LoggerFactory.getLogger(CustomerConsumerExceptionHandler.class); + + private static final String KEY_ERROR_CODE = "errorCode"; + private static final String PROBLEM_BASE = "urn:customer-service-consumer:problem:"; + private static final String ERROR_CODE_INTERNAL_ERROR = "INTERNAL_ERROR"; + + @ExceptionHandler(CustomerConsumerException.class) + public ProblemDetail handleCustomerConsumerException( + CustomerConsumerException ex, HttpServletRequest req) { + + log.warn("Consumer error [status={}, code={}]", ex.getStatus(), ex.getErrorCode()); + + ProblemDetail pd = ProblemDetail.forStatusAndDetail(ex.getStatus(), ex.getMessage()); + + pd.setType(URI.create(PROBLEM_BASE + "upstream-error")); + pd.setTitle("Upstream Error"); + pd.setInstance(instance(req)); + pd.setProperty(KEY_ERROR_CODE, ex.getErrorCode()); + + return pd; + } + + @ExceptionHandler(Exception.class) + public ProblemDetail handleGeneric(Exception ex, HttpServletRequest req) { + log.error("Unhandled exception", ex); + + ProblemDetail pd = + ProblemDetail.forStatusAndDetail( + HttpStatus.INTERNAL_SERVER_ERROR, "An unexpected error occurred."); + + pd.setType(URI.create(PROBLEM_BASE + "internal-error")); + pd.setTitle("Internal Server Error"); + pd.setInstance(instance(req)); + pd.setProperty(KEY_ERROR_CODE, ERROR_CODE_INTERNAL_ERROR); + + return pd; + } + + private URI instance(HttpServletRequest req) { + return UriComponentsBuilder.fromPath(req.getRequestURI()).build().toUri(); + } +} diff --git a/samples/spring-boot-3/customer-service-consumer/src/main/java/io/github/blueprintplatform/samples/customerservice/consumer/common/exception/CustomerConsumerException.java b/samples/spring-boot-3/customer-service-consumer/src/main/java/io/github/blueprintplatform/samples/customerservice/consumer/common/exception/CustomerConsumerException.java new file mode 100644 index 0000000..3bf466e --- /dev/null +++ b/samples/spring-boot-3/customer-service-consumer/src/main/java/io/github/blueprintplatform/samples/customerservice/consumer/common/exception/CustomerConsumerException.java @@ -0,0 +1,56 @@ +package io.github.blueprintplatform.samples.customerservice.consumer.common.exception; + +import io.github.blueprintplatform.openapi.generics.contract.error.ErrorItem; +import java.io.Serial; +import java.io.Serializable; +import java.util.List; +import org.springframework.http.HttpStatus; + +/** + * Consumer-level exception wrapping upstream failures. Shields callers from client internals and + * carries normalized error context for handler and logging. + */ +public final class CustomerConsumerException extends RuntimeException implements Serializable { + + @Serial private static final long serialVersionUID = 1L; + + private final HttpStatus status; + private final String errorCode; + private final transient List errors; + + public CustomerConsumerException( + HttpStatus status, String errorCode, String message, List errors) { + super(message); + this.status = status; + this.errorCode = errorCode; + this.errors = errors != null ? List.copyOf(errors) : List.of(); + } + + public CustomerConsumerException( + HttpStatus status, + String errorCode, + String message, + List errors, + Throwable cause) { + super(message, cause); + this.status = status; + this.errorCode = errorCode; + this.errors = errors != null ? List.copyOf(errors) : List.of(); + } + + public HttpStatus getStatus() { + return status; + } + + public String getErrorCode() { + return errorCode; + } + + public List getErrors() { + return errors; + } + + public boolean hasErrors() { + return !errors.isEmpty(); + } +} diff --git a/samples/spring-boot-3/customer-service-consumer/src/main/java/io/github/blueprintplatform/samples/customerservice/consumer/common/mapper/CustomerConsumerExceptionMapper.java b/samples/spring-boot-3/customer-service-consumer/src/main/java/io/github/blueprintplatform/samples/customerservice/consumer/common/mapper/CustomerConsumerExceptionMapper.java new file mode 100644 index 0000000..58e7095 --- /dev/null +++ b/samples/spring-boot-3/customer-service-consumer/src/main/java/io/github/blueprintplatform/samples/customerservice/consumer/common/mapper/CustomerConsumerExceptionMapper.java @@ -0,0 +1,43 @@ +package io.github.blueprintplatform.samples.customerservice.consumer.common.mapper; + +import io.github.blueprintplatform.samples.customerservice.client.common.problem.ApiProblemException; +import io.github.blueprintplatform.samples.customerservice.consumer.common.exception.CustomerConsumerException; +import org.springframework.http.HttpStatus; +import org.springframework.stereotype.Component; + +/** + * Maps ApiProblemException to CustomerConsumerException, isolating consumer layers from client + * internals. + */ +@Component +public class CustomerConsumerExceptionMapper { + + private static final String FALLBACK_ERROR_CODE = "UPSTREAM_ERROR"; + private static final String FALLBACK_MESSAGE = "Upstream service returned an error."; + + public CustomerConsumerException from(ApiProblemException source) { + HttpStatus status = resolveStatus(source); + String errorCode = resolveErrorCode(source); + String message = resolveMessage(source); + + return new CustomerConsumerException(status, errorCode, message, source.getErrors(), source); + } + + private HttpStatus resolveStatus(ApiProblemException source) { + HttpStatus resolved = HttpStatus.resolve(source.getStatus()); + return resolved != null ? resolved : HttpStatus.INTERNAL_SERVER_ERROR; + } + + private String resolveErrorCode(ApiProblemException source) { + String code = source.getErrorCode(); + return (code != null && !code.isBlank()) ? code : FALLBACK_ERROR_CODE; + } + + private String resolveMessage(ApiProblemException source) { + if (source.getProblem() == null) { + return FALLBACK_MESSAGE; + } + String detail = source.getProblem().getDetail(); + return (detail != null && !detail.isBlank()) ? detail : FALLBACK_MESSAGE; + } +} diff --git a/samples/spring-boot-3/customer-service-consumer/src/main/java/io/github/blueprintplatform/samples/customerservice/consumer/common/openapi/OpenApiConfig.java b/samples/spring-boot-3/customer-service-consumer/src/main/java/io/github/blueprintplatform/samples/customerservice/consumer/common/openapi/OpenApiConfig.java new file mode 100644 index 0000000..cc05df1 --- /dev/null +++ b/samples/spring-boot-3/customer-service-consumer/src/main/java/io/github/blueprintplatform/samples/customerservice/consumer/common/openapi/OpenApiConfig.java @@ -0,0 +1,31 @@ +package io.github.blueprintplatform.samples.customerservice.consumer.common.openapi; + +import static io.github.blueprintplatform.samples.customerservice.consumer.common.openapi.OpenApiConstants.*; + +import io.swagger.v3.oas.models.OpenAPI; +import io.swagger.v3.oas.models.info.Info; +import io.swagger.v3.oas.models.servers.Server; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +@Configuration +public class OpenApiConfig { + + @Value("${app.openapi.version:${project.version:unknown}}") + private String version; + + @Value("${app.openapi.base-url:}") + private String baseUrl; + + @Bean + public OpenAPI customerServiceOpenAPI() { + var openapi = + new OpenAPI().info(new Info().title(TITLE).version(version).description(DESCRIPTION)); + + if (baseUrl != null && !baseUrl.isBlank()) { + openapi.addServersItem(new Server().url(baseUrl).description(SERVER_DESCRIPTION)); + } + return openapi; + } +} diff --git a/samples/spring-boot-3/customer-service-consumer/src/main/java/io/github/blueprintplatform/samples/customerservice/consumer/common/openapi/OpenApiConstants.java b/samples/spring-boot-3/customer-service-consumer/src/main/java/io/github/blueprintplatform/samples/customerservice/consumer/common/openapi/OpenApiConstants.java new file mode 100644 index 0000000..75df579 --- /dev/null +++ b/samples/spring-boot-3/customer-service-consumer/src/main/java/io/github/blueprintplatform/samples/customerservice/consumer/common/openapi/OpenApiConstants.java @@ -0,0 +1,10 @@ +package io.github.blueprintplatform.samples.customerservice.consumer.common.openapi; + +public final class OpenApiConstants { + public static final String TITLE = "Customer Service Consumer API"; + public static final String DESCRIPTION = + "Customer Service Consumer API with type-safe generic responses using OpenAPI"; + public static final String SERVER_DESCRIPTION = "Consumer Local service URL"; + + private OpenApiConstants() {} +} diff --git a/samples/spring-boot-3/customer-service-consumer/src/main/java/io/github/blueprintplatform/samples/customerservice/consumer/config/JacksonConfig.java b/samples/spring-boot-3/customer-service-consumer/src/main/java/io/github/blueprintplatform/samples/customerservice/consumer/config/JacksonConfig.java new file mode 100644 index 0000000..c40f939 --- /dev/null +++ b/samples/spring-boot-3/customer-service-consumer/src/main/java/io/github/blueprintplatform/samples/customerservice/consumer/config/JacksonConfig.java @@ -0,0 +1,30 @@ +package io.github.blueprintplatform.samples.customerservice.consumer.config; + +import com.fasterxml.jackson.core.JsonGenerator; +import com.fasterxml.jackson.databind.JsonSerializer; +import com.fasterxml.jackson.databind.SerializerProvider; +import com.fasterxml.jackson.databind.module.SimpleModule; +import io.github.blueprintplatform.openapi.generics.contract.paging.SortDirection; +import java.io.IOException; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +@Configuration +public class JacksonConfig { + + @Bean + public SimpleModule sortDirectionModule() { + SimpleModule module = new SimpleModule(); + module.addSerializer( + SortDirection.class, + new JsonSerializer<>() { + @Override + public void serialize( + SortDirection value, JsonGenerator gen, SerializerProvider serializers) + throws IOException { + gen.writeString(value.value()); + } + }); + return module; + } +} diff --git a/samples/spring-boot-3/customer-service-consumer/src/main/java/io/github/blueprintplatform/samples/customerservice/consumer/config/WebConfig.java b/samples/spring-boot-3/customer-service-consumer/src/main/java/io/github/blueprintplatform/samples/customerservice/consumer/config/WebConfig.java new file mode 100644 index 0000000..b5936f0 --- /dev/null +++ b/samples/spring-boot-3/customer-service-consumer/src/main/java/io/github/blueprintplatform/samples/customerservice/consumer/config/WebConfig.java @@ -0,0 +1,17 @@ +package io.github.blueprintplatform.samples.customerservice.consumer.config; + +import io.github.blueprintplatform.openapi.generics.contract.paging.SortDirection; +import io.github.blueprintplatform.samples.customerservice.client.customer.CustomerSortField; +import org.springframework.context.annotation.Configuration; +import org.springframework.format.FormatterRegistry; +import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; + +@Configuration +public class WebConfig implements WebMvcConfigurer { + + @Override + public void addFormatters(FormatterRegistry registry) { + registry.addConverter(String.class, CustomerSortField.class, CustomerSortField::from); + registry.addConverter(String.class, SortDirection.class, SortDirection::from); + } +} diff --git a/samples/spring-boot-3/customer-service-consumer/src/main/java/io/github/blueprintplatform/samples/customerservice/consumer/service/CustomerConsumerService.java b/samples/spring-boot-3/customer-service-consumer/src/main/java/io/github/blueprintplatform/samples/customerservice/consumer/service/CustomerConsumerService.java new file mode 100644 index 0000000..1bafc09 --- /dev/null +++ b/samples/spring-boot-3/customer-service-consumer/src/main/java/io/github/blueprintplatform/samples/customerservice/consumer/service/CustomerConsumerService.java @@ -0,0 +1,34 @@ +package io.github.blueprintplatform.samples.customerservice.consumer.service; + +import io.github.blueprintplatform.contracts.customer.CustomerDto; +import io.github.blueprintplatform.openapi.generics.contract.envelope.ServiceResponse; +import io.github.blueprintplatform.openapi.generics.contract.paging.Page; +import io.github.blueprintplatform.openapi.generics.contract.paging.SortDirection; +import io.github.blueprintplatform.samples.customerservice.client.customer.CustomerSortField; +import io.github.blueprintplatform.samples.customerservice.consumer.api.dto.CustomerConsumerCreateRequest; +import io.github.blueprintplatform.samples.customerservice.consumer.api.dto.CustomerConsumerUpdateRequest; + +/** + * Orchestration layer for customer operations. Coordinates remote calls via CustomerServiceClient + * and provides the integration point for cross-cutting concerns such as caching, fallback, or + * response aggregation. + */ +public interface CustomerConsumerService { + + ServiceResponse createCustomer(CustomerConsumerCreateRequest request); + + ServiceResponse getCustomer(Integer customerId); + + ServiceResponse> getCustomers( + String name, + String email, + Integer page, + Integer size, + CustomerSortField sortBy, + SortDirection direction); + + ServiceResponse updateCustomer( + Integer customerId, CustomerConsumerUpdateRequest request); + + ServiceResponse deleteCustomer(Integer customerId); +} diff --git a/samples/spring-boot-3/customer-service-consumer/src/main/java/io/github/blueprintplatform/samples/customerservice/consumer/service/client/CustomerServiceClient.java b/samples/spring-boot-3/customer-service-consumer/src/main/java/io/github/blueprintplatform/samples/customerservice/consumer/service/client/CustomerServiceClient.java new file mode 100644 index 0000000..e2c2cdb --- /dev/null +++ b/samples/spring-boot-3/customer-service-consumer/src/main/java/io/github/blueprintplatform/samples/customerservice/consumer/service/client/CustomerServiceClient.java @@ -0,0 +1,33 @@ +package io.github.blueprintplatform.samples.customerservice.consumer.service.client; + +import io.github.blueprintplatform.contracts.customer.CustomerDto; +import io.github.blueprintplatform.openapi.generics.contract.envelope.ServiceResponse; +import io.github.blueprintplatform.openapi.generics.contract.paging.Page; +import io.github.blueprintplatform.openapi.generics.contract.paging.SortDirection; +import io.github.blueprintplatform.samples.customerservice.client.customer.CustomerSortField; +import io.github.blueprintplatform.samples.customerservice.consumer.api.dto.CustomerConsumerCreateRequest; +import io.github.blueprintplatform.samples.customerservice.consumer.api.dto.CustomerConsumerUpdateRequest; + +/** + * Consumer-side boundary for customer service remote calls. Isolates the consumer from the client + * adapter, enabling independent testing and future evolution. + */ +public interface CustomerServiceClient { + + ServiceResponse createCustomer(CustomerConsumerCreateRequest request); + + ServiceResponse getCustomer(Integer customerId); + + ServiceResponse> getCustomers( + String name, + String email, + Integer page, + Integer size, + CustomerSortField sortBy, + SortDirection direction); + + ServiceResponse updateCustomer( + Integer customerId, CustomerConsumerUpdateRequest request); + + ServiceResponse deleteCustomer(Integer customerId); +} diff --git a/samples/spring-boot-3/customer-service-consumer/src/main/java/io/github/blueprintplatform/samples/customerservice/consumer/service/client/impl/CustomerServiceClientImpl.java b/samples/spring-boot-3/customer-service-consumer/src/main/java/io/github/blueprintplatform/samples/customerservice/consumer/service/client/impl/CustomerServiceClientImpl.java new file mode 100644 index 0000000..3358457 --- /dev/null +++ b/samples/spring-boot-3/customer-service-consumer/src/main/java/io/github/blueprintplatform/samples/customerservice/consumer/service/client/impl/CustomerServiceClientImpl.java @@ -0,0 +1,85 @@ +package io.github.blueprintplatform.samples.customerservice.consumer.service.client.impl; + +import io.github.blueprintplatform.contracts.customer.CustomerDto; +import io.github.blueprintplatform.openapi.generics.contract.envelope.ServiceResponse; +import io.github.blueprintplatform.openapi.generics.contract.paging.Page; +import io.github.blueprintplatform.openapi.generics.contract.paging.SortDirection; +import io.github.blueprintplatform.samples.customerservice.client.adapter.CustomerClientAdapter; +import io.github.blueprintplatform.samples.customerservice.client.common.problem.ApiProblemException; +import io.github.blueprintplatform.samples.customerservice.client.customer.CustomerSortField; +import io.github.blueprintplatform.samples.customerservice.consumer.api.dto.CustomerConsumerCreateRequest; +import io.github.blueprintplatform.samples.customerservice.consumer.api.dto.CustomerConsumerUpdateRequest; +import io.github.blueprintplatform.samples.customerservice.consumer.common.mapper.CustomerConsumerExceptionMapper; +import io.github.blueprintplatform.samples.customerservice.consumer.service.client.CustomerServiceClient; +import io.github.blueprintplatform.samples.customerservice.consumer.service.client.mapper.CustomerRequestMapper; +import org.springframework.stereotype.Service; + +@Service +public class CustomerServiceClientImpl implements CustomerServiceClient { + + private final CustomerClientAdapter adapter; + private final CustomerConsumerExceptionMapper exceptionMapper; + private final CustomerRequestMapper requestMapper; + + public CustomerServiceClientImpl( + CustomerClientAdapter adapter, + CustomerConsumerExceptionMapper exceptionMapper, + CustomerRequestMapper requestMapper) { + this.adapter = adapter; + this.exceptionMapper = exceptionMapper; + this.requestMapper = requestMapper; + } + + @Override + public ServiceResponse createCustomer( + CustomerConsumerCreateRequest customerConsumerCreateRequest) { + try { + return adapter.createCustomer(requestMapper.from(customerConsumerCreateRequest)); + } catch (ApiProblemException ex) { + throw exceptionMapper.from(ex); + } + } + + @Override + public ServiceResponse getCustomer(Integer customerId) { + try { + return adapter.getCustomer(customerId); + } catch (ApiProblemException ex) { + throw exceptionMapper.from(ex); + } + } + + @Override + public ServiceResponse> getCustomers( + String name, + String email, + Integer page, + Integer size, + CustomerSortField sortBy, + SortDirection direction) { + try { + return adapter.getCustomers(name, email, page, size, sortBy, direction); + } catch (ApiProblemException ex) { + throw exceptionMapper.from(ex); + } + } + + @Override + public ServiceResponse updateCustomer( + Integer customerId, CustomerConsumerUpdateRequest consumerUpdateRequest) { + try { + return adapter.updateCustomer(customerId, requestMapper.from(consumerUpdateRequest)); + } catch (ApiProblemException ex) { + throw exceptionMapper.from(ex); + } + } + + @Override + public ServiceResponse deleteCustomer(Integer customerId) { + try { + return adapter.deleteCustomer(customerId); + } catch (ApiProblemException ex) { + throw exceptionMapper.from(ex); + } + } +} diff --git a/samples/spring-boot-3/customer-service-consumer/src/main/java/io/github/blueprintplatform/samples/customerservice/consumer/service/client/mapper/CustomerRequestMapper.java b/samples/spring-boot-3/customer-service-consumer/src/main/java/io/github/blueprintplatform/samples/customerservice/consumer/service/client/mapper/CustomerRequestMapper.java new file mode 100644 index 0000000..e08266d --- /dev/null +++ b/samples/spring-boot-3/customer-service-consumer/src/main/java/io/github/blueprintplatform/samples/customerservice/consumer/service/client/mapper/CustomerRequestMapper.java @@ -0,0 +1,27 @@ +package io.github.blueprintplatform.samples.customerservice.consumer.service.client.mapper; + +import io.github.blueprintplatform.samples.customerservice.client.generated.dto.CustomerCreateRequest; +import io.github.blueprintplatform.samples.customerservice.client.generated.dto.CustomerUpdateRequest; +import io.github.blueprintplatform.samples.customerservice.consumer.api.dto.CustomerConsumerCreateRequest; +import io.github.blueprintplatform.samples.customerservice.consumer.api.dto.CustomerConsumerUpdateRequest; +import org.springframework.stereotype.Component; + +@Component +public class CustomerRequestMapper { + + public CustomerCreateRequest from(CustomerConsumerCreateRequest source) { + if (source == null) { + return null; + } + + return new CustomerCreateRequest().name(source.name()).email(source.email()); + } + + public CustomerUpdateRequest from(CustomerConsumerUpdateRequest source) { + if (source == null) { + return null; + } + + return new CustomerUpdateRequest().name(source.name()).email(source.email()); + } +} diff --git a/samples/spring-boot-3/customer-service-consumer/src/main/java/io/github/blueprintplatform/samples/customerservice/consumer/service/impl/CustomerConsumerServiceImpl.java b/samples/spring-boot-3/customer-service-consumer/src/main/java/io/github/blueprintplatform/samples/customerservice/consumer/service/impl/CustomerConsumerServiceImpl.java new file mode 100644 index 0000000..d59ded9 --- /dev/null +++ b/samples/spring-boot-3/customer-service-consumer/src/main/java/io/github/blueprintplatform/samples/customerservice/consumer/service/impl/CustomerConsumerServiceImpl.java @@ -0,0 +1,54 @@ +package io.github.blueprintplatform.samples.customerservice.consumer.service.impl; + +import io.github.blueprintplatform.contracts.customer.CustomerDto; +import io.github.blueprintplatform.openapi.generics.contract.envelope.ServiceResponse; +import io.github.blueprintplatform.openapi.generics.contract.paging.Page; +import io.github.blueprintplatform.openapi.generics.contract.paging.SortDirection; +import io.github.blueprintplatform.samples.customerservice.client.customer.CustomerSortField; +import io.github.blueprintplatform.samples.customerservice.consumer.api.dto.CustomerConsumerCreateRequest; +import io.github.blueprintplatform.samples.customerservice.consumer.api.dto.CustomerConsumerUpdateRequest; +import io.github.blueprintplatform.samples.customerservice.consumer.service.CustomerConsumerService; +import io.github.blueprintplatform.samples.customerservice.consumer.service.client.CustomerServiceClient; +import org.springframework.stereotype.Service; + +@Service +public class CustomerConsumerServiceImpl implements CustomerConsumerService { + + private final CustomerServiceClient customerServiceClient; + + public CustomerConsumerServiceImpl(CustomerServiceClient customerServiceClient) { + this.customerServiceClient = customerServiceClient; + } + + @Override + public ServiceResponse createCustomer(CustomerConsumerCreateRequest request) { + return customerServiceClient.createCustomer(request); + } + + @Override + public ServiceResponse getCustomer(Integer customerId) { + return customerServiceClient.getCustomer(customerId); + } + + @Override + public ServiceResponse> getCustomers( + String name, + String email, + Integer page, + Integer size, + CustomerSortField sortBy, + SortDirection direction) { + return customerServiceClient.getCustomers(name, email, page, size, sortBy, direction); + } + + @Override + public ServiceResponse updateCustomer( + Integer customerId, CustomerConsumerUpdateRequest request) { + return customerServiceClient.updateCustomer(customerId, request); + } + + @Override + public ServiceResponse deleteCustomer(Integer customerId) { + return customerServiceClient.deleteCustomer(customerId); + } +} diff --git a/samples/spring-boot-3/customer-service-consumer/src/main/resources/application.yml b/samples/spring-boot-3/customer-service-consumer/src/main/resources/application.yml new file mode 100644 index 0000000..2f07812 --- /dev/null +++ b/samples/spring-boot-3/customer-service-consumer/src/main/resources/application.yml @@ -0,0 +1,41 @@ +server: + port: ${APP_PORT:8085} + servlet: + context-path: /customer-service-consumer + error: + include-message: always + include-binding-errors: always + include-stacktrace: never + include-exception: false + +spring: + jackson: + deserialization: + fail-on-unknown-properties: true + mvc: + problemdetails: + enabled: true + application: + name: customer-service-consumer + profiles: + active: ${SPRING_PROFILES_ACTIVE:local} + +app: + openapi: + version: @project.version@ + base-url: "http://localhost:${server.port}${server.servlet.context-path:}" + +logging: + level: + root: INFO + org.springframework.web: INFO + io.github.blueprintplatform: DEBUG + +customer: + api: + base-url: ${CUSTOMER_API_BASE_URL:http://localhost:8084/customer-service} + max-connections-total: 64 + max-connections-per-route: 16 + connect-timeout-seconds: 10 + connection-request-timeout-seconds: 10 + read-timeout-seconds: 15 \ No newline at end of file diff --git a/samples/spring-boot-3/customer-service-consumer/src/test/java/io/github/blueprintplatform/samples/customerservice/consumer/common/mapper/CustomerConsumerExceptionMapperTest.java b/samples/spring-boot-3/customer-service-consumer/src/test/java/io/github/blueprintplatform/samples/customerservice/consumer/common/mapper/CustomerConsumerExceptionMapperTest.java new file mode 100644 index 0000000..1adadff --- /dev/null +++ b/samples/spring-boot-3/customer-service-consumer/src/test/java/io/github/blueprintplatform/samples/customerservice/consumer/common/mapper/CustomerConsumerExceptionMapperTest.java @@ -0,0 +1,88 @@ +package io.github.blueprintplatform.samples.customerservice.consumer.common.mapper; + +import static org.junit.jupiter.api.Assertions.*; + +import io.github.blueprintplatform.samples.customerservice.client.common.problem.ApiProblemException; +import io.github.blueprintplatform.samples.customerservice.consumer.common.exception.CustomerConsumerException; +import java.util.Map; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; +import org.springframework.http.HttpStatus; +import org.springframework.http.ProblemDetail; + +@Tag("unit") +@DisplayName("Unit Test: CustomerConsumerExceptionMapper") +class CustomerConsumerExceptionMapperTest { + + private final CustomerConsumerExceptionMapper mapper = new CustomerConsumerExceptionMapper(); + + @Test + @DisplayName("from -> maps status, errorCode and message correctly") + void shouldMapAllFields() { + ProblemDetail problem = + ProblemDetail.forStatusAndDetail(HttpStatus.BAD_REQUEST, "Bad request detail"); + problem.setProperty("errorCode", "VALIDATION_FAILED"); + + ApiProblemException source = new ApiProblemException(problem, HttpStatus.BAD_REQUEST.value()); + + CustomerConsumerException result = mapper.from(source); + + assertNotNull(result); + assertEquals(HttpStatus.BAD_REQUEST, result.getStatus()); + assertEquals("VALIDATION_FAILED", result.getErrorCode()); + assertEquals("Bad request detail", result.getMessage()); + assertSame(source, result.getCause()); + } + + @Test + @DisplayName("from -> uses fallback errorCode when missing") + void shouldUseFallbackErrorCode() { + ProblemDetail problem = + ProblemDetail.forStatusAndDetail(HttpStatus.BAD_REQUEST, "Bad request detail"); + + ApiProblemException source = new ApiProblemException(problem, HttpStatus.BAD_REQUEST.value()); + + CustomerConsumerException result = mapper.from(source); + + assertEquals("UPSTREAM_ERROR", result.getErrorCode()); + } + + @Test + @DisplayName("from -> uses fallback message when problem is null") + void shouldUseFallbackMessageWhenProblemNull() { + ApiProblemException source = new ApiProblemException(null, HttpStatus.BAD_REQUEST.value()); + + CustomerConsumerException result = mapper.from(source); + + assertEquals("Upstream service returned an error.", result.getMessage()); + } + + @Test + @DisplayName("from -> uses fallback message when detail is blank") + void shouldUseFallbackMessageWhenDetailBlank() { + ProblemDetail problem = ProblemDetail.forStatus(HttpStatus.BAD_REQUEST); + problem.setDetail(""); + problem.setProperty("errorCode", "ERR"); + + ApiProblemException source = new ApiProblemException(problem, HttpStatus.BAD_REQUEST.value()); + + CustomerConsumerException result = mapper.from(source); + + assertEquals("Upstream service returned an error.", result.getMessage()); + } + + @Test + @DisplayName("from -> uses INTERNAL_SERVER_ERROR when status invalid") + void shouldFallbackToInternalServerError() { + ProblemDetail problem = ProblemDetail.forStatus(HttpStatus.BAD_REQUEST); + problem.setDetail("Any detail"); + problem.setProperties(Map.of("errorCode", "ERR")); + + ApiProblemException source = new ApiProblemException(problem, 999); + + CustomerConsumerException result = mapper.from(source); + + assertEquals(HttpStatus.INTERNAL_SERVER_ERROR, result.getStatus()); + } +} diff --git a/samples/spring-boot-3/customer-service-consumer/src/test/java/io/github/blueprintplatform/samples/customerservice/consumer/service/client/impl/CustomerServiceClientImplTest.java b/samples/spring-boot-3/customer-service-consumer/src/test/java/io/github/blueprintplatform/samples/customerservice/consumer/service/client/impl/CustomerServiceClientImplTest.java new file mode 100644 index 0000000..1c8b256 --- /dev/null +++ b/samples/spring-boot-3/customer-service-consumer/src/test/java/io/github/blueprintplatform/samples/customerservice/consumer/service/client/impl/CustomerServiceClientImplTest.java @@ -0,0 +1,153 @@ +package io.github.blueprintplatform.samples.customerservice.consumer.service.client.impl; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +import io.github.blueprintplatform.contracts.customer.CustomerDto; +import io.github.blueprintplatform.openapi.generics.contract.envelope.ServiceResponse; +import io.github.blueprintplatform.openapi.generics.contract.paging.Page; +import io.github.blueprintplatform.openapi.generics.contract.paging.SortDirection; +import io.github.blueprintplatform.samples.customerservice.client.adapter.CustomerClientAdapter; +import io.github.blueprintplatform.samples.customerservice.client.common.problem.ApiProblemException; +import io.github.blueprintplatform.samples.customerservice.client.customer.CustomerSortField; +import io.github.blueprintplatform.samples.customerservice.client.generated.dto.CustomerCreateRequest; +import io.github.blueprintplatform.samples.customerservice.client.generated.dto.CustomerUpdateRequest; +import io.github.blueprintplatform.samples.customerservice.consumer.api.dto.CustomerConsumerCreateRequest; +import io.github.blueprintplatform.samples.customerservice.consumer.api.dto.CustomerConsumerUpdateRequest; +import io.github.blueprintplatform.samples.customerservice.consumer.common.exception.CustomerConsumerException; +import io.github.blueprintplatform.samples.customerservice.consumer.common.mapper.CustomerConsumerExceptionMapper; +import io.github.blueprintplatform.samples.customerservice.consumer.service.client.mapper.CustomerRequestMapper; +import java.util.List; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; + +@Tag("unit") +@DisplayName("Unit Test: CustomerServiceClientImpl") +class CustomerServiceClientImplTest { + + private final CustomerClientAdapter adapter = mock(CustomerClientAdapter.class); + private final CustomerConsumerExceptionMapper exceptionMapper = + mock(CustomerConsumerExceptionMapper.class); + private final CustomerRequestMapper requestMapper = mock(CustomerRequestMapper.class); + + private final CustomerServiceClientImpl service = + new CustomerServiceClientImpl(adapter, exceptionMapper, requestMapper); + + @Test + @DisplayName("createCustomer -> maps request and returns response") + void createCustomer_success() { + var consumerReq = new CustomerConsumerCreateRequest("John", "john@mail.com"); + var mappedReq = new CustomerCreateRequest().name("John").email("john@mail.com"); + + var dto = new CustomerDto(1, "John", "john@mail.com"); + var response = ServiceResponse.of(dto); + + when(requestMapper.from(consumerReq)).thenReturn(mappedReq); + when(adapter.createCustomer(mappedReq)).thenReturn(response); + + var result = service.createCustomer(consumerReq); + + assertNotNull(result); + assertEquals(dto, result.getData()); + + verify(requestMapper).from(consumerReq); + verify(adapter).createCustomer(mappedReq); + } + + @Test + @DisplayName("createCustomer -> maps exception") + void createCustomer_exception() { + var consumerReq = new CustomerConsumerCreateRequest("John", "john@mail.com"); + var mappedReq = new CustomerCreateRequest(); + + var apiEx = mock(ApiProblemException.class); + var mappedEx = mock(CustomerConsumerException.class); + + when(requestMapper.from(consumerReq)).thenReturn(mappedReq); + when(adapter.createCustomer(mappedReq)).thenThrow(apiEx); + when(exceptionMapper.from(apiEx)).thenReturn(mappedEx); + + var thrown = + assertThrows(CustomerConsumerException.class, () -> service.createCustomer(consumerReq)); + + assertSame(mappedEx, thrown); + } + + @Test + @DisplayName("getCustomer -> success") + void getCustomer_success() { + var dto = new CustomerDto(1, "John", "john@mail.com"); + var response = ServiceResponse.of(dto); + + when(adapter.getCustomer(1)).thenReturn(response); + + var result = service.getCustomer(1); + + assertEquals(dto, result.getData()); + verify(adapter).getCustomer(1); + } + + @Test + @DisplayName("getCustomers -> success") + void getCustomers_success() { + var dto = new CustomerDto(1, "John", "john@mail.com"); + var page = Page.of(List.of(dto), 0, 5, 1); + var response = ServiceResponse.of(page); + + when(adapter.getCustomers(any(), any(), any(), any(), any(), any())).thenReturn(response); + + var result = + service.getCustomers("John", null, 0, 5, CustomerSortField.CUSTOMER_ID, SortDirection.ASC); + + assertNotNull(result); + assertEquals(1, result.getData().content().size()); + } + + @Test + @DisplayName("updateCustomer -> maps request") + void updateCustomer_success() { + var consumerReq = new CustomerConsumerUpdateRequest("Jane", "jane@mail.com"); + var mappedReq = new CustomerUpdateRequest().name("Jane").email("jane@mail.com"); + + var dto = new CustomerDto(1, "Jane", "jane@mail.com"); + var response = ServiceResponse.of(dto); + + when(requestMapper.from(consumerReq)).thenReturn(mappedReq); + when(adapter.updateCustomer(1, mappedReq)).thenReturn(response); + + var result = service.updateCustomer(1, consumerReq); + + assertEquals(dto, result.getData()); + + verify(requestMapper).from(consumerReq); + verify(adapter).updateCustomer(1, mappedReq); + } + + @Test + @DisplayName("deleteCustomer -> success") + void deleteCustomer_success() { + ServiceResponse response = ServiceResponse.of(null); + + when(adapter.deleteCustomer(1)).thenReturn(response); + + var result = service.deleteCustomer(1); + + assertNotNull(result); + verify(adapter).deleteCustomer(1); + } + + @Test + @DisplayName("deleteCustomer -> exception mapped") + void deleteCustomer_exception() { + var apiEx = mock(ApiProblemException.class); + var mappedEx = mock(CustomerConsumerException.class); + + when(adapter.deleteCustomer(1)).thenThrow(apiEx); + when(exceptionMapper.from(apiEx)).thenReturn(mappedEx); + + var thrown = assertThrows(CustomerConsumerException.class, () -> service.deleteCustomer(1)); + + assertSame(mappedEx, thrown); + } +} diff --git a/samples/spring-boot-3/customer-service-consumer/src/test/java/io/github/blueprintplatform/samples/customerservice/consumer/service/client/mapper/CustomerRequestMapperTest.java b/samples/spring-boot-3/customer-service-consumer/src/test/java/io/github/blueprintplatform/samples/customerservice/consumer/service/client/mapper/CustomerRequestMapperTest.java new file mode 100644 index 0000000..0039fb7 --- /dev/null +++ b/samples/spring-boot-3/customer-service-consumer/src/test/java/io/github/blueprintplatform/samples/customerservice/consumer/service/client/mapper/CustomerRequestMapperTest.java @@ -0,0 +1,49 @@ +package io.github.blueprintplatform.samples.customerservice.consumer.service.client.mapper; + +import static org.junit.jupiter.api.Assertions.*; + +import io.github.blueprintplatform.samples.customerservice.client.generated.dto.CustomerCreateRequest; +import io.github.blueprintplatform.samples.customerservice.client.generated.dto.CustomerUpdateRequest; +import io.github.blueprintplatform.samples.customerservice.consumer.api.dto.CustomerConsumerCreateRequest; +import io.github.blueprintplatform.samples.customerservice.consumer.api.dto.CustomerConsumerUpdateRequest; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; + +@Tag("unit") +@DisplayName("Unit Test: CustomerRequestMapper") +class CustomerRequestMapperTest { + + private final CustomerRequestMapper mapper = new CustomerRequestMapper(); + + @Test + @DisplayName("from(CustomerConsumerCreateRequest) -> maps fields correctly") + void shouldMapCreateRequest() { + var source = new CustomerConsumerCreateRequest("John", "john@example.com"); + + CustomerCreateRequest result = mapper.from(source); + + assertNotNull(result); + assertEquals("John", result.getName()); + assertEquals("john@example.com", result.getEmail()); + } + + @Test + @DisplayName("from(CustomerConsumerUpdateRequest) -> maps fields correctly") + void shouldMapUpdateRequest() { + var source = new CustomerConsumerUpdateRequest("Jane", "jane@example.com"); + + CustomerUpdateRequest result = mapper.from(source); + + assertNotNull(result); + assertEquals("Jane", result.getName()); + assertEquals("jane@example.com", result.getEmail()); + } + + @Test + @DisplayName("from(null) -> returns null") + void shouldReturnNullWhenSourceIsNull() { + assertNull(mapper.from((CustomerConsumerCreateRequest) null)); + assertNull(mapper.from((CustomerConsumerUpdateRequest) null)); + } +} diff --git a/samples/spring-boot-3/customer-service/.dockerignore b/samples/spring-boot-3/customer-service/.dockerignore new file mode 100644 index 0000000..efd6206 --- /dev/null +++ b/samples/spring-boot-3/customer-service/.dockerignore @@ -0,0 +1,5 @@ +target/ +.git +.gitignore +Dockerfile +README.md \ No newline at end of file diff --git a/samples/customer-service/Dockerfile b/samples/spring-boot-3/customer-service/Dockerfile similarity index 70% rename from samples/customer-service/Dockerfile rename to samples/spring-boot-3/customer-service/Dockerfile index 415ea70..7cb6f06 100644 --- a/samples/customer-service/Dockerfile +++ b/samples/spring-boot-3/customer-service/Dockerfile @@ -1,12 +1,12 @@ FROM maven:3.9.12-eclipse-temurin-21-noble AS builder WORKDIR /app -COPY customer-service/pom.xml customer-service/pom.xml -COPY customer-service/src customer-service/src +COPY pom.xml pom.xml +COPY src src RUN --mount=type=cache,target=/root/.m2,sharing=locked \ - mvn -q -T 2C -DskipTests --no-transfer-progress -f customer-service/pom.xml clean package && \ - JAR="$(ls -1 customer-service/target/customer-service-*.jar | head -n 1)" && \ + mvn -q -T 2C -DskipTests --no-transfer-progress clean package && \ + JAR="$(ls -1 target/customer-service-*.jar | head -n 1)" && \ java -Djarmode=layertools -jar "$JAR" extract FROM eclipse-temurin:21.0.10_7-jre-noble @@ -16,8 +16,8 @@ RUN useradd -r -u 10001 -g root -s /bin/false -d /app appuser && \ mkdir -p /app/logs && \ chown -R 10001:0 /app -LABEL org.opencontainers.image.title="customer-service" \ - org.opencontainers.image.description="Customer Service" \ +LABEL org.opencontainers.image.title="customer-service-sb3" \ + org.opencontainers.image.description="Customer Service Spring Boot 3" \ org.opencontainers.image.source="https://github.com/blueprint-platform/openapi-generics" \ org.opencontainers.image.licenses="MIT" diff --git a/samples/spring-boot-3/customer-service/README.md b/samples/spring-boot-3/customer-service/README.md new file mode 100644 index 0000000..26ed31f --- /dev/null +++ b/samples/spring-boot-3/customer-service/README.md @@ -0,0 +1,289 @@ +# customer-service + +> **Reference implementation: exposing a Spring Boot API that produces a clean, deterministic OpenAPI for contract-aligned, generics-aware clients** + +[![Java 21](https://img.shields.io/badge/Java-21-red?logo=openjdk)](https://openjdk.org/projects/jdk/21/) +[![Spring Boot](https://img.shields.io/badge/Spring%20Boot-3.5.x-green?logo=springboot)](https://spring.io/projects/spring-boot) +[![Springdoc](https://img.shields.io/badge/Springdoc-2.8.x-brightgreen)](https://springdoc.org/) +[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](../../LICENSE) + +--- + +## πŸ“‘ Table of Contents + +* [πŸš€ Start here (what you actually want)](#-start-here-what-you-actually-want) +* [⚠️ Rules (do NOT break these)](#-rules-do-not-break-these) + + * [1. Only constrain the envelope β€” payload is yours](#1-only-constrain-the-envelope--payload-is-yours) + * [2. Do NOT replace the envelope (this is the only restriction)](#2-do-not-replace-the-envelope-this-is-the-only-restriction) + * [3. Do NOT wrap errors](#3-do-not-wrap-errors) + * [4. Do NOT customize OpenAPI manually](#4-do-not-customize-openapi-manually) +* [🧠 What is happening under the hood (short version)](#-what-is-happening-under-the-hood-short-version) +* [πŸ”„ Full pipeline (important)](#-full-pipeline-important) +* [πŸ”— Related Modules](#-related-modules) +* [πŸ§ͺ Verify quickly](#-verify-quickly) +* [🌐 OpenAPI endpoints](#-openapi-endpoints) +* [🚫 What this project is NOT](#-what-this-project-is-not) +* [πŸ›‘οΈ License](#-license) + +--- + +## πŸš€ Start here (what you actually want) + +You have a Spring Boot service. + +You want a **reliable downstream outcome**: + +* OpenAPI is **clean and stable** (no schema noise) +* Client generation is **type-safe** (no envelope duplication) +* `ServiceResponse` is **preserved end-to-end** (not flattened) + +Do this: + +### 1. Add ONE dependency + +```xml + + io.github.blueprintplatform + openapi-generics-server-starter + +``` + +That’s it. + +> You write your controller contract. The starter ensures a deterministic OpenAPI projection. + +--- + +## ⚠️ Rules (do NOT break these) + +### 1. Only constrain the envelope β€” payload is yours + +You must use **only these envelope shapes**: + +```text +ServiceResponse +ServiceResponse> +``` + +**Important:** + +* `T` is completely free +* It represents YOUR domain model +* It can be anything: DTO, record, response object, etc. + +Examples (all valid): + +```text +ServiceResponse +ServiceResponse +ServiceResponse +ServiceResponse> +``` + +πŸ‘‰ The platform constrains only the **outer contract (ServiceResponse / Page)** +πŸ‘‰ It does NOT constrain your domain + +--- + +### 2. Do NOT replace the envelope (this is the only restriction) + +What is forbidden is NOT your DTO naming. + +What is forbidden is replacing the **envelope abstraction itself**. + +❌ Wrong (custom envelope types): + +```text +CustomerResponse +PagedCustomerResponse +ApiResponse +BaseResponse +``` + +These break: + +* contract symmetry +* OpenAPI determinism +* client generation + +--- + +### βœ… What is absolutely fine (and expected) + +You can define ANY domain models: + +```text +CustomerDto +CustomerResponse ← perfectly fine as a DOMAIN object +OrderResult +Anything +``` + +And use them like: + +```text +ServiceResponse +``` + +βœ” Correct +βœ” Expected usage + +--- + +### πŸ”‘ Mental model (critical) + +```text +YOU own: T (your domain / DTOs) +PLATFORM owns: envelope (ServiceResponse, Page) +``` + +If you don’t touch the envelope: + +> You are free to design your API however you want + +--- + +### 3. Do NOT wrap errors + +Errors must be: + +```text +ProblemDetail (RFC 9457) +``` + +Never: + +```text +ServiceResponse +``` + +--- + +### 4. Do NOT customize OpenAPI manually + +No annotations. +No schema hacks. +No manual overrides. + +Everything is handled by the starter. + +--- + +## 🧠 What is happening under the hood (short version) + +```text +Controller (your contract) + ↓ +ServiceResponse + ↓ +openapi-generics-server-starter + ↓ +Deterministic OpenAPI (+ vendor extensions) +``` + +Key point: + +> OpenAPI is a projection of your contract β€” not a place to define it. + +--- + +## πŸ”„ Full pipeline (important) + +```text +THIS MODULE (producer) + ↓ +OpenAPI spec (projection) + ↓ +openapi-generics-java-codegen-parent (build-time orchestration) + ↓ +Generated client (contract-aligned) +``` + +This project exists to guarantee the **first step is correct**. + +If this step is correct: + +```text +Server β†’ OpenAPI β†’ Client stays consistent +``` + +--- + +## πŸ”— Related Modules + +* **[openapi-generics-contract](../../openapi-generics-contract/README.md)** + Canonical contract definitions (`ServiceResponse`, `Page`). + +* **[openapi-generics-server-starter](../../openapi-generics-server-starter/README.md)** + Projection layer used in this module. + +* **[openapi-generics-java-codegen-parent](../../openapi-generics-java-codegen-parent/README.md)** + Client generation orchestration. + +* **[customer-service-client](../customer-service-client/README.md)** + Consumer example showing how the generated client is used. + +--- + +## πŸ§ͺ Verify quickly + +```bash +curl http://localhost:8084/customer-service/customers/1 +``` + +Expected: + +```json +{ + "data": { + "customerId": 1, + "name": "Jane Doe", + "email": "jane@example.com" + }, + "meta": { + "serverTime": "...", + "sort": [] + } +} +``` + +If this shape is correct: + +```text +Contract β†’ OpenAPI β†’ Client will be correct +``` + +--- + +## 🌐 OpenAPI endpoints + +* Swagger UI + [http://localhost:8084/customer-service/swagger-ui/index.html](http://localhost:8084/customer-service/swagger-ui/index.html) + +* OpenAPI YAML + [http://localhost:8084/customer-service/v3/api-docs.yaml](http://localhost:8084/customer-service/v3/api-docs.yaml) + +--- + +## 🚫 What this project is NOT + +* not a framework +* not a reusable starter template +* not a production system + +It is only: + +> A minimal, correct reference for contract-first API exposure + +--- + +## πŸ›‘οΈ License + +MIT License + +--- + +**Maintained by:** +**Barış SaylΔ±** +[GitHub](https://github.com/bsayli) Β· [Medium](https://medium.com/@baris.sayli) Β· [LinkedIn](https://www.linkedin.com/in/bsayli) diff --git a/samples/customer-service/pom.xml b/samples/spring-boot-3/customer-service/pom.xml similarity index 93% rename from samples/customer-service/pom.xml rename to samples/spring-boot-3/customer-service/pom.xml index da5dd64..4799d40 100644 --- a/samples/customer-service/pom.xml +++ b/samples/spring-boot-3/customer-service/pom.xml @@ -12,9 +12,9 @@ io.github.blueprint-platform.samples - customer-service - 0.8.2 - customer-service + customer-service-sb3 + 0.9.0 + customer-service-sb3 Spring Boot reference producer for the OpenAPI Generics platform https://github.com/blueprint-platform/openapi-generics @@ -30,7 +30,7 @@ https://github.com/blueprint-platform/openapi-generics scm:git:https://github.com/blueprint-platform/openapi-generics.git scm:git:ssh://git@github.com:blueprint-platform/openapi-generics.git - v0.8.2 + v0.9.0 @@ -45,8 +45,8 @@ 21 UTF-8 UTF-8 - - 0.8.2 + 0.9.0 + 0.9.0 2.8.16 0.8.14 @@ -60,6 +60,12 @@ ${openapi-generics-server-starter.version} + + io.github.blueprint-platform.samples + customer-contract + ${customer-contract.version} + + org.springframework.boot spring-boot-starter-web diff --git a/samples/customer-service/src/main/java/io/github/blueprintplatform/samples/customerservice/CustomerServiceApplication.java b/samples/spring-boot-3/customer-service/src/main/java/io/github/blueprintplatform/samples/customerservice/CustomerServiceApplication.java similarity index 93% rename from samples/customer-service/src/main/java/io/github/blueprintplatform/samples/customerservice/CustomerServiceApplication.java rename to samples/spring-boot-3/customer-service/src/main/java/io/github/blueprintplatform/samples/customerservice/CustomerServiceApplication.java index d60ae33..376c0fd 100644 --- a/samples/customer-service/src/main/java/io/github/blueprintplatform/samples/customerservice/CustomerServiceApplication.java +++ b/samples/spring-boot-3/customer-service/src/main/java/io/github/blueprintplatform/samples/customerservice/CustomerServiceApplication.java @@ -5,7 +5,7 @@ import org.springframework.boot.context.properties.ConfigurationPropertiesScan; @SpringBootApplication -@ConfigurationPropertiesScan(basePackages = "io.github.blueprintplatform.customerservice") +@ConfigurationPropertiesScan(basePackages = "io.github.blueprintplatform.samples.customerservice") public class CustomerServiceApplication { public static void main(String[] args) { SpringApplication.run(CustomerServiceApplication.class, args); diff --git a/samples/customer-service/src/main/java/io/github/blueprintplatform/samples/customerservice/api/controller/CustomerController.java b/samples/spring-boot-3/customer-service/src/main/java/io/github/blueprintplatform/samples/customerservice/api/controller/CustomerController.java similarity index 86% rename from samples/customer-service/src/main/java/io/github/blueprintplatform/samples/customerservice/api/controller/CustomerController.java rename to samples/spring-boot-3/customer-service/src/main/java/io/github/blueprintplatform/samples/customerservice/api/controller/CustomerController.java index 4f9bd10..b5172ef 100644 --- a/samples/customer-service/src/main/java/io/github/blueprintplatform/samples/customerservice/api/controller/CustomerController.java +++ b/samples/spring-boot-3/customer-service/src/main/java/io/github/blueprintplatform/samples/customerservice/api/controller/CustomerController.java @@ -1,5 +1,6 @@ package io.github.blueprintplatform.samples.customerservice.api.controller; +import io.github.blueprintplatform.contracts.customer.CustomerDto; import io.github.blueprintplatform.openapi.generics.contract.envelope.Meta; import io.github.blueprintplatform.openapi.generics.contract.envelope.ServiceResponse; import io.github.blueprintplatform.openapi.generics.contract.paging.Page; @@ -7,10 +8,13 @@ import io.github.blueprintplatform.samples.customerservice.api.dto.*; import io.github.blueprintplatform.samples.customerservice.common.api.sort.SortField; import io.github.blueprintplatform.samples.customerservice.service.CustomerService; +import io.swagger.v3.oas.annotations.responses.ApiResponse; import jakarta.validation.Valid; import jakarta.validation.constraints.Max; import jakarta.validation.constraints.Min; import java.net.URI; + +import org.springframework.http.HttpStatus; import org.springframework.http.MediaType; import org.springframework.http.ResponseEntity; import org.springframework.validation.annotation.Validated; @@ -18,7 +22,7 @@ import org.springframework.web.servlet.support.ServletUriComponentsBuilder; @RestController -@RequestMapping(value = "/v1/customers", produces = MediaType.APPLICATION_JSON_VALUE) +@RequestMapping(value = "/customers", produces = MediaType.APPLICATION_JSON_VALUE) @Validated public class CustomerController { @@ -71,11 +75,11 @@ public ResponseEntity> updateCustomer( } @DeleteMapping("/{customerId}") - public ResponseEntity> deleteCustomer( - @PathVariable @Min(1) Integer customerId) { + @ResponseStatus(HttpStatus.NO_CONTENT) + @ApiResponse(responseCode = "204", description = "Customer deleted") + public ResponseEntity deleteCustomer(@PathVariable @Min(1) Integer customerId) { customerService.deleteCustomer(customerId); - var body = new CustomerDeleteResponse(customerId); - return ResponseEntity.ok(ServiceResponse.of(body)); + return ResponseEntity.noContent().build(); } } diff --git a/samples/customer-service/src/main/java/io/github/blueprintplatform/samples/customerservice/api/dto/CustomerCreateRequest.java b/samples/spring-boot-3/customer-service/src/main/java/io/github/blueprintplatform/samples/customerservice/api/dto/CustomerCreateRequest.java similarity index 100% rename from samples/customer-service/src/main/java/io/github/blueprintplatform/samples/customerservice/api/dto/CustomerCreateRequest.java rename to samples/spring-boot-3/customer-service/src/main/java/io/github/blueprintplatform/samples/customerservice/api/dto/CustomerCreateRequest.java diff --git a/samples/customer-service/src/main/java/io/github/blueprintplatform/samples/customerservice/api/dto/CustomerSearchCriteria.java b/samples/spring-boot-3/customer-service/src/main/java/io/github/blueprintplatform/samples/customerservice/api/dto/CustomerSearchCriteria.java similarity index 100% rename from samples/customer-service/src/main/java/io/github/blueprintplatform/samples/customerservice/api/dto/CustomerSearchCriteria.java rename to samples/spring-boot-3/customer-service/src/main/java/io/github/blueprintplatform/samples/customerservice/api/dto/CustomerSearchCriteria.java diff --git a/samples/customer-service/src/main/java/io/github/blueprintplatform/samples/customerservice/api/dto/CustomerUpdateRequest.java b/samples/spring-boot-3/customer-service/src/main/java/io/github/blueprintplatform/samples/customerservice/api/dto/CustomerUpdateRequest.java similarity index 100% rename from samples/customer-service/src/main/java/io/github/blueprintplatform/samples/customerservice/api/dto/CustomerUpdateRequest.java rename to samples/spring-boot-3/customer-service/src/main/java/io/github/blueprintplatform/samples/customerservice/api/dto/CustomerUpdateRequest.java diff --git a/samples/customer-service/src/main/java/io/github/blueprintplatform/samples/customerservice/api/error/ApiRequestExceptionHandler.java b/samples/spring-boot-3/customer-service/src/main/java/io/github/blueprintplatform/samples/customerservice/api/error/ApiRequestExceptionHandler.java similarity index 100% rename from samples/customer-service/src/main/java/io/github/blueprintplatform/samples/customerservice/api/error/ApiRequestExceptionHandler.java rename to samples/spring-boot-3/customer-service/src/main/java/io/github/blueprintplatform/samples/customerservice/api/error/ApiRequestExceptionHandler.java diff --git a/samples/customer-service/src/main/java/io/github/blueprintplatform/samples/customerservice/api/error/ApplicationExceptionHandler.java b/samples/spring-boot-3/customer-service/src/main/java/io/github/blueprintplatform/samples/customerservice/api/error/ApplicationExceptionHandler.java similarity index 100% rename from samples/customer-service/src/main/java/io/github/blueprintplatform/samples/customerservice/api/error/ApplicationExceptionHandler.java rename to samples/spring-boot-3/customer-service/src/main/java/io/github/blueprintplatform/samples/customerservice/api/error/ApplicationExceptionHandler.java diff --git a/samples/customer-service/src/main/java/io/github/blueprintplatform/samples/customerservice/api/error/ProblemSupport.java b/samples/spring-boot-3/customer-service/src/main/java/io/github/blueprintplatform/samples/customerservice/api/error/ProblemSupport.java similarity index 100% rename from samples/customer-service/src/main/java/io/github/blueprintplatform/samples/customerservice/api/error/ProblemSupport.java rename to samples/spring-boot-3/customer-service/src/main/java/io/github/blueprintplatform/samples/customerservice/api/error/ProblemSupport.java diff --git a/samples/customer-service/src/main/java/io/github/blueprintplatform/samples/customerservice/common/api/ApiConstants.java b/samples/spring-boot-3/customer-service/src/main/java/io/github/blueprintplatform/samples/customerservice/common/api/ApiConstants.java similarity index 100% rename from samples/customer-service/src/main/java/io/github/blueprintplatform/samples/customerservice/common/api/ApiConstants.java rename to samples/spring-boot-3/customer-service/src/main/java/io/github/blueprintplatform/samples/customerservice/common/api/ApiConstants.java diff --git a/samples/customer-service/src/main/java/io/github/blueprintplatform/samples/customerservice/common/api/sort/SortField.java b/samples/spring-boot-3/customer-service/src/main/java/io/github/blueprintplatform/samples/customerservice/common/api/sort/SortField.java similarity index 100% rename from samples/customer-service/src/main/java/io/github/blueprintplatform/samples/customerservice/common/api/sort/SortField.java rename to samples/spring-boot-3/customer-service/src/main/java/io/github/blueprintplatform/samples/customerservice/common/api/sort/SortField.java diff --git a/samples/customer-service/src/main/java/io/github/blueprintplatform/samples/customerservice/common/i18n/LocalizedMessageResolver.java b/samples/spring-boot-3/customer-service/src/main/java/io/github/blueprintplatform/samples/customerservice/common/i18n/LocalizedMessageResolver.java similarity index 100% rename from samples/customer-service/src/main/java/io/github/blueprintplatform/samples/customerservice/common/i18n/LocalizedMessageResolver.java rename to samples/spring-boot-3/customer-service/src/main/java/io/github/blueprintplatform/samples/customerservice/common/i18n/LocalizedMessageResolver.java diff --git a/samples/customer-service/src/main/java/io/github/blueprintplatform/samples/customerservice/common/i18n/impl/SpringLocalizedMessageResolver.java b/samples/spring-boot-3/customer-service/src/main/java/io/github/blueprintplatform/samples/customerservice/common/i18n/impl/SpringLocalizedMessageResolver.java similarity index 100% rename from samples/customer-service/src/main/java/io/github/blueprintplatform/samples/customerservice/common/i18n/impl/SpringLocalizedMessageResolver.java rename to samples/spring-boot-3/customer-service/src/main/java/io/github/blueprintplatform/samples/customerservice/common/i18n/impl/SpringLocalizedMessageResolver.java diff --git a/samples/customer-service/src/main/java/io/github/blueprintplatform/samples/customerservice/common/i18n/locale/CurrentLocaleProvider.java b/samples/spring-boot-3/customer-service/src/main/java/io/github/blueprintplatform/samples/customerservice/common/i18n/locale/CurrentLocaleProvider.java similarity index 100% rename from samples/customer-service/src/main/java/io/github/blueprintplatform/samples/customerservice/common/i18n/locale/CurrentLocaleProvider.java rename to samples/spring-boot-3/customer-service/src/main/java/io/github/blueprintplatform/samples/customerservice/common/i18n/locale/CurrentLocaleProvider.java diff --git a/samples/customer-service/src/main/java/io/github/blueprintplatform/samples/customerservice/common/i18n/locale/impl/DefaultLocaleProvider.java b/samples/spring-boot-3/customer-service/src/main/java/io/github/blueprintplatform/samples/customerservice/common/i18n/locale/impl/DefaultLocaleProvider.java similarity index 100% rename from samples/customer-service/src/main/java/io/github/blueprintplatform/samples/customerservice/common/i18n/locale/impl/DefaultLocaleProvider.java rename to samples/spring-boot-3/customer-service/src/main/java/io/github/blueprintplatform/samples/customerservice/common/i18n/locale/impl/DefaultLocaleProvider.java diff --git a/samples/customer-service/src/main/java/io/github/blueprintplatform/samples/customerservice/common/openapi/OpenApiConfig.java b/samples/spring-boot-3/customer-service/src/main/java/io/github/blueprintplatform/samples/customerservice/common/openapi/OpenApiConfig.java similarity index 100% rename from samples/customer-service/src/main/java/io/github/blueprintplatform/samples/customerservice/common/openapi/OpenApiConfig.java rename to samples/spring-boot-3/customer-service/src/main/java/io/github/blueprintplatform/samples/customerservice/common/openapi/OpenApiConfig.java diff --git a/samples/customer-service/src/main/java/io/github/blueprintplatform/samples/customerservice/common/openapi/OpenApiConstants.java b/samples/spring-boot-3/customer-service/src/main/java/io/github/blueprintplatform/samples/customerservice/common/openapi/OpenApiConstants.java similarity index 100% rename from samples/customer-service/src/main/java/io/github/blueprintplatform/samples/customerservice/common/openapi/OpenApiConstants.java rename to samples/spring-boot-3/customer-service/src/main/java/io/github/blueprintplatform/samples/customerservice/common/openapi/OpenApiConstants.java diff --git a/samples/spring-boot-3/customer-service/src/main/java/io/github/blueprintplatform/samples/customerservice/config/JacksonConfig.java b/samples/spring-boot-3/customer-service/src/main/java/io/github/blueprintplatform/samples/customerservice/config/JacksonConfig.java new file mode 100644 index 0000000..7c6c654 --- /dev/null +++ b/samples/spring-boot-3/customer-service/src/main/java/io/github/blueprintplatform/samples/customerservice/config/JacksonConfig.java @@ -0,0 +1,33 @@ +package io.github.blueprintplatform.samples.customerservice.config; + +import com.fasterxml.jackson.core.JsonGenerator; +import com.fasterxml.jackson.databind.JsonSerializer; +import com.fasterxml.jackson.databind.SerializerProvider; +import com.fasterxml.jackson.databind.module.SimpleModule; +import io.github.blueprintplatform.openapi.generics.contract.paging.SortDirection; +import java.io.IOException; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +@Configuration +public class JacksonConfig { + + @Bean + public SimpleModule sortDirectionModule() { + SimpleModule module = new SimpleModule(); + + module.addSerializer( + SortDirection.class, + new JsonSerializer() { + @Override + public void serialize( + SortDirection value, JsonGenerator gen, SerializerProvider serializers) + throws IOException { + + gen.writeString(value.value()); + } + }); + + return module; + } +} diff --git a/samples/customer-service/src/main/java/io/github/blueprintplatform/samples/customerservice/config/WebConfig.java b/samples/spring-boot-3/customer-service/src/main/java/io/github/blueprintplatform/samples/customerservice/config/WebConfig.java similarity index 100% rename from samples/customer-service/src/main/java/io/github/blueprintplatform/samples/customerservice/config/WebConfig.java rename to samples/spring-boot-3/customer-service/src/main/java/io/github/blueprintplatform/samples/customerservice/config/WebConfig.java diff --git a/samples/customer-service/src/main/java/io/github/blueprintplatform/samples/customerservice/service/CustomerService.java b/samples/spring-boot-3/customer-service/src/main/java/io/github/blueprintplatform/samples/customerservice/service/CustomerService.java similarity index 92% rename from samples/customer-service/src/main/java/io/github/blueprintplatform/samples/customerservice/service/CustomerService.java rename to samples/spring-boot-3/customer-service/src/main/java/io/github/blueprintplatform/samples/customerservice/service/CustomerService.java index 7ff4e7d..77f7b0d 100644 --- a/samples/customer-service/src/main/java/io/github/blueprintplatform/samples/customerservice/service/CustomerService.java +++ b/samples/spring-boot-3/customer-service/src/main/java/io/github/blueprintplatform/samples/customerservice/service/CustomerService.java @@ -1,9 +1,9 @@ package io.github.blueprintplatform.samples.customerservice.service; +import io.github.blueprintplatform.contracts.customer.CustomerDto; import io.github.blueprintplatform.openapi.generics.contract.paging.Page; import io.github.blueprintplatform.openapi.generics.contract.paging.SortDirection; import io.github.blueprintplatform.samples.customerservice.api.dto.CustomerCreateRequest; -import io.github.blueprintplatform.samples.customerservice.api.dto.CustomerDto; import io.github.blueprintplatform.samples.customerservice.api.dto.CustomerSearchCriteria; import io.github.blueprintplatform.samples.customerservice.api.dto.CustomerUpdateRequest; import io.github.blueprintplatform.samples.customerservice.common.api.sort.SortField; diff --git a/samples/customer-service/src/main/java/io/github/blueprintplatform/samples/customerservice/service/impl/CustomerServiceImpl.java b/samples/spring-boot-3/customer-service/src/main/java/io/github/blueprintplatform/samples/customerservice/service/impl/CustomerServiceImpl.java similarity index 98% rename from samples/customer-service/src/main/java/io/github/blueprintplatform/samples/customerservice/service/impl/CustomerServiceImpl.java rename to samples/spring-boot-3/customer-service/src/main/java/io/github/blueprintplatform/samples/customerservice/service/impl/CustomerServiceImpl.java index 6aa43bf..d3a7dd1 100644 --- a/samples/customer-service/src/main/java/io/github/blueprintplatform/samples/customerservice/service/impl/CustomerServiceImpl.java +++ b/samples/spring-boot-3/customer-service/src/main/java/io/github/blueprintplatform/samples/customerservice/service/impl/CustomerServiceImpl.java @@ -1,9 +1,9 @@ package io.github.blueprintplatform.samples.customerservice.service.impl; +import io.github.blueprintplatform.contracts.customer.CustomerDto; import io.github.blueprintplatform.openapi.generics.contract.paging.Page; import io.github.blueprintplatform.openapi.generics.contract.paging.SortDirection; import io.github.blueprintplatform.samples.customerservice.api.dto.CustomerCreateRequest; -import io.github.blueprintplatform.samples.customerservice.api.dto.CustomerDto; import io.github.blueprintplatform.samples.customerservice.api.dto.CustomerSearchCriteria; import io.github.blueprintplatform.samples.customerservice.api.dto.CustomerUpdateRequest; import io.github.blueprintplatform.samples.customerservice.common.api.sort.SortField; diff --git a/samples/customer-service/src/main/resources/application.yml b/samples/spring-boot-3/customer-service/src/main/resources/application.yml similarity index 100% rename from samples/customer-service/src/main/resources/application.yml rename to samples/spring-boot-3/customer-service/src/main/resources/application.yml diff --git a/samples/customer-service/src/main/resources/messages.properties b/samples/spring-boot-3/customer-service/src/main/resources/messages.properties similarity index 100% rename from samples/customer-service/src/main/resources/messages.properties rename to samples/spring-boot-3/customer-service/src/main/resources/messages.properties diff --git a/samples/customer-service/src/test/java/io/github/blueprintplatform/samples/customerservice/CustomerServiceApplicationIT.java b/samples/spring-boot-3/customer-service/src/test/java/io/github/blueprintplatform/samples/customerservice/CustomerServiceApplicationIT.java similarity index 100% rename from samples/customer-service/src/test/java/io/github/blueprintplatform/samples/customerservice/CustomerServiceApplicationIT.java rename to samples/spring-boot-3/customer-service/src/test/java/io/github/blueprintplatform/samples/customerservice/CustomerServiceApplicationIT.java diff --git a/samples/customer-service/src/test/java/io/github/blueprintplatform/samples/customerservice/api/controller/CustomerControllerIT.java b/samples/spring-boot-3/customer-service/src/test/java/io/github/blueprintplatform/samples/customerservice/api/controller/CustomerControllerIT.java similarity index 81% rename from samples/customer-service/src/test/java/io/github/blueprintplatform/samples/customerservice/api/controller/CustomerControllerIT.java rename to samples/spring-boot-3/customer-service/src/test/java/io/github/blueprintplatform/samples/customerservice/api/controller/CustomerControllerIT.java index 9db5d3c..90f4d9a 100644 --- a/samples/customer-service/src/test/java/io/github/blueprintplatform/samples/customerservice/api/controller/CustomerControllerIT.java +++ b/samples/spring-boot-3/customer-service/src/test/java/io/github/blueprintplatform/samples/customerservice/api/controller/CustomerControllerIT.java @@ -12,15 +12,16 @@ import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; import com.fasterxml.jackson.databind.ObjectMapper; +import io.github.blueprintplatform.contracts.customer.CustomerDto; import io.github.blueprintplatform.openapi.generics.contract.paging.Page; import io.github.blueprintplatform.openapi.generics.contract.paging.SortDirection; import io.github.blueprintplatform.samples.customerservice.api.dto.CustomerCreateRequest; -import io.github.blueprintplatform.samples.customerservice.api.dto.CustomerDto; import io.github.blueprintplatform.samples.customerservice.api.dto.CustomerSearchCriteria; import io.github.blueprintplatform.samples.customerservice.api.dto.CustomerUpdateRequest; import io.github.blueprintplatform.samples.customerservice.api.error.ApiRequestExceptionHandler; import io.github.blueprintplatform.samples.customerservice.api.error.ApplicationExceptionHandler; import io.github.blueprintplatform.samples.customerservice.common.api.sort.SortField; +import io.github.blueprintplatform.samples.customerservice.config.JacksonConfig; import io.github.blueprintplatform.samples.customerservice.service.CustomerService; import io.github.blueprintplatform.samples.customerservice.testconfig.TestControllerMocksConfig; import java.util.List; @@ -40,7 +41,8 @@ @Import({ ApiRequestExceptionHandler.class, ApplicationExceptionHandler.class, - TestControllerMocksConfig.class + TestControllerMocksConfig.class, + JacksonConfig.class }) @Tag("integration") class CustomerControllerIT { @@ -50,18 +52,18 @@ class CustomerControllerIT { @Autowired private CustomerService customerService; @Test - @DisplayName("POST /v1/customers -> 201 Created, Location header ve ServiceResponse(data, meta)") + @DisplayName("POST /customers -> 201 Created, Location header ve ServiceResponse(data, meta)") void createCustomer_created201_withLocation() throws Exception { var req = new CustomerCreateRequest("John Smith", "john.smith@example.com"); var dto = new CustomerDto(1, req.name(), req.email()); when(customerService.createCustomer(any(CustomerCreateRequest.class))).thenReturn(dto); mvc.perform( - post("/v1/customers") + post("/customers") .contentType(MediaType.APPLICATION_JSON) .content(om.writeValueAsBytes(req))) .andExpect(status().isCreated()) - .andExpect(header().string("Location", endsWith("/v1/customers/1"))) + .andExpect(header().string("Location", endsWith("/customers/1"))) .andExpect(content().contentTypeCompatibleWith(MediaType.APPLICATION_JSON)) .andExpect(jsonPath("$.data.customerId").value(1)) .andExpect(jsonPath("$.data.name").value("John Smith")) @@ -71,21 +73,21 @@ void createCustomer_created201_withLocation() throws Exception { } @Test - @DisplayName("POST /v1/customers -> 400 validation error (MethodArgumentNotValid)") + @DisplayName("POST /customers -> 400 validation error (MethodArgumentNotValid)") void createCustomer_validationError_methodArgumentNotValid() throws Exception { var badJson = """ {"name":"","email":"not-an-email"} """; - mvc.perform(post("/v1/customers").contentType(MediaType.APPLICATION_JSON).content(badJson)) + mvc.perform(post("/customers").contentType(MediaType.APPLICATION_JSON).content(badJson)) .andExpect(status().isBadRequest()) .andExpect(content().contentTypeCompatibleWith(MediaType.APPLICATION_PROBLEM_JSON)) .andExpect(jsonPath("$.type").value("urn:customer-service:problem:validation-failed")) .andExpect(jsonPath("$.title").value("Validation failed")) .andExpect(jsonPath("$.status").value(400)) .andExpect(jsonPath("$.detail").value("One or more fields are invalid.")) - .andExpect(jsonPath("$.instance").value("/v1/customers")) + .andExpect(jsonPath("$.instance").value("/customers")) .andExpect(jsonPath("$.errorCode").value("VALIDATION_FAILED")) .andExpect(jsonPath("$.extensions.errors").isArray()) .andExpect(jsonPath("$.extensions.errors.length()").value(3)) @@ -101,30 +103,30 @@ void createCustomer_validationError_methodArgumentNotValid() throws Exception { } @Test - @DisplayName("POST /v1/customers -> 400 invalid JSON (HttpMessageNotReadable)") + @DisplayName("POST /customers -> 400 invalid JSON (HttpMessageNotReadable)") void createCustomer_badJson_notReadable() throws Exception { var malformed = "{ \"name\": \"John\", \"email\": }"; - mvc.perform(post("/v1/customers").contentType(MediaType.APPLICATION_JSON).content(malformed)) + mvc.perform(post("/customers").contentType(MediaType.APPLICATION_JSON).content(malformed)) .andExpect(status().isBadRequest()) .andExpect(content().contentTypeCompatibleWith(MediaType.APPLICATION_PROBLEM_JSON)) .andExpect(jsonPath("$.type").value("urn:customer-service:problem:bad-request")) .andExpect(jsonPath("$.title").value("Bad request")) .andExpect(jsonPath("$.status").value(400)) .andExpect(jsonPath("$.detail").value("Malformed request body.")) - .andExpect(jsonPath("$.instance").value("/v1/customers")) + .andExpect(jsonPath("$.instance").value("/customers")) .andExpect(jsonPath("$.errorCode").value("BAD_REQUEST")) .andExpect(jsonPath("$.extensions.errors[0].code").value("BAD_REQUEST")) .andExpect(jsonPath("$.extensions.errors[0].message").value("Invalid JSON payload.")); } @Test - @DisplayName("GET /v1/customers/{id} -> 200 OK (one customer)") + @DisplayName("GET /customers/{id} -> 200 OK (one customer)") void getCustomer_ok200() throws Exception { var dto = new CustomerDto(1, "John Smith", "john.smith@example.com"); when(customerService.getCustomer(1)).thenReturn(dto); - mvc.perform(get("/v1/customers/{id}", 1)) + mvc.perform(get("/customers/{id}", 1)) .andExpect(status().isOk()) .andExpect(content().contentTypeCompatibleWith(MediaType.APPLICATION_JSON)) .andExpect(jsonPath("$.data.customerId").value(1)) @@ -135,19 +137,19 @@ void getCustomer_ok200() throws Exception { } @Test - @DisplayName("GET /v1/customers/{id} -> 404 NOT_FOUND (NoSuchElementException)") + @DisplayName("GET /customers/{id} -> 404 NOT_FOUND (NoSuchElementException)") void getCustomer_notFound404() throws Exception { when(customerService.getCustomer(99)) .thenThrow(new NoSuchElementException("Customer not found: 99")); - mvc.perform(get("/v1/customers/{id}", 99)) + mvc.perform(get("/customers/{id}", 99)) .andExpect(status().isNotFound()) .andExpect(content().contentTypeCompatibleWith(MediaType.APPLICATION_PROBLEM_JSON)) .andExpect(jsonPath("$.type").value("urn:customer-service:problem:not-found")) .andExpect(jsonPath("$.title").value("Resource not found")) .andExpect(jsonPath("$.status").value(404)) .andExpect(jsonPath("$.detail").value("Requested resource was not found.")) - .andExpect(jsonPath("$.instance").value("/v1/customers/99")) + .andExpect(jsonPath("$.instance").value("/customers/99")) .andExpect(jsonPath("$.errorCode").value("NOT_FOUND")) .andExpect(jsonPath("$.extensions.errors[0].code").value("NOT_FOUND")) .andExpect(jsonPath("$.extensions.errors[0].message").value("Customer not found: 99")) @@ -155,18 +157,18 @@ void getCustomer_notFound404() throws Exception { } @Test - @DisplayName("GET /v1/customers/{id} -> 404 NOT_FOUND fallback message") + @DisplayName("GET /customers/{id} -> 404 NOT_FOUND fallback message") void getCustomer_notFound404_fallbackMessage() throws Exception { when(customerService.getCustomer(77)).thenThrow(new NoSuchElementException("")); - mvc.perform(get("/v1/customers/{id}", 77)) + mvc.perform(get("/customers/{id}", 77)) .andExpect(status().isNotFound()) .andExpect(content().contentTypeCompatibleWith(MediaType.APPLICATION_PROBLEM_JSON)) .andExpect(jsonPath("$.type").value("urn:customer-service:problem:not-found")) .andExpect(jsonPath("$.title").value("Resource not found")) .andExpect(jsonPath("$.status").value(404)) .andExpect(jsonPath("$.detail").value("Requested resource was not found.")) - .andExpect(jsonPath("$.instance").value("/v1/customers/77")) + .andExpect(jsonPath("$.instance").value("/customers/77")) .andExpect(jsonPath("$.errorCode").value("NOT_FOUND")) .andExpect(jsonPath("$.extensions.errors[0].code").value("NOT_FOUND")) .andExpect(jsonPath("$.extensions.errors[0].message").value("Resource not found.")) @@ -174,16 +176,16 @@ void getCustomer_notFound404_fallbackMessage() throws Exception { } @Test - @DisplayName("GET /v1/customers/{id} -> 400 BAD_REQUEST (type mismatch)") + @DisplayName("GET /customers/{id} -> 400 BAD_REQUEST (type mismatch)") void getCustomer_typeMismatch400() throws Exception { - mvc.perform(get("/v1/customers/{id}", "abc")) + mvc.perform(get("/customers/{id}", "abc")) .andExpect(status().isBadRequest()) .andExpect(content().contentTypeCompatibleWith(MediaType.APPLICATION_PROBLEM_JSON)) .andExpect(jsonPath("$.type").value("urn:customer-service:problem:bad-request")) .andExpect(jsonPath("$.title").value("Bad request")) .andExpect(jsonPath("$.status").value(400)) .andExpect(jsonPath("$.detail").value("One or more parameters are invalid.")) - .andExpect(jsonPath("$.instance").value("/v1/customers/abc")) + .andExpect(jsonPath("$.instance").value("/customers/abc")) .andExpect(jsonPath("$.errorCode").value("BAD_REQUEST")) .andExpect(jsonPath("$.extensions.errors[0].code").value("BAD_REQUEST")) .andExpect( @@ -192,33 +194,33 @@ void getCustomer_typeMismatch400() throws Exception { } @Test - @DisplayName("GET /v1/customers/{id} -> 400 validation error (@Min violation)") + @DisplayName("GET /customers/{id} -> 400 validation error (@Min violation)") void getCustomer_constraintViolation_min() throws Exception { - mvc.perform(get("/v1/customers/{id}", 0)) + mvc.perform(get("/customers/{id}", 0)) .andExpect(status().isBadRequest()) .andExpect(content().contentTypeCompatibleWith(MediaType.APPLICATION_PROBLEM_JSON)) .andExpect(jsonPath("$.type").value("urn:customer-service:problem:validation-failed")) .andExpect(jsonPath("$.title").value("Validation failed")) .andExpect(jsonPath("$.status").value(400)) .andExpect(jsonPath("$.detail").value("One or more fields are invalid.")) - .andExpect(jsonPath("$.instance").value("/v1/customers/0")) + .andExpect(jsonPath("$.instance").value("/customers/0")) .andExpect(jsonPath("$.errorCode").value("VALIDATION_FAILED")) .andExpect(jsonPath("$.extensions.errors[0].code").value("VALIDATION_FAILED")); } @Test - @DisplayName("GET /v1/customers/{id} -> 500 Internal Server Error (RFC 9457 ProblemDetail)") + @DisplayName("GET /customers/{id} -> 500 Internal Server Error (RFC 9457 ProblemDetail)") void getCustomer_internalServerError_generic() throws Exception { when(customerService.getCustomer(1)).thenThrow(new RuntimeException("Unexpected failure")); - mvc.perform(get("/v1/customers/{id}", 1)) + mvc.perform(get("/customers/{id}", 1)) .andExpect(status().isInternalServerError()) .andExpect(content().contentTypeCompatibleWith(MediaType.APPLICATION_PROBLEM_JSON)) .andExpect(jsonPath("$.type").value("urn:customer-service:problem:internal-error")) .andExpect(jsonPath("$.title").value("Internal server error")) .andExpect(jsonPath("$.status").value(500)) .andExpect(jsonPath("$.detail").value("Internal server error. Please try again later.")) - .andExpect(jsonPath("$.instance").value("/v1/customers/1")) + .andExpect(jsonPath("$.instance").value("/customers/1")) .andExpect(jsonPath("$.errorCode").value("INTERNAL_ERROR")) .andExpect(jsonPath("$.extensions.errors[0].code").value("INTERNAL_ERROR")) .andExpect( @@ -227,7 +229,7 @@ void getCustomer_internalServerError_generic() throws Exception { } @Test - @DisplayName("GET /v1/customers -> 200 OK, Page + default meta.sort") + @DisplayName("GET /customers -> 200 OK, Page + default meta.sort") void getCustomers_list200_defaultSort() throws Exception { var d1 = new CustomerDto(1, "John Smith", "john.smith@example.com"); var d2 = new CustomerDto(2, "Ahmet Yilmaz", "ahmet.yilmaz@example.com"); @@ -241,7 +243,7 @@ void getCustomers_list200_defaultSort() throws Exception { any(SortDirection.class))) .thenReturn(page); - mvc.perform(get("/v1/customers")) + mvc.perform(get("/customers")) .andExpect(status().isOk()) .andExpect(content().contentTypeCompatibleWith(MediaType.APPLICATION_JSON)) .andExpect(jsonPath("$.data.content.length()").value(2)) @@ -256,7 +258,7 @@ void getCustomers_list200_defaultSort() throws Exception { } @Test - @DisplayName("GET /v1/customers -> 200 OK, custom sort metadata") + @DisplayName("GET /customers -> 200 OK, custom sort metadata") void getCustomers_list200_customSort() throws Exception { var d1 = new CustomerDto(2, "Jane Doe", "jane.doe@example.com"); var d2 = new CustomerDto(1, "Ahmet Yilmaz", "ahmet.yilmaz@example.com"); @@ -270,7 +272,7 @@ void getCustomers_list200_customSort() throws Exception { any(SortDirection.class))) .thenReturn(page); - mvc.perform(get("/v1/customers").param("sortBy", "name").param("direction", "desc")) + mvc.perform(get("/customers").param("sortBy", "name").param("direction", "desc")) .andExpect(status().isOk()) .andExpect(content().contentTypeCompatibleWith(MediaType.APPLICATION_JSON)) .andExpect(jsonPath("$.data.content.length()").value(2)) @@ -280,16 +282,16 @@ void getCustomers_list200_customSort() throws Exception { } @Test - @DisplayName("GET /v1/customers -> 400 BAD_REQUEST (sortBy type mismatch)") + @DisplayName("GET /customers -> 400 BAD_REQUEST (sortBy type mismatch)") void getCustomers_sortBy_typeMismatch400() throws Exception { - mvc.perform(get("/v1/customers").param("sortBy", "foo")) + mvc.perform(get("/customers").param("sortBy", "foo")) .andExpect(status().isBadRequest()) .andExpect(content().contentTypeCompatibleWith(MediaType.APPLICATION_PROBLEM_JSON)) .andExpect(jsonPath("$.type").value("urn:customer-service:problem:bad-request")) .andExpect(jsonPath("$.title").value("Bad request")) .andExpect(jsonPath("$.status").value(400)) .andExpect(jsonPath("$.detail").value("One or more parameters are invalid.")) - .andExpect(jsonPath("$.instance").value("/v1/customers")) + .andExpect(jsonPath("$.instance").value("/customers")) .andExpect(jsonPath("$.errorCode").value("BAD_REQUEST")) .andExpect(jsonPath("$.extensions.errors[0].code").value("BAD_REQUEST")) .andExpect( @@ -298,59 +300,59 @@ void getCustomers_sortBy_typeMismatch400() throws Exception { } @Test - @DisplayName("GET /v1/customers -> 400 validation error (page @Min)") + @DisplayName("GET /customers -> 400 validation error (page @Min)") void getCustomers_page_validation_min() throws Exception { - mvc.perform(get("/v1/customers").param("page", "-1")) + mvc.perform(get("/customers").param("page", "-1")) .andExpect(status().isBadRequest()) .andExpect(content().contentTypeCompatibleWith(MediaType.APPLICATION_PROBLEM_JSON)) .andExpect(jsonPath("$.type").value("urn:customer-service:problem:validation-failed")) .andExpect(jsonPath("$.title").value("Validation failed")) .andExpect(jsonPath("$.status").value(400)) .andExpect(jsonPath("$.detail").value("One or more fields are invalid.")) - .andExpect(jsonPath("$.instance").value("/v1/customers")) + .andExpect(jsonPath("$.instance").value("/customers")) .andExpect(jsonPath("$.errorCode").value("VALIDATION_FAILED")) .andExpect(jsonPath("$.extensions.errors[0].code").value("VALIDATION_FAILED")); } @Test - @DisplayName("GET /v1/customers -> 400 validation error (size @Min)") + @DisplayName("GET /customers -> 400 validation error (size @Min)") void getCustomers_size_validation_min() throws Exception { - mvc.perform(get("/v1/customers").param("size", "0")) + mvc.perform(get("/customers").param("size", "0")) .andExpect(status().isBadRequest()) .andExpect(content().contentTypeCompatibleWith(MediaType.APPLICATION_PROBLEM_JSON)) .andExpect(jsonPath("$.type").value("urn:customer-service:problem:validation-failed")) .andExpect(jsonPath("$.title").value("Validation failed")) .andExpect(jsonPath("$.status").value(400)) .andExpect(jsonPath("$.detail").value("One or more fields are invalid.")) - .andExpect(jsonPath("$.instance").value("/v1/customers")) + .andExpect(jsonPath("$.instance").value("/customers")) .andExpect(jsonPath("$.errorCode").value("VALIDATION_FAILED")) .andExpect(jsonPath("$.extensions.errors[0].code").value("VALIDATION_FAILED")); } @Test - @DisplayName("GET /v1/customers -> 400 validation error (size @Max)") + @DisplayName("GET /customers -> 400 validation error (size @Max)") void getCustomers_size_validation_max() throws Exception { - mvc.perform(get("/v1/customers").param("size", "11")) + mvc.perform(get("/customers").param("size", "11")) .andExpect(status().isBadRequest()) .andExpect(content().contentTypeCompatibleWith(MediaType.APPLICATION_PROBLEM_JSON)) .andExpect(jsonPath("$.type").value("urn:customer-service:problem:validation-failed")) .andExpect(jsonPath("$.title").value("Validation failed")) .andExpect(jsonPath("$.status").value(400)) .andExpect(jsonPath("$.detail").value("One or more fields are invalid.")) - .andExpect(jsonPath("$.instance").value("/v1/customers")) + .andExpect(jsonPath("$.instance").value("/customers")) .andExpect(jsonPath("$.errorCode").value("VALIDATION_FAILED")) .andExpect(jsonPath("$.extensions.errors[0].code").value("VALIDATION_FAILED")); } @Test - @DisplayName("PUT /v1/customers/{id} -> 200 OK (update)") + @DisplayName("PUT /customers/{id} -> 200 OK (update)") void updateCustomer_ok200() throws Exception { var req = new CustomerUpdateRequest("Jane Doe", "jane.doe@example.com"); var updated = new CustomerDto(1, req.name(), req.email()); when(customerService.updateCustomer(1, req)).thenReturn(updated); mvc.perform( - put("/v1/customers/{id}", 1) + put("/customers/{id}", 1) .contentType(MediaType.APPLICATION_JSON) .content(om.writeValueAsBytes(req))) .andExpect(status().isOk()) @@ -363,22 +365,21 @@ void updateCustomer_ok200() throws Exception { } @Test - @DisplayName("PUT /v1/customers/{id} -> 400 validation error (MethodArgumentNotValid)") + @DisplayName("PUT /customers/{id} -> 400 validation error (MethodArgumentNotValid)") void updateCustomer_validationError_methodArgumentNotValid() throws Exception { var badJson = """ {"name":"","email":"bad-email"} """; - mvc.perform( - put("/v1/customers/{id}", 1).contentType(MediaType.APPLICATION_JSON).content(badJson)) + mvc.perform(put("/customers/{id}", 1).contentType(MediaType.APPLICATION_JSON).content(badJson)) .andExpect(status().isBadRequest()) .andExpect(content().contentTypeCompatibleWith(MediaType.APPLICATION_PROBLEM_JSON)) .andExpect(jsonPath("$.type").value("urn:customer-service:problem:validation-failed")) .andExpect(jsonPath("$.title").value("Validation failed")) .andExpect(jsonPath("$.status").value(400)) .andExpect(jsonPath("$.detail").value("One or more fields are invalid.")) - .andExpect(jsonPath("$.instance").value("/v1/customers/1")) + .andExpect(jsonPath("$.instance").value("/customers/1")) .andExpect(jsonPath("$.errorCode").value("VALIDATION_FAILED")) .andExpect(jsonPath("$.extensions.errors").isArray()) .andExpect(jsonPath("$.extensions.errors.length()").value(3)) @@ -386,22 +387,19 @@ void updateCustomer_validationError_methodArgumentNotValid() throws Exception { } @Test - @DisplayName("DELETE /v1/customers/{id} -> 200 OK (delete)") - void deleteCustomer_ok200() throws Exception { + @DisplayName("DELETE /customers/{id} -> 204 No Content") + void deleteCustomer_noContent204() throws Exception { doNothing().when(customerService).deleteCustomer(1); - mvc.perform(delete("/v1/customers/{id}", 1)) - .andExpect(status().isOk()) - .andExpect(content().contentTypeCompatibleWith(MediaType.APPLICATION_JSON)) - .andExpect(jsonPath("$.data.customerId").value(1)) - .andExpect(jsonPath("$.meta.serverTime").exists()) - .andExpect(jsonPath("$.meta.sort").isArray()); + mvc.perform(delete("/customers/{id}", 1)) + .andExpect(status().isNoContent()) + .andExpect(content().string("")); } @Test - @DisplayName("DELETE /v1/customers -> 405 method not allowed") + @DisplayName("DELETE /customers -> 405 method not allowed") void collectionDelete_methodNotAllowed405() throws Exception { - mvc.perform(delete("/v1/customers")) + mvc.perform(delete("/customers")) .andExpect(status().isMethodNotAllowed()) .andExpect(content().contentTypeCompatibleWith(MediaType.APPLICATION_PROBLEM_JSON)) .andExpect(jsonPath("$.type").value("urn:customer-service:problem:method-not-allowed")) @@ -409,7 +407,7 @@ void collectionDelete_methodNotAllowed405() throws Exception { .andExpect(jsonPath("$.status").value(405)) .andExpect( jsonPath("$.detail").value("The request method is not supported for this resource.")) - .andExpect(jsonPath("$.instance").value("/v1/customers")) + .andExpect(jsonPath("$.instance").value("/customers")) .andExpect(jsonPath("$.errorCode").value("METHOD_NOT_ALLOWED")) .andExpect(jsonPath("$.extensions.errors[0].code").value("METHOD_NOT_ALLOWED")) .andExpect( diff --git a/samples/customer-service/src/test/java/io/github/blueprintplatform/samples/customerservice/api/controller/CustomerControllerTest.java b/samples/spring-boot-3/customer-service/src/test/java/io/github/blueprintplatform/samples/customerservice/api/controller/CustomerControllerTest.java similarity index 83% rename from samples/customer-service/src/test/java/io/github/blueprintplatform/samples/customerservice/api/controller/CustomerControllerTest.java rename to samples/spring-boot-3/customer-service/src/test/java/io/github/blueprintplatform/samples/customerservice/api/controller/CustomerControllerTest.java index 754c908..a5a8b05 100644 --- a/samples/customer-service/src/test/java/io/github/blueprintplatform/samples/customerservice/api/controller/CustomerControllerTest.java +++ b/samples/spring-boot-3/customer-service/src/test/java/io/github/blueprintplatform/samples/customerservice/api/controller/CustomerControllerTest.java @@ -3,14 +3,13 @@ import static org.junit.jupiter.api.Assertions.*; import static org.mockito.Mockito.*; +import io.github.blueprintplatform.contracts.customer.CustomerDto; import io.github.blueprintplatform.openapi.generics.contract.envelope.Meta; import io.github.blueprintplatform.openapi.generics.contract.envelope.ServiceResponse; import io.github.blueprintplatform.openapi.generics.contract.paging.Page; import io.github.blueprintplatform.openapi.generics.contract.paging.Sort; import io.github.blueprintplatform.openapi.generics.contract.paging.SortDirection; import io.github.blueprintplatform.samples.customerservice.api.dto.CustomerCreateRequest; -import io.github.blueprintplatform.samples.customerservice.api.dto.CustomerDeleteResponse; -import io.github.blueprintplatform.samples.customerservice.api.dto.CustomerDto; import io.github.blueprintplatform.samples.customerservice.api.dto.CustomerSearchCriteria; import io.github.blueprintplatform.samples.customerservice.api.dto.CustomerUpdateRequest; import io.github.blueprintplatform.samples.customerservice.common.api.sort.SortField; @@ -46,7 +45,7 @@ void setUp() { MockHttpServletRequest request = new MockHttpServletRequest(); request.setMethod("POST"); - request.setRequestURI("/v1/customers"); + request.setRequestURI("/customers"); RequestContextHolder.setRequestAttributes(new ServletRequestAttributes(request)); } @@ -56,7 +55,7 @@ void tearDown() { } @Test - @DisplayName("POST /v1/customers -> 201 Created + ServiceResponse(data, meta)") + @DisplayName("POST /customers -> 201 Created + ServiceResponse(data, meta)") void createCustomer_shouldReturnCreated() { var req = new CustomerCreateRequest("John Smith", "john.smith@example.com"); when(customerService.createCustomer(req)).thenReturn(dto1); @@ -77,7 +76,7 @@ void createCustomer_shouldReturnCreated() { } @Test - @DisplayName("GET /v1/customers/{id} -> 200 OK + ServiceResponse(data, meta)") + @DisplayName("GET /customers/{id} -> 200 OK + ServiceResponse(data, meta)") void getCustomer_shouldReturnOk() { when(customerService.getCustomer(1)).thenReturn(dto1); @@ -96,7 +95,7 @@ void getCustomer_shouldReturnOk() { } @Test - @DisplayName("GET /v1/customers -> 200 OK + Page + Meta.sort") + @DisplayName("GET /customers -> 200 OK + Page + Meta.sort") void getCustomers_shouldReturnPaged() { var page = Page.of(List.of(dto1, dto2), 0, 5, 2); var criteria = new CustomerSearchCriteria(null, null); @@ -138,7 +137,7 @@ void getCustomers_shouldReturnPaged() { } @Test - @DisplayName("PUT /v1/customers/{id} -> 200 OK + ServiceResponse(data, meta)") + @DisplayName("PUT /customers/{id} -> 200 OK + ServiceResponse(data, meta)") void updateCustomer_shouldReturnOk() { var req = new CustomerUpdateRequest("John Smith", "john.smith@example.com"); var updated = new CustomerDto(1, req.name(), req.email()); @@ -159,23 +158,16 @@ void updateCustomer_shouldReturnOk() { } @Test - @DisplayName("DELETE /v1/customers/{id} -> 200 OK + ServiceResponse(CustomerDeleteResponse)") - void deleteCustomer_shouldReturnOk() { + @DisplayName("DELETE /customers/{id} -> 204 No Content") + void deleteCustomer_shouldReturnNoContent() { doNothing().when(customerService).deleteCustomer(1); - ResponseEntity> resp = controller.deleteCustomer(1); + ResponseEntity resp = controller.deleteCustomer(1); - assertEquals(HttpStatus.OK, resp.getStatusCode()); - - var body = resp.getBody(); - assertNotNull(body); - - assertNotNull(body.getData()); - assertEquals(1, body.getData().customerId()); - - assertNotNull(body.getMeta()); - assertNotNull(body.getMeta().serverTime()); + assertEquals(HttpStatus.NO_CONTENT, resp.getStatusCode()); + assertNull(resp.getBody()); verify(customerService).deleteCustomer(1); } + } diff --git a/samples/customer-service/src/test/java/io/github/blueprintplatform/samples/customerservice/api/dto/CustomerCreateRequestValidationTest.java b/samples/spring-boot-3/customer-service/src/test/java/io/github/blueprintplatform/samples/customerservice/api/dto/CustomerCreateRequestValidationTest.java similarity index 100% rename from samples/customer-service/src/test/java/io/github/blueprintplatform/samples/customerservice/api/dto/CustomerCreateRequestValidationTest.java rename to samples/spring-boot-3/customer-service/src/test/java/io/github/blueprintplatform/samples/customerservice/api/dto/CustomerCreateRequestValidationTest.java diff --git a/samples/customer-service/src/test/java/io/github/blueprintplatform/samples/customerservice/api/dto/CustomerUpdateRequestValidationTest.java b/samples/spring-boot-3/customer-service/src/test/java/io/github/blueprintplatform/samples/customerservice/api/dto/CustomerUpdateRequestValidationTest.java similarity index 100% rename from samples/customer-service/src/test/java/io/github/blueprintplatform/samples/customerservice/api/dto/CustomerUpdateRequestValidationTest.java rename to samples/spring-boot-3/customer-service/src/test/java/io/github/blueprintplatform/samples/customerservice/api/dto/CustomerUpdateRequestValidationTest.java diff --git a/samples/customer-service/src/test/java/io/github/blueprintplatform/samples/customerservice/common/api/response/PageTest.java b/samples/spring-boot-3/customer-service/src/test/java/io/github/blueprintplatform/samples/customerservice/common/api/response/PageTest.java similarity index 100% rename from samples/customer-service/src/test/java/io/github/blueprintplatform/samples/customerservice/common/api/response/PageTest.java rename to samples/spring-boot-3/customer-service/src/test/java/io/github/blueprintplatform/samples/customerservice/common/api/response/PageTest.java diff --git a/samples/customer-service/src/test/java/io/github/blueprintplatform/samples/customerservice/service/impl/CustomerServiceImplTest.java b/samples/spring-boot-3/customer-service/src/test/java/io/github/blueprintplatform/samples/customerservice/service/impl/CustomerServiceImplTest.java similarity index 98% rename from samples/customer-service/src/test/java/io/github/blueprintplatform/samples/customerservice/service/impl/CustomerServiceImplTest.java rename to samples/spring-boot-3/customer-service/src/test/java/io/github/blueprintplatform/samples/customerservice/service/impl/CustomerServiceImplTest.java index 04ecb79..ce3119c 100644 --- a/samples/customer-service/src/test/java/io/github/blueprintplatform/samples/customerservice/service/impl/CustomerServiceImplTest.java +++ b/samples/spring-boot-3/customer-service/src/test/java/io/github/blueprintplatform/samples/customerservice/service/impl/CustomerServiceImplTest.java @@ -2,10 +2,10 @@ import static org.junit.jupiter.api.Assertions.*; +import io.github.blueprintplatform.contracts.customer.CustomerDto; import io.github.blueprintplatform.openapi.generics.contract.paging.Page; import io.github.blueprintplatform.openapi.generics.contract.paging.SortDirection; import io.github.blueprintplatform.samples.customerservice.api.dto.CustomerCreateRequest; -import io.github.blueprintplatform.samples.customerservice.api.dto.CustomerDto; import io.github.blueprintplatform.samples.customerservice.api.dto.CustomerSearchCriteria; import io.github.blueprintplatform.samples.customerservice.api.dto.CustomerUpdateRequest; import io.github.blueprintplatform.samples.customerservice.common.api.sort.SortField; diff --git a/samples/customer-service/src/test/java/io/github/blueprintplatform/samples/customerservice/testconfig/TestControllerMocksConfig.java b/samples/spring-boot-3/customer-service/src/test/java/io/github/blueprintplatform/samples/customerservice/testconfig/TestControllerMocksConfig.java similarity index 100% rename from samples/customer-service/src/test/java/io/github/blueprintplatform/samples/customerservice/testconfig/TestControllerMocksConfig.java rename to samples/spring-boot-3/customer-service/src/test/java/io/github/blueprintplatform/samples/customerservice/testconfig/TestControllerMocksConfig.java diff --git a/samples/spring-boot-3/docker-compose.yml b/samples/spring-boot-3/docker-compose.yml new file mode 100644 index 0000000..c70f4ac --- /dev/null +++ b/samples/spring-boot-3/docker-compose.yml @@ -0,0 +1,31 @@ +services: + customer-service: + container_name: customer-service-sb3 + build: + context: ./customer-service + dockerfile: Dockerfile + image: customer-service-sb3:latest + restart: on-failure + environment: + APP_PORT: ${APP_PORT:-8084} + SPRING_PROFILES_ACTIVE: ${SPRING_PROFILES_ACTIVE:-local} + JAVA_OPTS: ${JAVA_OPTS:-} + ports: + - "${APP_PORT:-8084}:${APP_PORT:-8084}" + + customer-service-consumer: + container_name: customer-service-consumer-sb3 + build: + context: ./customer-service-consumer + dockerfile: Dockerfile + image: customer-service-consumer-sb3:latest + restart: on-failure + depends_on: + - customer-service + environment: + APP_PORT: ${CONSUMER_APP_PORT:-8085} + SPRING_PROFILES_ACTIVE: ${SPRING_PROFILES_ACTIVE:-local} + CUSTOMER_API_BASE_URL: http://customer-service-sb3:8084/customer-service + JAVA_OPTS: ${JAVA_OPTS:-} + ports: + - "${CONSUMER_APP_PORT:-8085}:${CONSUMER_APP_PORT:-8085}" \ No newline at end of file diff --git a/samples/spring-boot-4/.env b/samples/spring-boot-4/.env new file mode 100644 index 0000000..c8832fb --- /dev/null +++ b/samples/spring-boot-4/.env @@ -0,0 +1,4 @@ +APP_PORT=8094 +CONSUMER_APP_PORT=8095 +SPRING_PROFILES_ACTIVE=local +JAVA_OPTS= \ No newline at end of file diff --git a/samples/customer-service-client/README.md b/samples/spring-boot-4/customer-service-client/README.md similarity index 82% rename from samples/customer-service-client/README.md rename to samples/spring-boot-4/customer-service-client/README.md index 86cf087..4fd76ba 100644 --- a/samples/customer-service-client/README.md +++ b/samples/spring-boot-4/customer-service-client/README.md @@ -3,9 +3,9 @@ > **Reference integration: generating and using a contract-aligned, generics-aware OpenAPI client in a Spring Boot application** [![Java 21](https://img.shields.io/badge/Java-21-red?logo=openjdk)](https://openjdk.org/projects/jdk/21/) -[![Spring Boot](https://img.shields.io/badge/Spring%20Boot-3.5.13-green?logo=springboot)](https://spring.io/projects/spring-boot) -[![OpenAPI Generator](https://img.shields.io/badge/OpenAPI%20Generator-7.21.0-blue?logo=openapiinitiative)](https://openapi-generator.tech/) -[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](../../LICENSE) +[![Spring Boot](https://img.shields.io/badge/Spring%20Boot-4.x-green?logo=springboot)](https://spring.io/projects/spring-boot) +[![OpenAPI Generator](https://img.shields.io/badge/OpenAPI%20Generator-7.x-blue?logo=openapiinitiative)](https://openapi-generator.tech/) +[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](../../../LICENSE) --- @@ -22,7 +22,7 @@ --- > This is a minimal reference implementation. -> See the [Adoption Guides](../../docs/adoption/client-side-adoption.md) for rules, constraints, and architecture. +> See the [Adoption Guides](../../../docs/adoption/client-side-adoption.md) for rules, constraints, and architecture. ## πŸš€ TL;DR (Start Here) @@ -32,7 +32,7 @@ io.github.blueprintplatform openapi-generics-java-codegen-parent - 0.8.2 + 0.9.0 ``` @@ -131,9 +131,9 @@ Fallbacks handled: ## πŸ”— Related Modules -* **[Contract](../../openapi-generics-contract/README.md)** -* **[Server Starter](../../openapi-generics-server-starter/README.md)** -* **[Client Codegen](../../openapi-generics-java-codegen-parent/README.md)** +* **[Contract](../../../openapi-generics-contract/README.md)** +* **[Server Starter](../../../openapi-generics-server-starter/README.md)** +* **[Client Codegen](../../../openapi-generics-java-codegen-parent/README.md)** * **[Server Sample](../customer-service/README.md)** --- diff --git a/samples/spring-boot-4/customer-service-client/pom.xml b/samples/spring-boot-4/customer-service-client/pom.xml new file mode 100644 index 0000000..39f267b --- /dev/null +++ b/samples/spring-boot-4/customer-service-client/pom.xml @@ -0,0 +1,305 @@ + + + 4.0.0 + + + io.github.blueprint-platform + openapi-generics-java-codegen-parent + 0.9.0 + + + + io.github.blueprint-platform.samples + customer-service-client-sb4 + customer-service-client-sb4 + Generated client (RestClient) using generics-aware OpenAPI templates + jar + https://github.com/blueprint-platform/openapi-generics + + + + MIT License + https://opensource.org/licenses/MIT + repo + + + + + https://github.com/blueprint-platform/openapi-generics + scm:git:https://github.com/blueprint-platform/openapi-generics.git + scm:git:ssh://git@github.com:bsayli/spring-boot-openapi-generics-clients.git + + v0.9.0 + + + + + bsayli + Baris Sayli + https://github.com/bsayli + + + + + UTF-8 + UTF-8 + + 21 + 4.0.5 + 7.21.0 + 0.9.0 + 3.1.1 + 3.0.0 + 5.3.2 + 5.5.2 + 0.8.14 + 3.15.0 + 3.9.0 + 3.5.5 + 3.5.5 + 2.0.17 + 1.5.32 + + false + + + + + + io.github.blueprint-platform.samples + customer-contract + ${customer-contract.version} + + + + org.springframework.boot + spring-boot-starter-web + ${spring-boot.version} + provided + + + + org.springframework.boot + spring-boot-starter + ${spring-boot.version} + provided + + + + org.springframework.boot + spring-boot-starter-restclient + ${spring-boot.version} + provided + + + + jakarta.validation + jakarta.validation-api + ${jakarta.validation.version} + provided + + + + jakarta.annotation + jakarta.annotation-api + ${jakarta.annotation-api.version} + provided + + + + org.apache.httpcomponents.client5 + httpclient5 + ${httpclient5.version} + + + + org.springframework.boot + spring-boot-starter-test + ${spring-boot.version} + test + + + + com.squareup.okhttp3 + mockwebserver + ${mockwebserver.version} + test + + + + org.slf4j + slf4j-api + ${slf4j-api.version} + provided + + + + ch.qos.logback + logback-classic + ${logback.version} + test + + + + + + + + src/main/resources + + openapi-templates/** + + + + + + + + org.apache.maven.plugins + maven-dependency-plugin + + + + resolve-mockito-agent + + properties + + + + + + + org.openapitools + openapi-generator-maven-plugin + + + + generate-client + generate-sources + + generate + + + + java-generics-contract + ${project.basedir}/src/main/resources/customer-api-docs.yaml + + restclient + + io.github.blueprintplatform.samples.customerservice.client.generated.api + + io.github.blueprintplatform.samples.customerservice.client.generated.dto + + + io.github.blueprintplatform.samples.customerservice.client.generated.invoker + + + + true + java8 + true + false + + + true + false + + false + false + false + false + + + + + + + + + org.apache.maven.plugins + maven-compiler-plugin + ${maven.compiler.plugin.version} + + ${java.version} + ${project.build.sourceEncoding} + + + + + + org.apache.maven.plugins + maven-surefire-plugin + ${maven-surefire-plugin.version} + + @{argLine} -javaagent:${org.mockito:mockito-core:jar} + + **/*Test.java + + + + + + + org.apache.maven.plugins + maven-failsafe-plugin + ${maven-failsafe-plugin.version} + + + default + + integration-test + verify + + + @{argLine} -javaagent:${org.mockito:mockito-core:jar} + + **/*IT.java + + + + + + + + + org.jacoco + jacoco-maven-plugin + ${jacoco-maven-plugin.version} + + + prepare-agent + + prepare-agent + + + + + prepare-agent-integration + + prepare-agent-integration + + + + + report + verify + + report + + + + + report-integration + verify + + report-integration + + + + + + + + \ No newline at end of file diff --git a/samples/spring-boot-4/customer-service-client/src/main/java/io/github/blueprintplatform/samples/customerservice/client/adapter/CustomerClientAdapter.java b/samples/spring-boot-4/customer-service-client/src/main/java/io/github/blueprintplatform/samples/customerservice/client/adapter/CustomerClientAdapter.java new file mode 100644 index 0000000..acda9d1 --- /dev/null +++ b/samples/spring-boot-4/customer-service-client/src/main/java/io/github/blueprintplatform/samples/customerservice/client/adapter/CustomerClientAdapter.java @@ -0,0 +1,34 @@ +package io.github.blueprintplatform.samples.customerservice.client.adapter; + +import io.github.blueprintplatform.contracts.customer.CustomerDto; +import io.github.blueprintplatform.openapi.generics.contract.envelope.ServiceResponse; +import io.github.blueprintplatform.openapi.generics.contract.paging.Page; +import io.github.blueprintplatform.openapi.generics.contract.paging.SortDirection; +import io.github.blueprintplatform.samples.customerservice.client.customer.CustomerSortField; +import io.github.blueprintplatform.samples.customerservice.client.generated.dto.CustomerCreateRequest; +import io.github.blueprintplatform.samples.customerservice.client.generated.dto.CustomerUpdateRequest; + +/** + * Consumer-facing boundary for customer service remote calls. + * Returns shared contract types β€” generated model internals stay inside the adapter. + */ +public interface CustomerClientAdapter { + + ServiceResponse createCustomer(CustomerCreateRequest request); + + ServiceResponse getCustomer(Integer customerId); + + ServiceResponse> getCustomers(); + + ServiceResponse> getCustomers( + String name, + String email, + Integer page, + Integer size, + CustomerSortField sortBy, + SortDirection direction); + + ServiceResponse updateCustomer(Integer customerId, CustomerUpdateRequest request); + + ServiceResponse deleteCustomer(Integer customerId); +} \ No newline at end of file diff --git a/samples/spring-boot-4/customer-service-client/src/main/java/io/github/blueprintplatform/samples/customerservice/client/adapter/config/CustomerApiClientConfig.java b/samples/spring-boot-4/customer-service-client/src/main/java/io/github/blueprintplatform/samples/customerservice/client/adapter/config/CustomerApiClientConfig.java new file mode 100644 index 0000000..2eca001 --- /dev/null +++ b/samples/spring-boot-4/customer-service-client/src/main/java/io/github/blueprintplatform/samples/customerservice/client/adapter/config/CustomerApiClientConfig.java @@ -0,0 +1,108 @@ +package io.github.blueprintplatform.samples.customerservice.client.adapter.config; + +import io.github.blueprintplatform.samples.customerservice.client.adapter.support.ProblemDetailSupport; +import io.github.blueprintplatform.samples.customerservice.client.common.problem.ApiProblemException; +import io.github.blueprintplatform.samples.customerservice.client.generated.api.CustomerControllerApi; +import io.github.blueprintplatform.samples.customerservice.client.generated.invoker.ApiClient; +import java.util.List; +import org.apache.hc.client5.http.config.ConnectionConfig; +import org.apache.hc.client5.http.config.RequestConfig; +import org.apache.hc.client5.http.impl.classic.CloseableHttpClient; +import org.apache.hc.client5.http.impl.classic.HttpClients; +import org.apache.hc.client5.http.impl.io.PoolingHttpClientConnectionManagerBuilder; +import org.apache.hc.core5.util.TimeValue; +import org.apache.hc.core5.util.Timeout; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.restclient.RestClientCustomizer; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.http.HttpStatusCode; +import org.springframework.http.ProblemDetail; +import org.springframework.http.client.HttpComponentsClientHttpRequestFactory; +import org.springframework.web.client.RestClient; +import tools.jackson.databind.ObjectMapper; + +@Configuration +public class CustomerApiClientConfig { + + @Bean(destroyMethod = "close") + CloseableHttpClient customerHttpClient( + @Value("${customer.api.max-connections-total:64}") int maxTotal, + @Value("${customer.api.max-connections-per-route:16}") int maxPerRoute, + @Value("${customer.api.connect-timeout-seconds:10}") long connect, + @Value("${customer.api.connection-request-timeout-seconds:10}") long connReq, + @Value("${customer.api.read-timeout-seconds:15}") long read) { + + var connectionConfig = + ConnectionConfig.custom() + .setConnectTimeout(Timeout.ofSeconds(connect)) + .build(); + + var requestConfig = + RequestConfig.custom() + .setConnectionRequestTimeout(Timeout.ofSeconds(connReq)) + .setResponseTimeout(Timeout.ofSeconds(read)) + .build(); + + var cm = + PoolingHttpClientConnectionManagerBuilder.create() + .setMaxConnTotal(maxTotal) + .setMaxConnPerRoute(maxPerRoute) + .setDefaultConnectionConfig(connectionConfig) + .build(); + + return HttpClients.custom() + .setConnectionManager(cm) + .setDefaultRequestConfig(requestConfig) + .evictExpiredConnections() + .evictIdleConnections(TimeValue.ofSeconds(30)) + .setUserAgent("customer-service-client") + .disableAutomaticRetries() + .build(); + } + + @Bean + HttpComponentsClientHttpRequestFactory customerRequestFactory( + CloseableHttpClient customerHttpClient) { + return new HttpComponentsClientHttpRequestFactory(customerHttpClient); + } + + @Bean + RestClientCustomizer problemDetailStatusHandler(ObjectMapper objectMapper) { + return builder -> + builder.defaultStatusHandler( + HttpStatusCode::isError, + (request, response) -> { + ProblemDetail pd = ProblemDetailSupport.extract(objectMapper, response); + throw new ApiProblemException(pd, response.getStatusCode().value()); + }); + } + + @Bean + RestClient customerRestClient( + RestClient.Builder builder, + HttpComponentsClientHttpRequestFactory requestFactory, + List customizers) { + + builder.requestFactory(requestFactory); + + if (customizers != null) { + customizers.forEach(c -> c.customize(builder)); + } + + return builder.build(); + } + + @Bean + ApiClient customerApiClient( + RestClient customerRestClient, + @Value("${customer.api.base-url}") String baseUrl) { + + return new ApiClient(customerRestClient).setBasePath(baseUrl); + } + + @Bean + CustomerControllerApi customerControllerApi(ApiClient customerApiClient) { + return new CustomerControllerApi(customerApiClient); + } +} \ No newline at end of file diff --git a/samples/spring-boot-4/customer-service-client/src/main/java/io/github/blueprintplatform/samples/customerservice/client/adapter/impl/CustomerClientAdapterImpl.java b/samples/spring-boot-4/customer-service-client/src/main/java/io/github/blueprintplatform/samples/customerservice/client/adapter/impl/CustomerClientAdapterImpl.java new file mode 100644 index 0000000..ff876fe --- /dev/null +++ b/samples/spring-boot-4/customer-service-client/src/main/java/io/github/blueprintplatform/samples/customerservice/client/adapter/impl/CustomerClientAdapterImpl.java @@ -0,0 +1,98 @@ +package io.github.blueprintplatform.samples.customerservice.client.adapter.impl; + +import io.github.blueprintplatform.contracts.customer.CustomerDto; +import io.github.blueprintplatform.openapi.generics.contract.envelope.ServiceResponse; +import io.github.blueprintplatform.openapi.generics.contract.paging.Page; +import io.github.blueprintplatform.openapi.generics.contract.paging.SortDirection; +import io.github.blueprintplatform.samples.customerservice.client.adapter.CustomerClientAdapter; +import io.github.blueprintplatform.samples.customerservice.client.adapter.mapper.CustomerDtoMapper; +import io.github.blueprintplatform.samples.customerservice.client.customer.CustomerSortField; +import io.github.blueprintplatform.samples.customerservice.client.generated.api.CustomerControllerApi; +import io.github.blueprintplatform.samples.customerservice.client.generated.dto.CustomerCreateRequest; +import io.github.blueprintplatform.samples.customerservice.client.generated.dto.CustomerUpdateRequest; +import org.springframework.stereotype.Service; + +@Service +public class CustomerClientAdapterImpl implements CustomerClientAdapter { + + private final CustomerControllerApi api; + private final CustomerDtoMapper mapper; + + public CustomerClientAdapterImpl( + CustomerControllerApi customerControllerApi, + CustomerDtoMapper mapper) { + this.api = customerControllerApi; + this.mapper = mapper; + } + + @Override + public ServiceResponse createCustomer(CustomerCreateRequest request) { + var response = api.createCustomer(request); + return mapData(response); + } + + @Override + public ServiceResponse getCustomer(Integer customerId) { + var response = api.getCustomer(customerId); + return mapData(response); + } + + @Override + public ServiceResponse> getCustomers() { + return getCustomers(null, null, 0, 5, CustomerSortField.CUSTOMER_ID, SortDirection.ASC); + } + + @Override + public ServiceResponse> getCustomers( + String name, + String email, + Integer page, + Integer size, + CustomerSortField sortBy, + SortDirection direction) { + var response = api.getCustomers( + name, + email, + page, + size, + sortBy != null ? sortBy.value() : CustomerSortField.CUSTOMER_ID.value(), + direction != null ? direction.value() : SortDirection.ASC.value()); + return mapPageData(response); + } + + @Override + public ServiceResponse updateCustomer( + Integer customerId, CustomerUpdateRequest request) { + var response = api.updateCustomer(customerId, request); + return mapData(response); + } + + @Override + public ServiceResponse deleteCustomer(Integer customerId) { + api.deleteCustomer(customerId); + return ServiceResponse.of(null); + } + + private ServiceResponse mapData( + ServiceResponse response) { + CustomerDto mapped = mapper.toContract(response.getData()); + return ServiceResponse.of(mapped, response.getMeta()); + } + + private ServiceResponse> mapPageData( + ServiceResponse> response) { + Page generatedPage = response.getData(); + if (generatedPage == null) { + return ServiceResponse.of(null, response.getMeta()); + } + var mappedContent = generatedPage.content().stream() + .map(mapper::toContract) + .toList(); + Page mappedPage = Page.of( + mappedContent, + generatedPage.page(), + generatedPage.size(), + generatedPage.totalElements()); + return ServiceResponse.of(mappedPage, response.getMeta()); + } +} \ No newline at end of file diff --git a/samples/spring-boot-4/customer-service-client/src/main/java/io/github/blueprintplatform/samples/customerservice/client/adapter/mapper/CustomerDtoMapper.java b/samples/spring-boot-4/customer-service-client/src/main/java/io/github/blueprintplatform/samples/customerservice/client/adapter/mapper/CustomerDtoMapper.java new file mode 100644 index 0000000..6da1623 --- /dev/null +++ b/samples/spring-boot-4/customer-service-client/src/main/java/io/github/blueprintplatform/samples/customerservice/client/adapter/mapper/CustomerDtoMapper.java @@ -0,0 +1,23 @@ +package io.github.blueprintplatform.samples.customerservice.client.adapter.mapper; + +import io.github.blueprintplatform.contracts.customer.CustomerDto; +import org.springframework.stereotype.Component; + +/** + * Maps generated CustomerDto to the shared contract CustomerDto. + * Isolates the adapter from generated model internals. + */ +@Component +public class CustomerDtoMapper { + + public CustomerDto toContract( + io.github.blueprintplatform.samples.customerservice.client.generated.dto.CustomerDto generated) { + if (generated == null) { + return null; + } + return new CustomerDto( + generated.getCustomerId(), + generated.getName(), + generated.getEmail()); + } +} \ No newline at end of file diff --git a/samples/spring-boot-4/customer-service-client/src/main/java/io/github/blueprintplatform/samples/customerservice/client/adapter/support/ProblemDetailFallbacks.java b/samples/spring-boot-4/customer-service-client/src/main/java/io/github/blueprintplatform/samples/customerservice/client/adapter/support/ProblemDetailFallbacks.java new file mode 100644 index 0000000..2963791 --- /dev/null +++ b/samples/spring-boot-4/customer-service-client/src/main/java/io/github/blueprintplatform/samples/customerservice/client/adapter/support/ProblemDetailFallbacks.java @@ -0,0 +1,151 @@ +package io.github.blueprintplatform.samples.customerservice.client.adapter.support; + +import io.github.blueprintplatform.openapi.generics.contract.error.ErrorItem; +import io.github.blueprintplatform.openapi.generics.contract.error.ProblemExtensions; +import java.net.URI; +import java.util.ArrayList; +import java.util.List; +import org.springframework.http.HttpStatusCode; +import org.springframework.http.MediaType; +import org.springframework.http.ProblemDetail; + +final class ProblemDetailFallbacks { + + private static final HttpStatusCode STATUS_INTERNAL_SERVER_ERROR = HttpStatusCode.valueOf(500); + + private static final String KEY_ERROR_CODE = "errorCode"; + private static final String KEY_EXTENSIONS = "extensions"; + + private static final String TITLE_HTTP_ERROR = "HTTP error"; + private static final String TITLE_NON_JSON = "Non-JSON error response"; + private static final String TITLE_UNPARSABLE = "Unparseable problem response"; + private static final String TITLE_EMPTY = "Empty problem response body"; + + private static final String DETAIL_NON_JSON = "Upstream returned non-JSON error response."; + private static final String DETAIL_UNPARSABLE = + "Upstream returned a problem response, but it could not be parsed."; + private static final String DETAIL_EMPTY = "Upstream returned an empty error response body."; + private static final String DETAIL_STATUS_UNAVAILABLE = + "Unable to read HTTP status from upstream."; + + private static final String ERROR_CODE_UPSTREAM_NON_JSON = "UPSTREAM_NON_JSON_ERROR"; + private static final String ERROR_CODE_UPSTREAM_UNPARSABLE = "UPSTREAM_UNPARSABLE_PROBLEM"; + private static final String ERROR_CODE_UPSTREAM_EMPTY = "UPSTREAM_EMPTY_PROBLEM"; + private static final String ERROR_CODE_UPSTREAM_STATUS_UNAVAILABLE = + "UPSTREAM_STATUS_UNAVAILABLE"; + + private static final URI TYPE_NON_JSON = + URI.create("urn:customer-service-client:problem:client-fallback-upstream-non-json"); + + private static final URI TYPE_UNPARSABLE = + URI.create("urn:customer-service-client:problem:client-fallback-upstream-unparsable"); + + private static final URI TYPE_EMPTY = + URI.create("urn:customer-service-client:problem:client-fallback-upstream-empty"); + + private static final URI TYPE_STATUS_UNAVAILABLE = + URI.create("urn:customer-service-client:problem:client-fallback-upstream-status-unavailable"); + + private static final String ERROR_ITEM_RESOURCE_UPSTREAM = "upstream"; + private static final String ERROR_ITEM_FIELD_CONTENT_TYPE = "contentType"; + private static final String ERROR_ITEM_FIELD_STATUS = "status"; + private static final String ERROR_ITEM_FIELD_CAUSE = "cause"; + + private static final String MSG_CONTENT_TYPE_PREFIX = "Upstream Content-Type: "; + private static final String MSG_STATUS_UNAVAILABLE = "unavailable"; + + private ProblemDetailFallbacks() {} + + static ProblemDetail emptyBody( + HttpStatusCode status, MediaType contentType, Throwable bodyReadError) { + ProblemDetail pd = + baseProblem(status, TYPE_EMPTY, TITLE_EMPTY, DETAIL_EMPTY, ERROR_CODE_UPSTREAM_EMPTY); + addContextErrors(pd, ERROR_CODE_UPSTREAM_EMPTY, false, contentType, bodyReadError); + return pd; + } + + static ProblemDetail statusUnavailable(MediaType contentType, Throwable statusReadError) { + ProblemDetail pd = + baseProblem( + STATUS_INTERNAL_SERVER_ERROR, + TYPE_STATUS_UNAVAILABLE, + TITLE_HTTP_ERROR, + DETAIL_STATUS_UNAVAILABLE, + ERROR_CODE_UPSTREAM_STATUS_UNAVAILABLE); + addContextErrors( + pd, ERROR_CODE_UPSTREAM_STATUS_UNAVAILABLE, true, contentType, statusReadError); + return pd; + } + + static ProblemDetail nonJson( + HttpStatusCode status, MediaType contentType, boolean statusUnavailable) { + ProblemDetail pd = + baseProblem( + status, TYPE_NON_JSON, TITLE_NON_JSON, DETAIL_NON_JSON, ERROR_CODE_UPSTREAM_NON_JSON); + addContextErrors(pd, ERROR_CODE_UPSTREAM_NON_JSON, statusUnavailable, contentType, null); + return pd; + } + + static ProblemDetail unparsable( + HttpStatusCode status, + MediaType contentType, + boolean statusUnavailable, + Throwable parseError) { + ProblemDetail pd = + baseProblem( + status, + TYPE_UNPARSABLE, + TITLE_UNPARSABLE, + DETAIL_UNPARSABLE, + ERROR_CODE_UPSTREAM_UNPARSABLE); + addContextErrors( + pd, ERROR_CODE_UPSTREAM_UNPARSABLE, statusUnavailable, contentType, parseError); + return pd; + } + + private static ProblemDetail baseProblem( + HttpStatusCode status, URI type, String title, String detail, String errorCode) { + + ProblemDetail pd = ProblemDetail.forStatusAndDetail(status, detail); + pd.setType(type); + pd.setTitle((title != null && !title.isBlank()) ? title : TITLE_HTTP_ERROR); + pd.setProperty(KEY_ERROR_CODE, errorCode); + return pd; + } + + private static void addContextErrors( + ProblemDetail pd, + String problemCode, + boolean statusUnavailable, + MediaType contentType, + Throwable cause) { + + List errors = new ArrayList<>(); + + String ct = contentType != null ? contentType.toString() : ""; + if (!ct.isBlank()) { + errors.add( + errorItem(problemCode, MSG_CONTENT_TYPE_PREFIX + ct, ERROR_ITEM_FIELD_CONTENT_TYPE)); + } + + if (statusUnavailable) { + errors.add( + errorItem( + ERROR_CODE_UPSTREAM_STATUS_UNAVAILABLE, + MSG_STATUS_UNAVAILABLE, + ERROR_ITEM_FIELD_STATUS)); + } + + if (cause != null) { + errors.add(errorItem(problemCode, cause.getClass().getSimpleName(), ERROR_ITEM_FIELD_CAUSE)); + } + + if (!errors.isEmpty()) { + pd.setProperty(KEY_EXTENSIONS, ProblemExtensions.ofErrors(List.copyOf(errors))); + } + } + + private static ErrorItem errorItem(String code, String message, String field) { + return new ErrorItem(code, message, field, ERROR_ITEM_RESOURCE_UPSTREAM, null); + } +} diff --git a/samples/spring-boot-4/customer-service-client/src/main/java/io/github/blueprintplatform/samples/customerservice/client/adapter/support/ProblemDetailSupport.java b/samples/spring-boot-4/customer-service-client/src/main/java/io/github/blueprintplatform/samples/customerservice/client/adapter/support/ProblemDetailSupport.java new file mode 100644 index 0000000..b73515f --- /dev/null +++ b/samples/spring-boot-4/customer-service-client/src/main/java/io/github/blueprintplatform/samples/customerservice/client/adapter/support/ProblemDetailSupport.java @@ -0,0 +1,105 @@ +package io.github.blueprintplatform.samples.customerservice.client.adapter.support; + +import io.github.blueprintplatform.openapi.generics.contract.error.ProblemExtensions; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.http.MediaType; +import org.springframework.http.ProblemDetail; +import org.springframework.http.client.ClientHttpResponse; +import tools.jackson.databind.JsonNode; +import tools.jackson.databind.ObjectMapper; + +public final class ProblemDetailSupport { + + private static final Logger log = LoggerFactory.getLogger(ProblemDetailSupport.class); + + private static final String KEY_ERROR_CODE = "errorCode"; + private static final String KEY_EXTENSIONS = "extensions"; + + private ProblemDetailSupport() {} + + public static ProblemDetail extract(ObjectMapper om, ClientHttpResponse response) { + ResponseSnapshot snap = ResponseSnapshot.read(response); + + logIfErrors(snap); + + if (snap.body().length == 0) { + return handleEmptyBody(snap); + } + + if (!isJson(snap.contentType())) { + return ProblemDetailFallbacks.nonJson( + snap.status(), snap.contentType(), snap.statusUnavailable()); + } + + try { + return deserializeAndEnrich(om, snap); + } catch (Exception e) { + log.warn( + "Unable to deserialize ProblemDetail (status={}, contentType={}, bodyBytes={})", + snap.status(), + snap.contentType(), + snap.body().length, + e); + + return ProblemDetailFallbacks.unparsable( + snap.status(), snap.contentType(), snap.statusUnavailable(), e); + } + } + + private static void logIfErrors(ResponseSnapshot snap) { + if (snap.statusReadError() != null) { + log.warn("Unable to read upstream status code", snap.statusReadError()); + } + if (snap.bodyReadError() != null) { + log.warn("Unable to read upstream response body", snap.bodyReadError()); + } + } + + private static ProblemDetail handleEmptyBody(ResponseSnapshot snap) { + return snap.statusUnavailable() + ? ProblemDetailFallbacks.statusUnavailable(snap.contentType(), snap.statusReadError()) + : ProblemDetailFallbacks.emptyBody( + snap.status(), snap.contentType(), snap.bodyReadError()); + } + + private static ProblemDetail deserializeAndEnrich(ObjectMapper om, ResponseSnapshot snap) { + + ProblemDetail pd = om.readValue(snap.body(), ProblemDetail.class); + JsonNode tree = om.readTree(snap.body()); + + enrichErrorCode(pd, tree); + enrichExtensions(om, pd, tree); + + return pd; + } + + private static void enrichErrorCode(ProblemDetail pd, JsonNode tree) { + JsonNode node = tree.get(KEY_ERROR_CODE); + if (node != null && !node.isNull()) { + pd.setProperty(KEY_ERROR_CODE, node.asText()); + } + } + + private static void enrichExtensions(ObjectMapper om, ProblemDetail pd, JsonNode tree) { + JsonNode node = tree.get(KEY_EXTENSIONS); + if (node == null || node.isNull()) { + return; + } + + try { + ProblemExtensions ext = om.treeToValue(node, ProblemExtensions.class); + pd.setProperty(KEY_EXTENSIONS, ext); + } catch (Exception e) { + log.warn("Failed to map extensions to ProblemExtensions", e); + } + } + + private static boolean isJson(MediaType contentType) { + if (contentType == null) { + return false; + } + return MediaType.APPLICATION_JSON.isCompatibleWith(contentType) + || MediaType.APPLICATION_PROBLEM_JSON.isCompatibleWith(contentType); + } +} \ No newline at end of file diff --git a/samples/spring-boot-4/customer-service-client/src/main/java/io/github/blueprintplatform/samples/customerservice/client/adapter/support/ResponseSnapshot.java b/samples/spring-boot-4/customer-service-client/src/main/java/io/github/blueprintplatform/samples/customerservice/client/adapter/support/ResponseSnapshot.java new file mode 100644 index 0000000..ac2dd2a --- /dev/null +++ b/samples/spring-boot-4/customer-service-client/src/main/java/io/github/blueprintplatform/samples/customerservice/client/adapter/support/ResponseSnapshot.java @@ -0,0 +1,54 @@ +package io.github.blueprintplatform.samples.customerservice.client.adapter.support; + +import java.io.IOException; +import java.io.InputStream; +import org.springframework.http.HttpStatusCode; +import org.springframework.http.MediaType; +import org.springframework.http.client.ClientHttpResponse; + +@SuppressWarnings("java:S6218") +record ResponseSnapshot( + HttpStatusCode status, + boolean statusUnavailable, + MediaType contentType, + byte[] body, + IOException statusReadError, + IOException bodyReadError) { + + private static final int MAX_BODY_BYTES = 128_000; + + static ResponseSnapshot read(ClientHttpResponse response) { + MediaType contentType = response.getHeaders().getContentType(); + + StatusRead statusRead = readStatus(response); + BodyRead bodyRead = readBody(response); + + return new ResponseSnapshot( + statusRead.status, + statusRead.unavailable, + contentType, + bodyRead.body, + statusRead.error, + bodyRead.error); + } + + private static StatusRead readStatus(ClientHttpResponse response) { + try { + return new StatusRead(response.getStatusCode(), false, null); + } catch (IOException e) { + return new StatusRead(HttpStatusCode.valueOf(500), true, e); + } + } + + private static BodyRead readBody(ClientHttpResponse response) { + try (InputStream is = response.getBody()) { + return new BodyRead(is.readNBytes(MAX_BODY_BYTES), null); + } catch (IOException e) { + return new BodyRead(new byte[0], e); + } + } + + private record StatusRead(HttpStatusCode status, boolean unavailable, IOException error) {} + + private record BodyRead(byte[] body, IOException error) {} +} diff --git a/samples/spring-boot-4/customer-service-client/src/main/java/io/github/blueprintplatform/samples/customerservice/client/common/problem/ApiProblemException.java b/samples/spring-boot-4/customer-service-client/src/main/java/io/github/blueprintplatform/samples/customerservice/client/common/problem/ApiProblemException.java new file mode 100644 index 0000000..cabcba8 --- /dev/null +++ b/samples/spring-boot-4/customer-service-client/src/main/java/io/github/blueprintplatform/samples/customerservice/client/common/problem/ApiProblemException.java @@ -0,0 +1,182 @@ +package io.github.blueprintplatform.samples.customerservice.client.common.problem; + +import io.github.blueprintplatform.openapi.generics.contract.error.ErrorItem; +import io.github.blueprintplatform.openapi.generics.contract.error.ProblemExtensions; +import java.io.Serial; +import java.io.Serializable; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import org.springframework.http.ProblemDetail; + +public final class ApiProblemException extends RuntimeException implements Serializable { + + @Serial private static final long serialVersionUID = 1L; + + private static final String KEY_ERROR_CODE = "errorCode"; + private static final String KEY_EXTENSIONS = "extensions"; + private static final String KEY_ERRORS = "errors"; + + private final transient ProblemDetail problem; + private final int status; + private final String errorCode; + private final transient List errors; + + public ApiProblemException(ProblemDetail problem, int status) { + super(buildMessage(problem, status)); + this.problem = problem; + this.status = status; + this.errorCode = resolveErrorCode(problem); + this.errors = resolveErrors(problem); + } + + public ApiProblemException(ProblemDetail problem, int status, Throwable cause) { + super(buildMessage(problem, status), cause); + this.problem = problem; + this.status = status; + this.errorCode = resolveErrorCode(problem); + this.errors = resolveErrors(problem); + } + + private static String resolveErrorCode(ProblemDetail pd) { + Map properties = propertiesOf(pd); + if (properties.isEmpty()) { + return ""; + } + + Object raw = properties.get(KEY_ERROR_CODE); + if (!(raw instanceof String value)) { + return ""; + } + + String trimmed = value.trim(); + return trimmed.isEmpty() ? "" : trimmed; + } + + private static List resolveErrors(ProblemDetail pd) { + Map properties = propertiesOf(pd); + if (properties.isEmpty()) { + return List.of(); + } + + Object rawExtensions = properties.get(KEY_EXTENSIONS); + if (rawExtensions instanceof ProblemExtensions(List errors1)) { + return copyOfOrEmpty(errors1); + } + + if (rawExtensions instanceof Map extensionsMap) { + return mapErrors(extensionsMap.get(KEY_ERRORS)); + } + + return List.of(); + } + + private static Map propertiesOf(ProblemDetail pd) { + if (pd == null) { + return Map.of(); + } + + Map properties = pd.getProperties(); + return properties != null ? properties : Map.of(); + } + + private static List copyOfOrEmpty(List items) { + return (items == null || items.isEmpty()) ? List.of() : List.copyOf(items); + } + + private static List mapErrors(Object rawErrors) { + if (!(rawErrors instanceof List list) || list.isEmpty()) { + return List.of(); + } + + List mapped = new ArrayList<>(); + + for (Object item : list) { + if (item instanceof ErrorItem errorItem) { + mapped.add(errorItem); + continue; + } + + if (item instanceof Map map) { + mapped.add( + new ErrorItem( + asString(map.get("code")), + asString(map.get("message")), + asString(map.get("field")), + asString(map.get("resource")), + asString(map.get("id")))); + } + } + + return mapped.isEmpty() ? List.of() : List.copyOf(mapped); + } + + private static String asString(Object value) { + return value == null ? null : String.valueOf(value); + } + + private static String buildMessage(ProblemDetail pd, int status) { + if (pd == null) { + return "HTTP %d (no problem body)".formatted(status); + } + + StringBuilder sb = new StringBuilder("HTTP %d".formatted(status)); + appendIfNotBlank(sb, " - ", pd.getTitle()); + appendIfNotBlank(sb, " | ", pd.getDetail()); + + tag(sb, "code", normalize(resolveErrorCode(pd))); + tag(sb, "type", pd.getType() != null ? pd.getType().toString() : null); + tag(sb, "instance", pd.getInstance() != null ? pd.getInstance().toString() : null); + + int errorCount = resolveErrors(pd).size(); + if (errorCount > 0) { + sb.append(" [errors=").append(errorCount).append(']'); + } + + return sb.toString(); + } + + private static String normalize(String s) { + if (s == null) { + return null; + } + String trimmed = s.trim(); + return trimmed.isEmpty() ? null : trimmed; + } + + private static void appendIfNotBlank(StringBuilder sb, String sep, String v) { + if (v != null && !v.isBlank()) { + sb.append(sep).append(v); + } + } + + private static void tag(StringBuilder sb, String key, String value) { + if (value != null && !value.isBlank()) { + sb.append(" [").append(key).append('=').append(value).append(']'); + } + } + + public ProblemDetail getProblem() { + return problem; + } + + public int getStatus() { + return status; + } + + public String getErrorCode() { + return errorCode; + } + + public List getErrors() { + return errors; + } + + public boolean hasErrors() { + return !errors.isEmpty(); + } + + public ErrorItem firstErrorOrNull() { + return errors.isEmpty() ? null : errors.getFirst(); + } +} diff --git a/samples/spring-boot-4/customer-service-client/src/main/java/io/github/blueprintplatform/samples/customerservice/client/customer/CustomerSortField.java b/samples/spring-boot-4/customer-service-client/src/main/java/io/github/blueprintplatform/samples/customerservice/client/customer/CustomerSortField.java new file mode 100644 index 0000000..fc7b28b --- /dev/null +++ b/samples/spring-boot-4/customer-service-client/src/main/java/io/github/blueprintplatform/samples/customerservice/client/customer/CustomerSortField.java @@ -0,0 +1,31 @@ +package io.github.blueprintplatform.samples.customerservice.client.customer; + +public enum CustomerSortField { + CUSTOMER_ID("customerId"), + NAME("name"), + EMAIL("email"); + + private final String value; + + CustomerSortField(String value) { + this.value = value; + } + + public static CustomerSortField from(String s) { + if (s == null) return CUSTOMER_ID; + for (var f : values()) { + if (f.value.equalsIgnoreCase(s)) return f; + } + throw new IllegalArgumentException("Unsupported sort field: " + s); + } + + + public String value() { + return value; + } + + @Override + public String toString() { + return value; + } +} diff --git a/samples/spring-boot-4/customer-service-client/src/main/resources/customer-api-docs.yaml b/samples/spring-boot-4/customer-service-client/src/main/resources/customer-api-docs.yaml new file mode 100644 index 0000000..3fbca9a --- /dev/null +++ b/samples/spring-boot-4/customer-service-client/src/main/resources/customer-api-docs.yaml @@ -0,0 +1,271 @@ +openapi: 3.1.0 +info: + title: Customer Service API + description: Customer Service API with type-safe generic responses using OpenAPI + version: 0.9.0 +servers: + - url: http://localhost:8085/customer-service + description: Local service URL +paths: + /customers/{customerId}: + get: + tags: + - customer-controller + operationId: getCustomer + parameters: + - name: customerId + in: path + required: true + schema: + type: integer + format: int32 + minimum: 1 + responses: + "200": + description: OK + content: + application/json: + schema: + $ref: "#/components/schemas/ServiceResponseCustomerDto" + put: + tags: + - customer-controller + operationId: updateCustomer + parameters: + - name: customerId + in: path + required: true + schema: + type: integer + format: int32 + minimum: 1 + requestBody: + content: + application/json: + schema: + $ref: "#/components/schemas/CustomerUpdateRequest" + required: true + responses: + "200": + description: OK + content: + application/json: + schema: + $ref: "#/components/schemas/ServiceResponseCustomerDto" + delete: + tags: + - customer-controller + operationId: deleteCustomer + parameters: + - name: customerId + in: path + required: true + schema: + type: integer + format: int32 + minimum: 1 + responses: + "204": + description: Customer deleted + /customers: + get: + tags: + - customer-controller + operationId: getCustomers + parameters: + - name: name + in: query + required: false + schema: + type: string + - name: email + in: query + required: false + schema: + type: string + - name: page + in: query + required: false + schema: + type: integer + format: int32 + default: 0 + minimum: 0 + - name: size + in: query + required: false + schema: + type: integer + format: int32 + default: 5 + maximum: 10 + minimum: 1 + - name: sortBy + in: query + required: false + schema: + type: string + default: customerId + enum: + - customerId + - name + - email + - name: direction + in: query + required: false + schema: + type: string + default: ASC + enum: + - ASC + - DESC + responses: + "200": + description: OK + content: + application/json: + schema: + $ref: "#/components/schemas/ServiceResponsePageCustomerDto" + post: + tags: + - customer-controller + operationId: createCustomer + requestBody: + content: + application/json: + schema: + $ref: "#/components/schemas/CustomerCreateRequest" + required: true + responses: + "200": + description: OK + content: + application/json: + schema: + $ref: "#/components/schemas/ServiceResponseCustomerDto" +components: + schemas: + CustomerUpdateRequest: + type: object + properties: + name: + type: string + maxLength: 80 + minLength: 2 + email: + type: string + format: email + minLength: 1 + required: + - email + - name + CustomerDto: + type: object + properties: + customerId: + type: integer + format: int32 + name: + type: string + email: + type: string + Meta: + type: object + properties: + serverTime: + type: string + format: date-time + sort: + type: array + items: + $ref: "#/components/schemas/Sort" + x-ignore-model: true + ServiceResponseCustomerDto: + allOf: + - $ref: "#/components/schemas/ServiceResponse" + - type: object + properties: + data: + $ref: "#/components/schemas/CustomerDto" + x-api-wrapper: true + x-api-wrapper-datatype: CustomerDto + Sort: + type: object + properties: + field: + type: string + direction: + type: string + enum: + - ASC + - DESC + x-ignore-model: true + CustomerCreateRequest: + type: object + properties: + name: + type: string + maxLength: 80 + minLength: 2 + email: + type: string + format: email + minLength: 1 + required: + - email + - name + PageCustomerDto: + type: object + properties: + content: + type: array + items: + $ref: "#/components/schemas/CustomerDto" + page: + type: integer + format: int32 + size: + type: integer + format: int32 + totalElements: + type: integer + format: int64 + totalPages: + type: integer + format: int32 + hasNext: + type: boolean + hasPrev: + type: boolean + x-ignore-model: true + ServiceResponsePageCustomerDto: + allOf: + - $ref: "#/components/schemas/ServiceResponse" + - type: object + properties: + data: + $ref: "#/components/schemas/PageCustomerDto" + x-api-wrapper: true + x-api-wrapper-datatype: PageCustomerDto + x-data-container: Page + x-data-item: CustomerDto + ServiceResponse: + type: object + properties: + data: + type: object + meta: + $ref: "#/components/schemas/Meta" + required: + - meta + x-ignore-model: true + ServiceResponseVoid: + type: object + properties: + data: + type: object + meta: + $ref: "#/components/schemas/Meta" + required: + - meta + x-ignore-model: true diff --git a/samples/spring-boot-4/customer-service-client/src/test/java/io/github/blueprintplatform/samples/customerservice/client/adapter/CustomerClientErrorIT.java b/samples/spring-boot-4/customer-service-client/src/test/java/io/github/blueprintplatform/samples/customerservice/client/adapter/CustomerClientErrorIT.java new file mode 100644 index 0000000..adc0aed --- /dev/null +++ b/samples/spring-boot-4/customer-service-client/src/test/java/io/github/blueprintplatform/samples/customerservice/client/adapter/CustomerClientErrorIT.java @@ -0,0 +1,213 @@ +package io.github.blueprintplatform.samples.customerservice.client.adapter; + +import static org.junit.jupiter.api.Assertions.*; + +import io.github.blueprintplatform.samples.customerservice.client.adapter.config.CustomerApiClientConfig; +import io.github.blueprintplatform.samples.customerservice.client.common.problem.ApiProblemException; +import io.github.blueprintplatform.samples.customerservice.client.generated.api.CustomerControllerApi; +import io.github.blueprintplatform.samples.customerservice.client.generated.dto.CustomerCreateRequest; +import okhttp3.mockwebserver.MockResponse; +import okhttp3.mockwebserver.MockWebServer; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.http.MediaType; +import org.springframework.http.ProblemDetail; +import org.springframework.http.converter.json.Jackson2ObjectMapperBuilder; +import org.springframework.test.context.junit.jupiter.SpringJUnitConfig; +import org.springframework.web.client.RestClient; +import tools.jackson.databind.ObjectMapper; +import tools.jackson.databind.json.JsonMapper; + +@SpringJUnitConfig(classes = {CustomerApiClientConfig.class, CustomerClientErrorIT.TestBeans.class}) +class CustomerClientErrorIT { + + static MockWebServer server; + + @Autowired private CustomerControllerApi api; + + @BeforeAll + static void startServer() throws Exception { + server = new MockWebServer(); + server.start(); + System.setProperty("customer.api.base-url", server.url("/customer-service").toString()); + } + + @AfterAll + static void stopServer() throws Exception { + server.shutdown(); + System.clearProperty("customer.api.base-url"); + } + + @Test + @DisplayName( + "GET /customers/{id} -> 404 Problem => throws ApiProblemException with parsed body") + void getCustomer_404_problem() { + var problem = + """ + { + "type":"https://example.org/problem/not-found", + "title":"Not Found", + "status":404, + "detail":"Customer 999 not found", + "instance":"https://example.org/trace/customer-999", + "errorCode":"CUS_404" + } + """; + + server.enqueue( + new MockResponse() + .setResponseCode(404) + .addHeader("Content-Type", "application/problem+json") + .setBody(problem)); + + var ex = assertThrows(ApiProblemException.class, () -> api.getCustomer(999)); + + assertEquals(404, ex.getStatus()); + + ProblemDetail pd = ex.getProblem(); + assertNotNull(pd); + assertEquals("Not Found", pd.getTitle()); + assertEquals("Customer 999 not found", pd.getDetail()); + assertNotNull(pd.getType()); + assertEquals("https://example.org/problem/not-found", pd.getType().toString()); + assertNotNull(pd.getInstance()); + assertEquals("https://example.org/trace/customer-999", pd.getInstance().toString()); + + assertEquals("CUS_404", ex.getErrorCode()); + assertFalse(ex.hasErrors()); + assertNull(ex.firstErrorOrNull()); + } + + @Test + @DisplayName("POST /customers -> 400 Problem (validation) => throws ApiProblemException") + void createCustomer_400_problem() { + var problem = + """ + { + "title":"Bad Request", + "status":400, + "detail":"email must be a well-formed email address", + "errorCode":"VAL_001", + "extensions": { + "errors":[{"code":"invalid_email","message":"email format"}] + } + } + """; + + server.enqueue( + new MockResponse() + .setResponseCode(400) + .addHeader("Content-Type", "application/problem+json") + .setBody(problem)); + + var req = new CustomerCreateRequest().name("Bad Email").email("not-an-email"); + + var ex = assertThrows(ApiProblemException.class, () -> api.createCustomer(req)); + + assertEquals(400, ex.getStatus()); + + ProblemDetail pd = ex.getProblem(); + assertNotNull(pd); + assertEquals("Bad Request", pd.getTitle()); + assertEquals("email must be a well-formed email address", pd.getDetail()); + + assertEquals("VAL_001", ex.getErrorCode()); + assertTrue(ex.hasErrors()); + assertEquals(1, ex.getErrors().size()); + + var firstError = ex.firstErrorOrNull(); + assertNotNull(firstError); + assertEquals("invalid_email", firstError.code()); + assertEquals("email format", firstError.message()); + } + + @Test + @DisplayName( + "DELETE /customers/{id} -> 500 (no body) => throws ApiProblemException with fallback ProblemDetail") + void deleteCustomer_500_no_body() { + server.enqueue( + new MockResponse() + .setResponseCode(500) + .addHeader("Content-Type", MediaType.APPLICATION_JSON_VALUE)); + + var ex = assertThrows(ApiProblemException.class, () -> api.deleteCustomer(1)); + + assertEquals(500, ex.getStatus()); + + var pd = ex.getProblem(); + assertNotNull(pd); + assertEquals(500, pd.getStatus()); + assertEquals("Empty problem response body", pd.getTitle()); + assertEquals("Upstream returned an empty error response body.", pd.getDetail()); + assertNotNull(pd.getType()); + assertTrue(pd.getType().toString().contains("upstream-empty")); + + assertEquals("UPSTREAM_EMPTY_PROBLEM", ex.getErrorCode()); + assertTrue(ex.hasErrors()); + assertNotNull(ex.firstErrorOrNull()); + } + + @Test + @DisplayName("GET /customers/{id} -> 502 text/plain => fallback non-json problem") + void getCustomer_502_nonJsonFallback() { + server.enqueue( + new MockResponse() + .setResponseCode(502) + .addHeader("Content-Type", "text/plain") + .setBody("bad gateway")); + + var ex = assertThrows(ApiProblemException.class, () -> api.getCustomer(10)); + + assertEquals(502, ex.getStatus()); + + var pd = ex.getProblem(); + assertNotNull(pd); + assertNotNull(pd.getType()); + assertTrue(pd.getType().toString().contains("upstream-non-json")); + + assertEquals("UPSTREAM_NON_JSON_ERROR", ex.getErrorCode()); + assertTrue(ex.hasErrors()); + } + + @Test + @DisplayName("GET /customers/{id} -> 500 invalid problem json => fallback unparsable") + void getCustomer_500_unparsableProblemFallback() { + server.enqueue( + new MockResponse() + .setResponseCode(500) + .addHeader("Content-Type", "application/problem+json") + .setBody("{ invalid-json")); + + var ex = assertThrows(ApiProblemException.class, () -> api.getCustomer(20)); + + assertEquals(500, ex.getStatus()); + + var pd = ex.getProblem(); + assertNotNull(pd); + assertNotNull(pd.getType()); + assertTrue(pd.getType().toString().contains("upstream-unparsable")); + + assertEquals("UPSTREAM_UNPARSABLE_PROBLEM", ex.getErrorCode()); + assertTrue(ex.hasErrors()); + } + + @Configuration + static class TestBeans { + @Bean + RestClient.Builder restClientBuilder() { + return RestClient.builder(); + } + + @Bean + ObjectMapper objectMapper() { + return JsonMapper.builder() + .findAndAddModules() + .build(); + } + } +} diff --git a/samples/customer-service-client/src/test/java/io/github/blueprintplatform/samples/customerservice/client/adapter/CustomerClientIT.java b/samples/spring-boot-4/customer-service-client/src/test/java/io/github/blueprintplatform/samples/customerservice/client/adapter/CustomerClientIT.java similarity index 83% rename from samples/customer-service-client/src/test/java/io/github/blueprintplatform/samples/customerservice/client/adapter/CustomerClientIT.java rename to samples/spring-boot-4/customer-service-client/src/test/java/io/github/blueprintplatform/samples/customerservice/client/adapter/CustomerClientIT.java index 97fe1d0..e76136f 100644 --- a/samples/customer-service-client/src/test/java/io/github/blueprintplatform/samples/customerservice/client/adapter/CustomerClientIT.java +++ b/samples/spring-boot-4/customer-service-client/src/test/java/io/github/blueprintplatform/samples/customerservice/client/adapter/CustomerClientIT.java @@ -2,7 +2,6 @@ import static org.junit.jupiter.api.Assertions.*; -import com.fasterxml.jackson.databind.ObjectMapper; import io.github.blueprintplatform.samples.customerservice.client.adapter.config.CustomerApiClientConfig; import io.github.blueprintplatform.samples.customerservice.client.generated.api.CustomerControllerApi; import io.github.blueprintplatform.samples.customerservice.client.generated.dto.CustomerCreateRequest; @@ -16,9 +15,10 @@ import org.springframework.beans.factory.annotation.Autowired; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; -import org.springframework.http.converter.json.Jackson2ObjectMapperBuilder; import org.springframework.test.context.junit.jupiter.SpringJUnitConfig; import org.springframework.web.client.RestClient; +import tools.jackson.databind.ObjectMapper; +import tools.jackson.databind.json.JsonMapper; @SpringJUnitConfig(classes = {CustomerApiClientConfig.class, CustomerClientIT.TestBeans.class}) class CustomerClientIT { @@ -41,7 +41,7 @@ static void stopServer() throws Exception { } @Test - @DisplayName("POST /v1/customers -> 201 Created + maps {data, meta}") + @DisplayName("POST /customers -> 201 Created + maps {data, meta}") void createCustomer_shouldReturn201_andMapBody() { var body = """ @@ -71,7 +71,7 @@ void createCustomer_shouldReturn201_andMapBody() { } @Test - @DisplayName("GET /v1/customers/{id} -> 200 OK + maps {data, meta}") + @DisplayName("GET /customers/{id} -> 200 OK + maps {data, meta}") void getCustomer_shouldReturn200_andMapBody() { var body = """ @@ -99,7 +99,7 @@ void getCustomer_shouldReturn200_andMapBody() { } @Test - @DisplayName("GET /v1/customers -> 200 OK + maps Page in data and meta") + @DisplayName("GET /customers -> 200 OK + maps Page in data and meta") void getCustomers_shouldReturn200_andMapPage() { var body = """ @@ -148,7 +148,7 @@ void getCustomers_shouldReturn200_andMapPage() { } @Test - @DisplayName("PUT /v1/customers/{id} -> 200 OK + maps {data, meta}") + @DisplayName("PUT /customers/{id} -> 200 OK + maps {data, meta}") void updateCustomer_shouldReturn200_andMapBody() { var body = """ @@ -178,32 +178,15 @@ void updateCustomer_shouldReturn200_andMapBody() { } @Test - @DisplayName("DELETE /v1/customers/{id} -> 200 OK + maps {data, meta}") - void deleteCustomer_shouldReturn200_andMapBody() { - var body = - """ - { - "data": { "customerId": 1 }, - "meta": { "serverTime": "2025-01-05T08:00:00Z", "sort": [] } - } - """; + @DisplayName("DELETE /customers/{id} -> 200 OK (no body expected)") + void deleteCustomer_shouldReturn200() { server.enqueue( - new MockResponse() - .setResponseCode(200) - .addHeader("Content-Type", "application/json") - .setBody(body)); + new MockResponse().setResponseCode(200).addHeader("Content-Type", "application/json")); - var resp = api.deleteCustomer(1); - - assertNotNull(resp); - assertNotNull(resp.getData()); - assertEquals(1, resp.getData().getCustomerId()); - - assertNotNull(resp.getMeta()); - assertNotNull(resp.getMeta().serverTime()); + assertDoesNotThrow(() -> api.deleteCustomer(1)); } - + @Configuration static class TestBeans { @@ -214,7 +197,9 @@ RestClient.Builder restClientBuilder() { @Bean ObjectMapper objectMapper() { - return Jackson2ObjectMapperBuilder.json().build(); + return JsonMapper.builder() + .findAndAddModules() + .build(); } } } diff --git a/samples/spring-boot-4/customer-service-client/src/test/java/io/github/blueprintplatform/samples/customerservice/client/adapter/config/CustomerApiClientConfigStatusHandlerTest.java b/samples/spring-boot-4/customer-service-client/src/test/java/io/github/blueprintplatform/samples/customerservice/client/adapter/config/CustomerApiClientConfigStatusHandlerTest.java new file mode 100644 index 0000000..1c8c685 --- /dev/null +++ b/samples/spring-boot-4/customer-service-client/src/test/java/io/github/blueprintplatform/samples/customerservice/client/adapter/config/CustomerApiClientConfigStatusHandlerTest.java @@ -0,0 +1,160 @@ +package io.github.blueprintplatform.samples.customerservice.client.adapter.config; + +import static org.junit.jupiter.api.Assertions.*; +import static org.springframework.test.web.client.ExpectedCount.once; +import static org.springframework.test.web.client.match.MockRestRequestMatchers.requestTo; +import static org.springframework.test.web.client.response.MockRestResponseCreators.withStatus; + +import io.github.blueprintplatform.samples.customerservice.client.adapter.support.ProblemDetailSupport; +import io.github.blueprintplatform.samples.customerservice.client.common.problem.ApiProblemException; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.http.ProblemDetail; +import org.springframework.http.client.HttpComponentsClientHttpRequestFactory; +import org.springframework.test.web.client.MockRestServiceServer; +import org.springframework.web.client.RestClient; +import tools.jackson.databind.DeserializationFeature; +import tools.jackson.databind.ObjectMapper; +import tools.jackson.databind.json.JsonMapper; + +@DisplayName("Unit: CustomerApiClientConfig.defaultStatusHandler") +class CustomerApiClientConfigStatusHandlerTest { + + private static final String BASE_URL = "http://localhost"; + private static final String URI_400 = "/err400"; + private static final String URI_500 = "/err500"; + + private static final String ERROR_CODE_VALIDATION = "VAL_001"; + private static final String ERROR_CODE_EMPTY = "UPSTREAM_EMPTY_PROBLEM"; + + private static final String TITLE_BAD_REQUEST = "Bad Request"; + private static final String DETAIL_VALIDATION = "Validation failed"; + + private static final String TITLE_EMPTY = "Empty problem response body"; + private static final String DETAIL_EMPTY = + "Upstream returned an empty error response body."; + + private ObjectMapper objectMapper() { + return JsonMapper.builder() + .findAndAddModules() + .disable(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES) + .build(); + } + + private static class TestContext { + final RestClient client; + final MockRestServiceServer server; + + private TestContext(RestClient client, MockRestServiceServer server) { + this.client = client; + this.server = server; + } + } + + private TestContext buildClient(ObjectMapper om) { + var config = new CustomerApiClientConfig(); + + var httpClient = config.customerHttpClient(64, 16, 10, 10, 15); + var requestFactory = new HttpComponentsClientHttpRequestFactory(httpClient); + + RestClient.Builder builder = + RestClient.builder() + .baseUrl(BASE_URL) + .requestFactory(requestFactory) + .defaultStatusHandler( + org.springframework.http.HttpStatusCode::isError, + (request, response) -> { + var pd = ProblemDetailSupport.extract(om, response); + throw new ApiProblemException(pd, response.getStatusCode().value()); + }); + + MockRestServiceServer server = MockRestServiceServer.bindTo(builder).build(); + + return new TestContext(builder.build(), server); + } + + @Test + @DisplayName("400 β†’ parses ProblemDetail and throws ApiProblemException") + void handler_parses_problem_detail_on_4xx() { + var ctx = buildClient(objectMapper()); + + ctx.server.expect(once(), requestTo(BASE_URL + URI_400)) + .andRespond( + withStatus(HttpStatus.BAD_REQUEST) + .contentType(MediaType.APPLICATION_PROBLEM_JSON) + .body(problemJson())); + + ApiProblemException ex = performGetExpectingException(ctx.client, URI_400); + + assertBasicProblem(ex, 400, TITLE_BAD_REQUEST, DETAIL_VALIDATION); + assertValidationDetails(ex); + + ctx.server.verify(); + } + + @Test + @DisplayName("500 β†’ empty body fallback ProblemDetail") + void handler_handles_empty_body_on_5xx() { + var ctx = buildClient(objectMapper()); + + ctx.server.expect(once(), requestTo(BASE_URL + URI_500)) + .andRespond(withStatus(HttpStatus.INTERNAL_SERVER_ERROR)); + + ApiProblemException ex = performGetExpectingException(ctx.client, URI_500); + + assertBasicProblem(ex, 500, TITLE_EMPTY, DETAIL_EMPTY); + assertEquals(ERROR_CODE_EMPTY, ex.getErrorCode()); + assertFalse(ex.hasErrors()); + + ctx.server.verify(); + } + + private ApiProblemException performGetExpectingException(RestClient client, String uri) { + return assertThrows( + ApiProblemException.class, + () -> client.get().uri(uri).retrieve().body(String.class)); + } + + private void assertBasicProblem( + ApiProblemException ex, int status, String expectedTitle, String expectedDetail) { + + assertEquals(status, ex.getStatus()); + + ProblemDetail pd = ex.getProblem(); + assertNotNull(pd); + assertEquals(expectedTitle, pd.getTitle()); + assertEquals(expectedDetail, pd.getDetail()); + } + + private void assertValidationDetails(ApiProblemException ex) { + assertEquals(ERROR_CODE_VALIDATION, ex.getErrorCode()); + + assertTrue(ex.hasErrors()); + assertEquals(1, ex.getErrors().size()); + + var error = ex.firstErrorOrNull(); + assertNotNull(error); + assertEquals("too_short", error.code()); + assertEquals("name too short", error.message()); + } + + private String problemJson() { + return """ + { + "type":"https://example.org/problem/bad-request", + "title":"Bad Request", + "status":400, + "detail":"Validation failed", + "instance":"https://example.org/trace/abc", + "errorCode":"VAL_001", + "extensions": { + "errors": [ + { "code":"too_short", "message":"name too short" } + ] + } + } + """; + } +} \ No newline at end of file diff --git a/samples/spring-boot-4/customer-service-client/src/test/java/io/github/blueprintplatform/samples/customerservice/client/adapter/impl/CustomerClientAdapterImplTest.java b/samples/spring-boot-4/customer-service-client/src/test/java/io/github/blueprintplatform/samples/customerservice/client/adapter/impl/CustomerClientAdapterImplTest.java new file mode 100644 index 0000000..e5bb7fa --- /dev/null +++ b/samples/spring-boot-4/customer-service-client/src/test/java/io/github/blueprintplatform/samples/customerservice/client/adapter/impl/CustomerClientAdapterImplTest.java @@ -0,0 +1,205 @@ +package io.github.blueprintplatform.samples.customerservice.client.adapter.impl; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.doNothing; +import static org.mockito.Mockito.when; + +import io.github.blueprintplatform.contracts.customer.CustomerDto; +import io.github.blueprintplatform.openapi.generics.contract.envelope.Meta; +import io.github.blueprintplatform.openapi.generics.contract.envelope.ServiceResponse; +import io.github.blueprintplatform.openapi.generics.contract.paging.Page; +import io.github.blueprintplatform.openapi.generics.contract.paging.SortDirection; +import io.github.blueprintplatform.samples.customerservice.client.adapter.CustomerClientAdapter; +import io.github.blueprintplatform.samples.customerservice.client.adapter.mapper.CustomerDtoMapper; +import io.github.blueprintplatform.samples.customerservice.client.customer.CustomerSortField; +import io.github.blueprintplatform.samples.customerservice.client.generated.api.CustomerControllerApi; +import io.github.blueprintplatform.samples.customerservice.client.generated.dto.CustomerCreateRequest; +import io.github.blueprintplatform.samples.customerservice.client.generated.dto.CustomerUpdateRequest; +import io.github.blueprintplatform.samples.customerservice.client.generated.dto.ServiceResponseCustomerDto; +import io.github.blueprintplatform.samples.customerservice.client.generated.dto.ServiceResponsePageCustomerDto; +import java.time.OffsetDateTime; +import java.util.List; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +@Tag("unit") +@ExtendWith(MockitoExtension.class) +@DisplayName("Unit Test: CustomerClientAdapterImpl") +class CustomerClientAdapterImplTest { + + @Mock CustomerControllerApi api; + + @Mock CustomerDtoMapper mapper; + + @InjectMocks CustomerClientAdapterImpl adapter; + + @Test + @DisplayName("createCustomer -> delegates to API and maps generated dto to contract dto") + void createCustomer_delegates_and_returns_data_meta() { + var req = new CustomerCreateRequest().name("Jane Doe").email("jane@example.com"); + + var generatedDto = + new io.github.blueprintplatform.samples.customerservice.client.generated.dto.CustomerDto() + .customerId(1) + .name("Jane Doe") + .email("jane@example.com"); + + var contractDto = new CustomerDto(1, "Jane Doe", "jane@example.com"); + + var serverOdt = OffsetDateTime.parse("2025-01-01T12:34:56Z"); + var meta = new Meta(serverOdt.toInstant(), List.of()); + + var wrapper = new ServiceResponseCustomerDto(); + wrapper.setData(generatedDto); + wrapper.setMeta(meta); + + when(api.createCustomer(any(CustomerCreateRequest.class))).thenReturn(wrapper); + when(mapper.toContract(generatedDto)).thenReturn(contractDto); + + ServiceResponse res = adapter.createCustomer(req); + + assertNotNull(res); + assertNotNull(res.getData()); + assertEquals(1, res.getData().customerId()); + assertEquals("Jane Doe", res.getData().name()); + assertEquals("jane@example.com", res.getData().email()); + + assertNotNull(res.getMeta()); + assertEquals(serverOdt.toInstant(), res.getMeta().serverTime()); + } + + @Test + @DisplayName("getCustomer -> delegates to API and maps generated dto to contract dto") + void getCustomer_delegates_and_returnsDto() { + var generatedDto = + new io.github.blueprintplatform.samples.customerservice.client.generated.dto.CustomerDto() + .customerId(42) + .name("John Smith") + .email("john.smith@example.com"); + + var contractDto = new CustomerDto(42, "John Smith", "john.smith@example.com"); + + var serverOdt = OffsetDateTime.parse("2025-02-01T10:00:00Z"); + var wrapper = new ServiceResponseCustomerDto(); + wrapper.setData(generatedDto); + wrapper.setMeta(new Meta(serverOdt.toInstant(), List.of())); + + when(api.getCustomer(any())).thenReturn(wrapper); + when(mapper.toContract(generatedDto)).thenReturn(contractDto); + + ServiceResponse res = adapter.getCustomer(42); + + assertNotNull(res); + assertNotNull(res.getData()); + assertEquals(42, res.getData().customerId()); + assertEquals("John Smith", res.getData().name()); + assertEquals("john.smith@example.com", res.getData().email()); + + assertNotNull(res.getMeta()); + assertEquals(serverOdt.toInstant(), res.getMeta().serverTime()); + } + + @Test + @DisplayName("getCustomers -> delegates to API and maps generated page content to contract page") + void getCustomers_delegates_and_returnsPage() { + var generated1 = + new io.github.blueprintplatform.samples.customerservice.client.generated.dto.CustomerDto() + .customerId(1) + .name("A") + .email("a@example.com"); + + var generated2 = + new io.github.blueprintplatform.samples.customerservice.client.generated.dto.CustomerDto() + .customerId(2) + .name("B") + .email("b@example.com"); + + var contract1 = new CustomerDto(1, "A", "a@example.com"); + var contract2 = new CustomerDto(2, "B", "b@example.com"); + + var page = Page.of(List.of(generated1, generated2), 0, 5, 2L); + + var serverOdt = OffsetDateTime.parse("2025-03-01T09:00:00Z"); + var wrapper = new ServiceResponsePageCustomerDto(); + wrapper.setData(page); + wrapper.setMeta(new Meta(serverOdt.toInstant(), List.of())); + + when(api.getCustomers(any(), any(), any(), any(), any(), any())).thenReturn(wrapper); + when(mapper.toContract(generated1)).thenReturn(contract1); + when(mapper.toContract(generated2)).thenReturn(contract2); + + ServiceResponse> res = + adapter.getCustomers(null, null, 0, 5, CustomerSortField.CUSTOMER_ID, SortDirection.ASC); + + assertNotNull(res); + assertNotNull(res.getData()); + assertEquals(0, res.getData().page()); + assertEquals(5, res.getData().size()); + assertEquals(2L, res.getData().totalElements()); + assertNotNull(res.getData().content()); + assertEquals(2, res.getData().content().size()); + assertEquals(1, res.getData().content().getFirst().customerId()); + assertEquals("A", res.getData().content().getFirst().name()); + assertEquals(2, res.getData().content().get(1).customerId()); + + assertNotNull(res.getMeta()); + assertEquals(serverOdt.toInstant(), res.getMeta().serverTime()); + } + + @Test + @DisplayName("updateCustomer -> delegates to API and maps generated dto to contract dto") + void updateCustomer_delegates_and_returnsUpdated() { + var req = new CustomerUpdateRequest().name("Jane Updated").email("jane.updated@example.com"); + + var generatedDto = + new io.github.blueprintplatform.samples.customerservice.client.generated.dto.CustomerDto() + .customerId(1) + .name("Jane Updated") + .email("jane.updated@example.com"); + + var contractDto = new CustomerDto(1, "Jane Updated", "jane.updated@example.com"); + + var serverOdt = OffsetDateTime.parse("2025-04-02T12:00:00Z"); + var wrapper = new ServiceResponseCustomerDto(); + wrapper.setData(generatedDto); + wrapper.setMeta(new Meta(serverOdt.toInstant(), List.of())); + + when(api.updateCustomer(any(), any(CustomerUpdateRequest.class))).thenReturn(wrapper); + when(mapper.toContract(generatedDto)).thenReturn(contractDto); + + ServiceResponse res = adapter.updateCustomer(1, req); + + assertNotNull(res); + assertNotNull(res.getData()); + assertEquals("Jane Updated", res.getData().name()); + assertEquals("jane.updated@example.com", res.getData().email()); + + assertNotNull(res.getMeta()); + assertEquals(serverOdt.toInstant(), res.getMeta().serverTime()); + } + + @Test + @DisplayName("deleteCustomer -> returns empty ServiceResponse") + void deleteCustomer_delegates_and_wrapsVoidResponse() { + doNothing().when(api).deleteCustomer(any()); + + ServiceResponse res = adapter.deleteCustomer(7); + + assertNotNull(res); + assertNull(res.getData()); + assertNotNull(res.getMeta()); + } + + @Test + @DisplayName("Adapter interface type check") + void adapter_type_sanity() { + CustomerClientAdapter asInterface = adapter; + assertNotNull(asInterface); + } +} \ No newline at end of file diff --git a/samples/spring-boot-4/customer-service-consumer/.dockerignore b/samples/spring-boot-4/customer-service-consumer/.dockerignore new file mode 100644 index 0000000..efd6206 --- /dev/null +++ b/samples/spring-boot-4/customer-service-consumer/.dockerignore @@ -0,0 +1,5 @@ +target/ +.git +.gitignore +Dockerfile +README.md \ No newline at end of file diff --git a/samples/spring-boot-4/customer-service-consumer/Dockerfile b/samples/spring-boot-4/customer-service-consumer/Dockerfile new file mode 100644 index 0000000..7b4559a --- /dev/null +++ b/samples/spring-boot-4/customer-service-consumer/Dockerfile @@ -0,0 +1,35 @@ +FROM maven:3.9.12-eclipse-temurin-21-noble AS builder +WORKDIR /app + +COPY pom.xml pom.xml +COPY src src + +RUN --mount=type=cache,target=/root/.m2,sharing=locked \ + mvn -q -T 2C -DskipTests --no-transfer-progress clean package && \ + JAR="$(ls -1 target/customer-service-consumer-*.jar | head -n 1)" && \ + java -Djarmode=layertools -jar "$JAR" extract + +FROM eclipse-temurin:21.0.10_7-jre-noble +WORKDIR /app + +RUN useradd -r -u 10001 -g root -s /bin/false -d /app appuser && \ + mkdir -p /app/logs && \ + chown -R 10001:0 /app + +LABEL org.opencontainers.image.title="customer-service-consumer-sb4" \ + org.opencontainers.image.description="Customer Service Consumer Spring Boot 4" \ + org.opencontainers.image.source="https://github.com/blueprint-platform/openapi-generics" \ + org.opencontainers.image.licenses="MIT" + +COPY --from=builder --chown=10001:0 /app/dependencies/ ./ +COPY --from=builder --chown=10001:0 /app/snapshot-dependencies/ ./ +COPY --from=builder --chown=10001:0 /app/spring-boot-loader/ ./ +COPY --from=builder --chown=10001:0 /app/application/ ./ + +USER 10001 + +ENV SPRING_PROFILES_ACTIVE=default \ + JAVA_TOOL_OPTIONS="-XX:MaxRAMPercentage=75 -XX:+UseG1GC -Dfile.encoding=UTF-8" + +EXPOSE 8095 +ENTRYPOINT ["java","org.springframework.boot.loader.launch.JarLauncher"] \ No newline at end of file diff --git a/samples/spring-boot-4/customer-service-consumer/README.md b/samples/spring-boot-4/customer-service-consumer/README.md new file mode 100644 index 0000000..6d319fe --- /dev/null +++ b/samples/spring-boot-4/customer-service-consumer/README.md @@ -0,0 +1,216 @@ +# customer-service-consumer (Spring Boot 4) + +> **Reference consumer (SB4): consuming a contract-aligned, generics-aware client with an alternative configuration style (no BYOC mapping)** + +--- + +## πŸ“‘ Table of Contents + +* 🎯 [What this module shows](#-what-this-module-shows) +* 🧠 [Why this variant exists](#-why-this-variant-exists) +* πŸ—οΈ [Structure](#-structure) +* πŸ”Œ [Integration boundary (unchanged)](#-integration-boundary-unchanged) +* 🧩 [Adapter model](#-adapter-model) +* βš–οΈ [Error handling](#-error-handling) +* πŸ”„ [Contract preservation](#-contract-preservation) +* βš™οΈ [Configuration highlights](#-configuration-highlights) +* πŸ§ͺ [Verify quickly](#-verify-quickly) +* πŸ”‘ [Key takeaway](#-key-takeaway) +* 🧾 [Summary](#-summary) +* πŸ›‘ [License](#-license) + +--- + +## 🎯 What this module shows + +This module demonstrates the **same contract pipeline** as SB3, with one intentional difference: + +> It shows a **pure contract reuse setup without explicit BYOC mapping** + +```text +Producer β†’ OpenAPI β†’ Generated Client β†’ Adapter β†’ Consumer Service +``` + +--- + +## 🧠 Why this variant exists + +In SB3 examples, you saw: + +```xml + + + openapiGenerics.responseContract.CustomerDto=... + + +``` + +In this SB4 version: + +❌ This mapping is NOT used + +This demonstrates that: + +> Contract alignment can still work **without explicit BYOC configuration**, depending on setup and classpath alignment + +--- + +## πŸ—οΈ Structure + +```text +Controller β†’ Service β†’ Client β†’ Adapter β†’ Generated API +``` + +Same architecture, same guarantees. + +--- + +## πŸ”Œ Integration boundary (unchanged) + +The boundary remains: + +```text +CustomerServiceClient +``` + +Responsibilities: + +* isolates generated code +* maps requests if needed +* handles exceptions +* preserves `ServiceResponse` + +--- + +## 🧩 Adapter model + +Generated client is still NOT used directly. + +```java +adapter.getCustomer(customerId) +``` + +This ensures: + +* regeneration safety +* no coupling to generator internals +* stable application layer + +--- + +## βš–οΈ Error handling + +Same model: + +```text +ProblemDetail (RFC 9457) +``` + +Handled via: + +```java +ApiProblemException β†’ mapped to domain exceptions +``` + +--- + +## πŸ”„ Contract preservation + +End-to-end contract remains identical: + +```java +ServiceResponse +ServiceResponse> +``` + +No: + +* DTO duplication +* envelope rewriting +* semantic drift + +--- + +## βš™οΈ Configuration highlights + +### Upstream API + +```yaml +customer: + api: + base-url: http://localhost:8094/customer-service +``` + +### Differences vs SB3 + +* Uses **Spring Boot 4.x** +* Uses newer Springdoc version +* Demonstrates **versioned endpoints (V1)** +* Shows **contract reuse without explicit mapping** + +--- + +## πŸ§ͺ Verify quickly + +Run consumer: + +```bash +mvn spring-boot:run +``` + +Call: + +```bash +curl http://localhost:8095/customer-service-consumer/customers/1 +``` + +Expected: + +```json +{ + "data": { ... }, + "meta": { ... } +} +``` + +--- + +## πŸ”‘ Key takeaway + +There are now **two valid integration styles** in the repo: + +### 1. Explicit mapping (BYOC) + +* full control +* explicit ownership + +### 2. Implicit reuse (this module) + +* simpler setup +* fewer config points + +Both preserve: + +```text +Contract identity β†’ end-to-end +``` + +--- + +## 🧾 Summary + +```text +Same pipeline +Same guarantees +Different configuration strategy +``` + +This module exists to show: + +> The system is flexible in adoption β€” not tied to a single configuration path + +--- + +## πŸ›‘ License + +MIT License diff --git a/samples/spring-boot-4/customer-service-consumer/pom.xml b/samples/spring-boot-4/customer-service-consumer/pom.xml new file mode 100644 index 0000000..a3f2efe --- /dev/null +++ b/samples/spring-boot-4/customer-service-consumer/pom.xml @@ -0,0 +1,179 @@ + + + 4.0.0 + + + org.springframework.boot + spring-boot-starter-parent + 4.0.5 + + + + io.github.blueprint-platform.samples + customer-service-consumer-sb4 + 0.9.0 + customer-service-consumer-sb4 + + Sample consumer service demonstrating end-to-end contract-aligned API consumption + using customer-service-client with type-safe generic responses. + + jar + https://github.com/blueprint-platform/openapi-generics + + + + MIT License + https://opensource.org/licenses/MIT + repo + + + + + https://github.com/blueprint-platform/openapi-generics + scm:git:https://github.com/blueprint-platform/openapi-generics.git + scm:git:ssh://git@github.com:blueprint-platform/openapi-generics.git + v0.9.0 + + + + + bsayli + Baris Sayli + https://github.com/bsayli + + + + + 21 + UTF-8 + UTF-8 + 0.9.0 + 0.9.0 + 0.9.0 + 3.0.2 + 0.8.14 + + + + + + + io.github.blueprint-platform + openapi-generics-server-starter + ${openapi-generics-server-starter.version} + + + + io.github.blueprint-platform.samples + customer-service-client-sb4 + ${customer-service-client.version} + + + + org.springdoc + springdoc-openapi-starter-webmvc-ui + ${springdoc-openapi-starter.version} + + + + org.springframework.boot + spring-boot-starter-web + + + + org.springframework.boot + spring-boot-starter-restclient + + + + org.springframework.boot + spring-boot-starter-validation + + + + org.springframework.boot + spring-boot-configuration-processor + true + + + + org.springframework.boot + spring-boot-starter-test + test + + + + + + + + + org.springframework.boot + spring-boot-maven-plugin + + io.github.blueprintplatform.samples.customerservice.consumer.CustomerServiceConsumerApplication + + true + + + + + + org.apache.maven.plugins + maven-dependency-plugin + + + + properties + + + + + + + org.apache.maven.plugins + maven-compiler-plugin + + ${java.version} + ${project.build.sourceEncoding} + + + + + org.apache.maven.plugins + maven-surefire-plugin + + @{argLine} -javaagent:${org.mockito:mockito-core:jar} + + **/*Test.java + + + + + + org.jacoco + jacoco-maven-plugin + ${jacoco-maven-plugin.version} + + + prepare-agent + + prepare-agent + + + + report + verify + + report + + + + + + + + + \ No newline at end of file diff --git a/samples/spring-boot-4/customer-service-consumer/src/main/java/io/github/blueprintplatform/samples/customerservice/consumer/CustomerServiceConsumerApplication.java b/samples/spring-boot-4/customer-service-consumer/src/main/java/io/github/blueprintplatform/samples/customerservice/consumer/CustomerServiceConsumerApplication.java new file mode 100644 index 0000000..b9261eb --- /dev/null +++ b/samples/spring-boot-4/customer-service-consumer/src/main/java/io/github/blueprintplatform/samples/customerservice/consumer/CustomerServiceConsumerApplication.java @@ -0,0 +1,16 @@ +package io.github.blueprintplatform.samples.customerservice.consumer; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; + +@SpringBootApplication( + scanBasePackages = { + "io.github.blueprintplatform.samples.customerservice.consumer", + "io.github.blueprintplatform.samples.customerservice.client" + }) +public class CustomerServiceConsumerApplication { + + public static void main(String[] args) { + SpringApplication.run(CustomerServiceConsumerApplication.class, args); + } +} diff --git a/samples/spring-boot-4/customer-service-consumer/src/main/java/io/github/blueprintplatform/samples/customerservice/consumer/api/controller/CustomerConsumerController.java b/samples/spring-boot-4/customer-service-consumer/src/main/java/io/github/blueprintplatform/samples/customerservice/consumer/api/controller/CustomerConsumerController.java new file mode 100644 index 0000000..8cbbc42 --- /dev/null +++ b/samples/spring-boot-4/customer-service-consumer/src/main/java/io/github/blueprintplatform/samples/customerservice/consumer/api/controller/CustomerConsumerController.java @@ -0,0 +1,76 @@ +package io.github.blueprintplatform.samples.customerservice.consumer.api.controller; + +import static io.github.blueprintplatform.samples.customerservice.consumer.api.version.ApiVersions.V1; + +import io.github.blueprintplatform.contracts.customer.CustomerDto; +import io.github.blueprintplatform.openapi.generics.contract.envelope.ServiceResponse; +import io.github.blueprintplatform.openapi.generics.contract.paging.Page; +import io.github.blueprintplatform.openapi.generics.contract.paging.SortDirection; +import io.github.blueprintplatform.samples.customerservice.client.customer.CustomerSortField; +import io.github.blueprintplatform.samples.customerservice.consumer.api.dto.CustomerConsumerCreateRequest; +import io.github.blueprintplatform.samples.customerservice.consumer.api.dto.CustomerConsumerSearchCriteria; +import io.github.blueprintplatform.samples.customerservice.consumer.api.dto.CustomerConsumerUpdateRequest; +import io.github.blueprintplatform.samples.customerservice.consumer.service.CustomerConsumerService; +import jakarta.validation.Valid; +import jakarta.validation.constraints.Max; +import jakarta.validation.constraints.Min; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.*; + +/** + * Exposes customer operations sourced from the upstream customer service. Delegates all business + * logic to CustomerConsumerService. + */ +@RestController +@RequestMapping(value = "/customers", produces = MediaType.APPLICATION_JSON_VALUE) +@Validated +public class CustomerConsumerController { + + private final CustomerConsumerService customerConsumerService; + + public CustomerConsumerController(CustomerConsumerService customerConsumerService) { + this.customerConsumerService = customerConsumerService; + } + + @PostMapping(consumes = MediaType.APPLICATION_JSON_VALUE, version = V1) + public ResponseEntity> createCustomer( + @Valid @RequestBody CustomerConsumerCreateRequest request) { + return ResponseEntity.status(HttpStatus.CREATED) + .body(customerConsumerService.createCustomer(request)); + } + + @GetMapping(path = "/{customerId}", version = V1) + public ResponseEntity> getCustomer( + @PathVariable @Min(1) Integer customerId) { + return ResponseEntity.ok(customerConsumerService.getCustomer(customerId)); + } + + @GetMapping(version = V1) + public ResponseEntity>> getCustomers( + @ModelAttribute CustomerConsumerSearchCriteria criteria, + @RequestParam(defaultValue = "0") @Min(0) int page, + @RequestParam(defaultValue = "5") @Min(1) @Max(10) int size, + @RequestParam(defaultValue = "customerId") CustomerSortField sortBy, + @RequestParam(defaultValue = "asc") SortDirection direction) { + return ResponseEntity.ok( + customerConsumerService.getCustomers( + criteria.name(), criteria.email(), page, size, sortBy, direction)); + } + + @PutMapping(path = "/{customerId}", version = V1, consumes = MediaType.APPLICATION_JSON_VALUE) + public ResponseEntity> updateCustomer( + @PathVariable @Min(1) Integer customerId, + @Valid @RequestBody CustomerConsumerUpdateRequest request) { + return ResponseEntity.ok(customerConsumerService.updateCustomer(customerId, request)); + } + + @DeleteMapping(path = "/{customerId}", version = V1) + @ResponseStatus(HttpStatus.NO_CONTENT) + public ResponseEntity deleteCustomer(@PathVariable @Min(1) Integer customerId) { + customerConsumerService.deleteCustomer(customerId); + return ResponseEntity.noContent().build(); + } +} diff --git a/samples/spring-boot-4/customer-service-consumer/src/main/java/io/github/blueprintplatform/samples/customerservice/consumer/api/dto/CustomerConsumerCreateRequest.java b/samples/spring-boot-4/customer-service-consumer/src/main/java/io/github/blueprintplatform/samples/customerservice/consumer/api/dto/CustomerConsumerCreateRequest.java new file mode 100644 index 0000000..43aa169 --- /dev/null +++ b/samples/spring-boot-4/customer-service-consumer/src/main/java/io/github/blueprintplatform/samples/customerservice/consumer/api/dto/CustomerConsumerCreateRequest.java @@ -0,0 +1,8 @@ +package io.github.blueprintplatform.samples.customerservice.consumer.api.dto; + +import jakarta.validation.constraints.Email; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; + +public record CustomerConsumerCreateRequest( + @NotBlank @Size(min = 2, max = 80) String name, @NotBlank @Email String email) {} diff --git a/samples/spring-boot-4/customer-service-consumer/src/main/java/io/github/blueprintplatform/samples/customerservice/consumer/api/dto/CustomerConsumerSearchCriteria.java b/samples/spring-boot-4/customer-service-consumer/src/main/java/io/github/blueprintplatform/samples/customerservice/consumer/api/dto/CustomerConsumerSearchCriteria.java new file mode 100644 index 0000000..c87d5c1 --- /dev/null +++ b/samples/spring-boot-4/customer-service-consumer/src/main/java/io/github/blueprintplatform/samples/customerservice/consumer/api/dto/CustomerConsumerSearchCriteria.java @@ -0,0 +1,6 @@ +package io.github.blueprintplatform.samples.customerservice.consumer.api.dto; + +import org.springdoc.core.annotations.ParameterObject; + +@ParameterObject +public record CustomerConsumerSearchCriteria(String name, String email) {} diff --git a/samples/spring-boot-4/customer-service-consumer/src/main/java/io/github/blueprintplatform/samples/customerservice/consumer/api/dto/CustomerConsumerUpdateRequest.java b/samples/spring-boot-4/customer-service-consumer/src/main/java/io/github/blueprintplatform/samples/customerservice/consumer/api/dto/CustomerConsumerUpdateRequest.java new file mode 100644 index 0000000..e1d8625 --- /dev/null +++ b/samples/spring-boot-4/customer-service-consumer/src/main/java/io/github/blueprintplatform/samples/customerservice/consumer/api/dto/CustomerConsumerUpdateRequest.java @@ -0,0 +1,8 @@ +package io.github.blueprintplatform.samples.customerservice.consumer.api.dto; + +import jakarta.validation.constraints.Email; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; + +public record CustomerConsumerUpdateRequest( + @NotBlank @Size(min = 2, max = 80) String name, @NotBlank @Email String email) {} diff --git a/samples/spring-boot-4/customer-service-consumer/src/main/java/io/github/blueprintplatform/samples/customerservice/consumer/api/error/CustomerConsumerExceptionHandler.java b/samples/spring-boot-4/customer-service-consumer/src/main/java/io/github/blueprintplatform/samples/customerservice/consumer/api/error/CustomerConsumerExceptionHandler.java new file mode 100644 index 0000000..158f4b4 --- /dev/null +++ b/samples/spring-boot-4/customer-service-consumer/src/main/java/io/github/blueprintplatform/samples/customerservice/consumer/api/error/CustomerConsumerExceptionHandler.java @@ -0,0 +1,64 @@ +package io.github.blueprintplatform.samples.customerservice.consumer.api.error; + +import io.github.blueprintplatform.samples.customerservice.consumer.common.exception.CustomerConsumerException; +import jakarta.servlet.http.HttpServletRequest; +import java.net.URI; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.core.annotation.Order; +import org.springframework.http.HttpStatus; +import org.springframework.http.ProblemDetail; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.RestControllerAdvice; +import org.springframework.web.util.UriComponentsBuilder; + +/** + * Handles consumer-level exceptions and produces RFC 9457 problem responses. Only consumer-owned + * exception types are handled here. + */ +@RestControllerAdvice +@Order(1) +public class CustomerConsumerExceptionHandler { + + private static final Logger log = LoggerFactory.getLogger(CustomerConsumerExceptionHandler.class); + + private static final String KEY_ERROR_CODE = "errorCode"; + private static final String PROBLEM_BASE = "urn:customer-service-consumer:problem:"; + private static final String ERROR_CODE_INTERNAL_ERROR = "INTERNAL_ERROR"; + + @ExceptionHandler(CustomerConsumerException.class) + public ProblemDetail handleCustomerConsumerException( + CustomerConsumerException ex, HttpServletRequest req) { + + log.warn("Consumer error [status={}, code={}]", ex.getStatus(), ex.getErrorCode()); + + ProblemDetail pd = ProblemDetail.forStatusAndDetail(ex.getStatus(), ex.getMessage()); + + pd.setType(URI.create(PROBLEM_BASE + "upstream-error")); + pd.setTitle("Upstream Error"); + pd.setInstance(instance(req)); + pd.setProperty(KEY_ERROR_CODE, ex.getErrorCode()); + + return pd; + } + + @ExceptionHandler(Exception.class) + public ProblemDetail handleGeneric(Exception ex, HttpServletRequest req) { + log.error("Unhandled exception", ex); + + ProblemDetail pd = + ProblemDetail.forStatusAndDetail( + HttpStatus.INTERNAL_SERVER_ERROR, "An unexpected error occurred."); + + pd.setType(URI.create(PROBLEM_BASE + "internal-error")); + pd.setTitle("Internal Server Error"); + pd.setInstance(instance(req)); + pd.setProperty(KEY_ERROR_CODE, ERROR_CODE_INTERNAL_ERROR); + + return pd; + } + + private URI instance(HttpServletRequest req) { + return UriComponentsBuilder.fromPath(req.getRequestURI()).build().toUri(); + } +} diff --git a/samples/spring-boot-4/customer-service-consumer/src/main/java/io/github/blueprintplatform/samples/customerservice/consumer/api/version/ApiVersions.java b/samples/spring-boot-4/customer-service-consumer/src/main/java/io/github/blueprintplatform/samples/customerservice/consumer/api/version/ApiVersions.java new file mode 100644 index 0000000..1c5c78b --- /dev/null +++ b/samples/spring-boot-4/customer-service-consumer/src/main/java/io/github/blueprintplatform/samples/customerservice/consumer/api/version/ApiVersions.java @@ -0,0 +1,7 @@ +package io.github.blueprintplatform.samples.customerservice.consumer.api.version; + +public final class ApiVersions { + public static final String V1 = "1.0"; + + private ApiVersions() {} +} diff --git a/samples/spring-boot-4/customer-service-consumer/src/main/java/io/github/blueprintplatform/samples/customerservice/consumer/common/exception/CustomerConsumerException.java b/samples/spring-boot-4/customer-service-consumer/src/main/java/io/github/blueprintplatform/samples/customerservice/consumer/common/exception/CustomerConsumerException.java new file mode 100644 index 0000000..3bf466e --- /dev/null +++ b/samples/spring-boot-4/customer-service-consumer/src/main/java/io/github/blueprintplatform/samples/customerservice/consumer/common/exception/CustomerConsumerException.java @@ -0,0 +1,56 @@ +package io.github.blueprintplatform.samples.customerservice.consumer.common.exception; + +import io.github.blueprintplatform.openapi.generics.contract.error.ErrorItem; +import java.io.Serial; +import java.io.Serializable; +import java.util.List; +import org.springframework.http.HttpStatus; + +/** + * Consumer-level exception wrapping upstream failures. Shields callers from client internals and + * carries normalized error context for handler and logging. + */ +public final class CustomerConsumerException extends RuntimeException implements Serializable { + + @Serial private static final long serialVersionUID = 1L; + + private final HttpStatus status; + private final String errorCode; + private final transient List errors; + + public CustomerConsumerException( + HttpStatus status, String errorCode, String message, List errors) { + super(message); + this.status = status; + this.errorCode = errorCode; + this.errors = errors != null ? List.copyOf(errors) : List.of(); + } + + public CustomerConsumerException( + HttpStatus status, + String errorCode, + String message, + List errors, + Throwable cause) { + super(message, cause); + this.status = status; + this.errorCode = errorCode; + this.errors = errors != null ? List.copyOf(errors) : List.of(); + } + + public HttpStatus getStatus() { + return status; + } + + public String getErrorCode() { + return errorCode; + } + + public List getErrors() { + return errors; + } + + public boolean hasErrors() { + return !errors.isEmpty(); + } +} diff --git a/samples/spring-boot-4/customer-service-consumer/src/main/java/io/github/blueprintplatform/samples/customerservice/consumer/common/mapper/CustomerConsumerExceptionMapper.java b/samples/spring-boot-4/customer-service-consumer/src/main/java/io/github/blueprintplatform/samples/customerservice/consumer/common/mapper/CustomerConsumerExceptionMapper.java new file mode 100644 index 0000000..58e7095 --- /dev/null +++ b/samples/spring-boot-4/customer-service-consumer/src/main/java/io/github/blueprintplatform/samples/customerservice/consumer/common/mapper/CustomerConsumerExceptionMapper.java @@ -0,0 +1,43 @@ +package io.github.blueprintplatform.samples.customerservice.consumer.common.mapper; + +import io.github.blueprintplatform.samples.customerservice.client.common.problem.ApiProblemException; +import io.github.blueprintplatform.samples.customerservice.consumer.common.exception.CustomerConsumerException; +import org.springframework.http.HttpStatus; +import org.springframework.stereotype.Component; + +/** + * Maps ApiProblemException to CustomerConsumerException, isolating consumer layers from client + * internals. + */ +@Component +public class CustomerConsumerExceptionMapper { + + private static final String FALLBACK_ERROR_CODE = "UPSTREAM_ERROR"; + private static final String FALLBACK_MESSAGE = "Upstream service returned an error."; + + public CustomerConsumerException from(ApiProblemException source) { + HttpStatus status = resolveStatus(source); + String errorCode = resolveErrorCode(source); + String message = resolveMessage(source); + + return new CustomerConsumerException(status, errorCode, message, source.getErrors(), source); + } + + private HttpStatus resolveStatus(ApiProblemException source) { + HttpStatus resolved = HttpStatus.resolve(source.getStatus()); + return resolved != null ? resolved : HttpStatus.INTERNAL_SERVER_ERROR; + } + + private String resolveErrorCode(ApiProblemException source) { + String code = source.getErrorCode(); + return (code != null && !code.isBlank()) ? code : FALLBACK_ERROR_CODE; + } + + private String resolveMessage(ApiProblemException source) { + if (source.getProblem() == null) { + return FALLBACK_MESSAGE; + } + String detail = source.getProblem().getDetail(); + return (detail != null && !detail.isBlank()) ? detail : FALLBACK_MESSAGE; + } +} diff --git a/samples/spring-boot-4/customer-service-consumer/src/main/java/io/github/blueprintplatform/samples/customerservice/consumer/common/openapi/OpenApiConfig.java b/samples/spring-boot-4/customer-service-consumer/src/main/java/io/github/blueprintplatform/samples/customerservice/consumer/common/openapi/OpenApiConfig.java new file mode 100644 index 0000000..cc05df1 --- /dev/null +++ b/samples/spring-boot-4/customer-service-consumer/src/main/java/io/github/blueprintplatform/samples/customerservice/consumer/common/openapi/OpenApiConfig.java @@ -0,0 +1,31 @@ +package io.github.blueprintplatform.samples.customerservice.consumer.common.openapi; + +import static io.github.blueprintplatform.samples.customerservice.consumer.common.openapi.OpenApiConstants.*; + +import io.swagger.v3.oas.models.OpenAPI; +import io.swagger.v3.oas.models.info.Info; +import io.swagger.v3.oas.models.servers.Server; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +@Configuration +public class OpenApiConfig { + + @Value("${app.openapi.version:${project.version:unknown}}") + private String version; + + @Value("${app.openapi.base-url:}") + private String baseUrl; + + @Bean + public OpenAPI customerServiceOpenAPI() { + var openapi = + new OpenAPI().info(new Info().title(TITLE).version(version).description(DESCRIPTION)); + + if (baseUrl != null && !baseUrl.isBlank()) { + openapi.addServersItem(new Server().url(baseUrl).description(SERVER_DESCRIPTION)); + } + return openapi; + } +} diff --git a/samples/spring-boot-4/customer-service-consumer/src/main/java/io/github/blueprintplatform/samples/customerservice/consumer/common/openapi/OpenApiConstants.java b/samples/spring-boot-4/customer-service-consumer/src/main/java/io/github/blueprintplatform/samples/customerservice/consumer/common/openapi/OpenApiConstants.java new file mode 100644 index 0000000..75df579 --- /dev/null +++ b/samples/spring-boot-4/customer-service-consumer/src/main/java/io/github/blueprintplatform/samples/customerservice/consumer/common/openapi/OpenApiConstants.java @@ -0,0 +1,10 @@ +package io.github.blueprintplatform.samples.customerservice.consumer.common.openapi; + +public final class OpenApiConstants { + public static final String TITLE = "Customer Service Consumer API"; + public static final String DESCRIPTION = + "Customer Service Consumer API with type-safe generic responses using OpenAPI"; + public static final String SERVER_DESCRIPTION = "Consumer Local service URL"; + + private OpenApiConstants() {} +} diff --git a/samples/spring-boot-4/customer-service-consumer/src/main/java/io/github/blueprintplatform/samples/customerservice/consumer/config/JacksonConfig.java b/samples/spring-boot-4/customer-service-consumer/src/main/java/io/github/blueprintplatform/samples/customerservice/consumer/config/JacksonConfig.java new file mode 100644 index 0000000..b4657f5 --- /dev/null +++ b/samples/spring-boot-4/customer-service-consumer/src/main/java/io/github/blueprintplatform/samples/customerservice/consumer/config/JacksonConfig.java @@ -0,0 +1,32 @@ +package io.github.blueprintplatform.samples.customerservice.consumer.config; + +import io.github.blueprintplatform.openapi.generics.contract.paging.SortDirection; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import tools.jackson.core.JacksonException; +import tools.jackson.core.JsonGenerator; +import tools.jackson.databind.SerializationContext; +import tools.jackson.databind.ValueSerializer; +import tools.jackson.databind.module.SimpleModule; + +@Configuration +public class JacksonConfig { + + @Bean + public SimpleModule sortDirectionModule() { + SimpleModule module = new SimpleModule(); + + module.addSerializer( + SortDirection.class, + new ValueSerializer<>() { + @Override + public void serialize(SortDirection value, JsonGenerator gen, SerializationContext ctxt) + throws JacksonException { + + gen.writeString(value.value()); + } + }); + + return module; + } +} diff --git a/samples/spring-boot-4/customer-service-consumer/src/main/java/io/github/blueprintplatform/samples/customerservice/consumer/config/WebConfig.java b/samples/spring-boot-4/customer-service-consumer/src/main/java/io/github/blueprintplatform/samples/customerservice/consumer/config/WebConfig.java new file mode 100644 index 0000000..bc21e38 --- /dev/null +++ b/samples/spring-boot-4/customer-service-consumer/src/main/java/io/github/blueprintplatform/samples/customerservice/consumer/config/WebConfig.java @@ -0,0 +1,36 @@ +package io.github.blueprintplatform.samples.customerservice.consumer.config; + +import io.github.blueprintplatform.openapi.generics.contract.paging.SortDirection; +import io.github.blueprintplatform.samples.customerservice.client.customer.CustomerSortField; +import io.github.blueprintplatform.samples.customerservice.consumer.config.version.ApiOnlyVersionResolver; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Configuration; +import org.springframework.format.FormatterRegistry; +import org.springframework.web.servlet.config.annotation.ApiVersionConfigurer; +import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; + +@Configuration +public class WebConfig implements WebMvcConfigurer { + + private static final String API_VERSION_HEADER = "API-Version"; + + @Value("${server.servlet.context-path:}") + private String contextPath; + + @Value("${app.api.base-path:/customers}") + private String apiBasePath; + + @Override + public void addFormatters(FormatterRegistry registry) { + registry.addConverter(String.class, CustomerSortField.class, CustomerSortField::from); + registry.addConverter(String.class, SortDirection.class, SortDirection::from); + } + + @Override + public void configureApiVersioning(ApiVersionConfigurer configurer) { + configurer + .useVersionResolver( + new ApiOnlyVersionResolver(API_VERSION_HEADER, contextPath, apiBasePath)) + .setVersionRequired(false); + } +} diff --git a/samples/spring-boot-4/customer-service-consumer/src/main/java/io/github/blueprintplatform/samples/customerservice/consumer/config/version/ApiOnlyVersionResolver.java b/samples/spring-boot-4/customer-service-consumer/src/main/java/io/github/blueprintplatform/samples/customerservice/consumer/config/version/ApiOnlyVersionResolver.java new file mode 100644 index 0000000..9d2bc2d --- /dev/null +++ b/samples/spring-boot-4/customer-service-consumer/src/main/java/io/github/blueprintplatform/samples/customerservice/consumer/config/version/ApiOnlyVersionResolver.java @@ -0,0 +1,38 @@ +package io.github.blueprintplatform.samples.customerservice.consumer.config.version; + +import jakarta.servlet.http.HttpServletRequest; +import org.springframework.web.accept.ApiVersionResolver; + +public class ApiOnlyVersionResolver implements ApiVersionResolver { + + private final String headerName; + private final String contextPath; + private final String apiBasePath; + + public ApiOnlyVersionResolver(String headerName, String contextPath, String apiBasePath) { + + this.headerName = headerName; + this.contextPath = normalize(contextPath); + this.apiBasePath = normalize(apiBasePath); + } + + @Override + public String resolveVersion(HttpServletRequest request) { + + String uri = request.getRequestURI(); + String fullApiPath = contextPath + apiBasePath; + + if (uri.equals(fullApiPath) || uri.startsWith(fullApiPath + "/")) { + return request.getHeader(headerName); + } + + return null; + } + + private String normalize(String path) { + if (path == null || path.isBlank()) { + return ""; + } + return path.startsWith("/") ? path : "/" + path; + } +} diff --git a/samples/spring-boot-4/customer-service-consumer/src/main/java/io/github/blueprintplatform/samples/customerservice/consumer/service/CustomerConsumerService.java b/samples/spring-boot-4/customer-service-consumer/src/main/java/io/github/blueprintplatform/samples/customerservice/consumer/service/CustomerConsumerService.java new file mode 100644 index 0000000..1bafc09 --- /dev/null +++ b/samples/spring-boot-4/customer-service-consumer/src/main/java/io/github/blueprintplatform/samples/customerservice/consumer/service/CustomerConsumerService.java @@ -0,0 +1,34 @@ +package io.github.blueprintplatform.samples.customerservice.consumer.service; + +import io.github.blueprintplatform.contracts.customer.CustomerDto; +import io.github.blueprintplatform.openapi.generics.contract.envelope.ServiceResponse; +import io.github.blueprintplatform.openapi.generics.contract.paging.Page; +import io.github.blueprintplatform.openapi.generics.contract.paging.SortDirection; +import io.github.blueprintplatform.samples.customerservice.client.customer.CustomerSortField; +import io.github.blueprintplatform.samples.customerservice.consumer.api.dto.CustomerConsumerCreateRequest; +import io.github.blueprintplatform.samples.customerservice.consumer.api.dto.CustomerConsumerUpdateRequest; + +/** + * Orchestration layer for customer operations. Coordinates remote calls via CustomerServiceClient + * and provides the integration point for cross-cutting concerns such as caching, fallback, or + * response aggregation. + */ +public interface CustomerConsumerService { + + ServiceResponse createCustomer(CustomerConsumerCreateRequest request); + + ServiceResponse getCustomer(Integer customerId); + + ServiceResponse> getCustomers( + String name, + String email, + Integer page, + Integer size, + CustomerSortField sortBy, + SortDirection direction); + + ServiceResponse updateCustomer( + Integer customerId, CustomerConsumerUpdateRequest request); + + ServiceResponse deleteCustomer(Integer customerId); +} diff --git a/samples/spring-boot-4/customer-service-consumer/src/main/java/io/github/blueprintplatform/samples/customerservice/consumer/service/client/CustomerServiceClient.java b/samples/spring-boot-4/customer-service-consumer/src/main/java/io/github/blueprintplatform/samples/customerservice/consumer/service/client/CustomerServiceClient.java new file mode 100644 index 0000000..e2c2cdb --- /dev/null +++ b/samples/spring-boot-4/customer-service-consumer/src/main/java/io/github/blueprintplatform/samples/customerservice/consumer/service/client/CustomerServiceClient.java @@ -0,0 +1,33 @@ +package io.github.blueprintplatform.samples.customerservice.consumer.service.client; + +import io.github.blueprintplatform.contracts.customer.CustomerDto; +import io.github.blueprintplatform.openapi.generics.contract.envelope.ServiceResponse; +import io.github.blueprintplatform.openapi.generics.contract.paging.Page; +import io.github.blueprintplatform.openapi.generics.contract.paging.SortDirection; +import io.github.blueprintplatform.samples.customerservice.client.customer.CustomerSortField; +import io.github.blueprintplatform.samples.customerservice.consumer.api.dto.CustomerConsumerCreateRequest; +import io.github.blueprintplatform.samples.customerservice.consumer.api.dto.CustomerConsumerUpdateRequest; + +/** + * Consumer-side boundary for customer service remote calls. Isolates the consumer from the client + * adapter, enabling independent testing and future evolution. + */ +public interface CustomerServiceClient { + + ServiceResponse createCustomer(CustomerConsumerCreateRequest request); + + ServiceResponse getCustomer(Integer customerId); + + ServiceResponse> getCustomers( + String name, + String email, + Integer page, + Integer size, + CustomerSortField sortBy, + SortDirection direction); + + ServiceResponse updateCustomer( + Integer customerId, CustomerConsumerUpdateRequest request); + + ServiceResponse deleteCustomer(Integer customerId); +} diff --git a/samples/spring-boot-4/customer-service-consumer/src/main/java/io/github/blueprintplatform/samples/customerservice/consumer/service/client/impl/CustomerServiceClientImpl.java b/samples/spring-boot-4/customer-service-consumer/src/main/java/io/github/blueprintplatform/samples/customerservice/consumer/service/client/impl/CustomerServiceClientImpl.java new file mode 100644 index 0000000..3358457 --- /dev/null +++ b/samples/spring-boot-4/customer-service-consumer/src/main/java/io/github/blueprintplatform/samples/customerservice/consumer/service/client/impl/CustomerServiceClientImpl.java @@ -0,0 +1,85 @@ +package io.github.blueprintplatform.samples.customerservice.consumer.service.client.impl; + +import io.github.blueprintplatform.contracts.customer.CustomerDto; +import io.github.blueprintplatform.openapi.generics.contract.envelope.ServiceResponse; +import io.github.blueprintplatform.openapi.generics.contract.paging.Page; +import io.github.blueprintplatform.openapi.generics.contract.paging.SortDirection; +import io.github.blueprintplatform.samples.customerservice.client.adapter.CustomerClientAdapter; +import io.github.blueprintplatform.samples.customerservice.client.common.problem.ApiProblemException; +import io.github.blueprintplatform.samples.customerservice.client.customer.CustomerSortField; +import io.github.blueprintplatform.samples.customerservice.consumer.api.dto.CustomerConsumerCreateRequest; +import io.github.blueprintplatform.samples.customerservice.consumer.api.dto.CustomerConsumerUpdateRequest; +import io.github.blueprintplatform.samples.customerservice.consumer.common.mapper.CustomerConsumerExceptionMapper; +import io.github.blueprintplatform.samples.customerservice.consumer.service.client.CustomerServiceClient; +import io.github.blueprintplatform.samples.customerservice.consumer.service.client.mapper.CustomerRequestMapper; +import org.springframework.stereotype.Service; + +@Service +public class CustomerServiceClientImpl implements CustomerServiceClient { + + private final CustomerClientAdapter adapter; + private final CustomerConsumerExceptionMapper exceptionMapper; + private final CustomerRequestMapper requestMapper; + + public CustomerServiceClientImpl( + CustomerClientAdapter adapter, + CustomerConsumerExceptionMapper exceptionMapper, + CustomerRequestMapper requestMapper) { + this.adapter = adapter; + this.exceptionMapper = exceptionMapper; + this.requestMapper = requestMapper; + } + + @Override + public ServiceResponse createCustomer( + CustomerConsumerCreateRequest customerConsumerCreateRequest) { + try { + return adapter.createCustomer(requestMapper.from(customerConsumerCreateRequest)); + } catch (ApiProblemException ex) { + throw exceptionMapper.from(ex); + } + } + + @Override + public ServiceResponse getCustomer(Integer customerId) { + try { + return adapter.getCustomer(customerId); + } catch (ApiProblemException ex) { + throw exceptionMapper.from(ex); + } + } + + @Override + public ServiceResponse> getCustomers( + String name, + String email, + Integer page, + Integer size, + CustomerSortField sortBy, + SortDirection direction) { + try { + return adapter.getCustomers(name, email, page, size, sortBy, direction); + } catch (ApiProblemException ex) { + throw exceptionMapper.from(ex); + } + } + + @Override + public ServiceResponse updateCustomer( + Integer customerId, CustomerConsumerUpdateRequest consumerUpdateRequest) { + try { + return adapter.updateCustomer(customerId, requestMapper.from(consumerUpdateRequest)); + } catch (ApiProblemException ex) { + throw exceptionMapper.from(ex); + } + } + + @Override + public ServiceResponse deleteCustomer(Integer customerId) { + try { + return adapter.deleteCustomer(customerId); + } catch (ApiProblemException ex) { + throw exceptionMapper.from(ex); + } + } +} diff --git a/samples/spring-boot-4/customer-service-consumer/src/main/java/io/github/blueprintplatform/samples/customerservice/consumer/service/client/mapper/CustomerRequestMapper.java b/samples/spring-boot-4/customer-service-consumer/src/main/java/io/github/blueprintplatform/samples/customerservice/consumer/service/client/mapper/CustomerRequestMapper.java new file mode 100644 index 0000000..e08266d --- /dev/null +++ b/samples/spring-boot-4/customer-service-consumer/src/main/java/io/github/blueprintplatform/samples/customerservice/consumer/service/client/mapper/CustomerRequestMapper.java @@ -0,0 +1,27 @@ +package io.github.blueprintplatform.samples.customerservice.consumer.service.client.mapper; + +import io.github.blueprintplatform.samples.customerservice.client.generated.dto.CustomerCreateRequest; +import io.github.blueprintplatform.samples.customerservice.client.generated.dto.CustomerUpdateRequest; +import io.github.blueprintplatform.samples.customerservice.consumer.api.dto.CustomerConsumerCreateRequest; +import io.github.blueprintplatform.samples.customerservice.consumer.api.dto.CustomerConsumerUpdateRequest; +import org.springframework.stereotype.Component; + +@Component +public class CustomerRequestMapper { + + public CustomerCreateRequest from(CustomerConsumerCreateRequest source) { + if (source == null) { + return null; + } + + return new CustomerCreateRequest().name(source.name()).email(source.email()); + } + + public CustomerUpdateRequest from(CustomerConsumerUpdateRequest source) { + if (source == null) { + return null; + } + + return new CustomerUpdateRequest().name(source.name()).email(source.email()); + } +} diff --git a/samples/spring-boot-4/customer-service-consumer/src/main/java/io/github/blueprintplatform/samples/customerservice/consumer/service/impl/CustomerConsumerServiceImpl.java b/samples/spring-boot-4/customer-service-consumer/src/main/java/io/github/blueprintplatform/samples/customerservice/consumer/service/impl/CustomerConsumerServiceImpl.java new file mode 100644 index 0000000..d59ded9 --- /dev/null +++ b/samples/spring-boot-4/customer-service-consumer/src/main/java/io/github/blueprintplatform/samples/customerservice/consumer/service/impl/CustomerConsumerServiceImpl.java @@ -0,0 +1,54 @@ +package io.github.blueprintplatform.samples.customerservice.consumer.service.impl; + +import io.github.blueprintplatform.contracts.customer.CustomerDto; +import io.github.blueprintplatform.openapi.generics.contract.envelope.ServiceResponse; +import io.github.blueprintplatform.openapi.generics.contract.paging.Page; +import io.github.blueprintplatform.openapi.generics.contract.paging.SortDirection; +import io.github.blueprintplatform.samples.customerservice.client.customer.CustomerSortField; +import io.github.blueprintplatform.samples.customerservice.consumer.api.dto.CustomerConsumerCreateRequest; +import io.github.blueprintplatform.samples.customerservice.consumer.api.dto.CustomerConsumerUpdateRequest; +import io.github.blueprintplatform.samples.customerservice.consumer.service.CustomerConsumerService; +import io.github.blueprintplatform.samples.customerservice.consumer.service.client.CustomerServiceClient; +import org.springframework.stereotype.Service; + +@Service +public class CustomerConsumerServiceImpl implements CustomerConsumerService { + + private final CustomerServiceClient customerServiceClient; + + public CustomerConsumerServiceImpl(CustomerServiceClient customerServiceClient) { + this.customerServiceClient = customerServiceClient; + } + + @Override + public ServiceResponse createCustomer(CustomerConsumerCreateRequest request) { + return customerServiceClient.createCustomer(request); + } + + @Override + public ServiceResponse getCustomer(Integer customerId) { + return customerServiceClient.getCustomer(customerId); + } + + @Override + public ServiceResponse> getCustomers( + String name, + String email, + Integer page, + Integer size, + CustomerSortField sortBy, + SortDirection direction) { + return customerServiceClient.getCustomers(name, email, page, size, sortBy, direction); + } + + @Override + public ServiceResponse updateCustomer( + Integer customerId, CustomerConsumerUpdateRequest request) { + return customerServiceClient.updateCustomer(customerId, request); + } + + @Override + public ServiceResponse deleteCustomer(Integer customerId) { + return customerServiceClient.deleteCustomer(customerId); + } +} diff --git a/samples/spring-boot-4/customer-service-consumer/src/main/resources/application.yml b/samples/spring-boot-4/customer-service-consumer/src/main/resources/application.yml new file mode 100644 index 0000000..f1f00a8 --- /dev/null +++ b/samples/spring-boot-4/customer-service-consumer/src/main/resources/application.yml @@ -0,0 +1,47 @@ +server: + port: ${APP_PORT:8095} + servlet: + context-path: /customer-service-consumer + +spring: + jackson: + deserialization: + fail-on-unknown-properties: true + mvc: + problemdetails: + enabled: true + application: + name: customer-service-consumer + profiles: + active: ${SPRING_PROFILES_ACTIVE:local} + web: + error: + include-message: always + include-binding-errors: always + include-stacktrace: never + include-exception: false + +logging: + level: + root: INFO + org.springframework.web: INFO + io.github.blueprintplatform: DEBUG + +app: + openapi: + version: @project.version@ + base-url: "http://localhost:${server.port}${server.servlet.context-path:}" + wrapper: + # Optional: extra annotation for generated client models + # class-extra-annotation: "@com.fasterxml.jackson.annotation.JsonIgnoreProperties(ignoreUnknown = true)" + api: + base-path: /customers + +customer: + api: + base-url: ${CUSTOMER_API_BASE_URL:http://localhost:8094/customer-service} + max-connections-total: 64 + max-connections-per-route: 16 + connect-timeout-seconds: 10 + connection-request-timeout-seconds: 10 + read-timeout-seconds: 15 \ No newline at end of file diff --git a/samples/spring-boot-4/customer-service-consumer/src/test/java/consumer/common/mapper/CustomerConsumerExceptionMapperTest.java b/samples/spring-boot-4/customer-service-consumer/src/test/java/consumer/common/mapper/CustomerConsumerExceptionMapperTest.java new file mode 100644 index 0000000..0e961aa --- /dev/null +++ b/samples/spring-boot-4/customer-service-consumer/src/test/java/consumer/common/mapper/CustomerConsumerExceptionMapperTest.java @@ -0,0 +1,89 @@ +package consumer.common.mapper; + +import static org.junit.jupiter.api.Assertions.*; + +import io.github.blueprintplatform.samples.customerservice.client.common.problem.ApiProblemException; +import io.github.blueprintplatform.samples.customerservice.consumer.common.exception.CustomerConsumerException; +import io.github.blueprintplatform.samples.customerservice.consumer.common.mapper.CustomerConsumerExceptionMapper; +import java.util.Map; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; +import org.springframework.http.HttpStatus; +import org.springframework.http.ProblemDetail; + +@Tag("unit") +@DisplayName("Unit Test: CustomerConsumerExceptionMapper") +class CustomerConsumerExceptionMapperTest { + + private final CustomerConsumerExceptionMapper mapper = new CustomerConsumerExceptionMapper(); + + @Test + @DisplayName("from -> maps status, errorCode and message correctly") + void shouldMapAllFields() { + ProblemDetail problem = + ProblemDetail.forStatusAndDetail(HttpStatus.BAD_REQUEST, "Bad request detail"); + problem.setProperty("errorCode", "VALIDATION_FAILED"); + + ApiProblemException source = new ApiProblemException(problem, HttpStatus.BAD_REQUEST.value()); + + CustomerConsumerException result = mapper.from(source); + + assertNotNull(result); + assertEquals(HttpStatus.BAD_REQUEST, result.getStatus()); + assertEquals("VALIDATION_FAILED", result.getErrorCode()); + assertEquals("Bad request detail", result.getMessage()); + assertSame(source, result.getCause()); + } + + @Test + @DisplayName("from -> uses fallback errorCode when missing") + void shouldUseFallbackErrorCode() { + ProblemDetail problem = + ProblemDetail.forStatusAndDetail(HttpStatus.BAD_REQUEST, "Bad request detail"); + + ApiProblemException source = new ApiProblemException(problem, HttpStatus.BAD_REQUEST.value()); + + CustomerConsumerException result = mapper.from(source); + + assertEquals("UPSTREAM_ERROR", result.getErrorCode()); + } + + @Test + @DisplayName("from -> uses fallback message when problem is null") + void shouldUseFallbackMessageWhenProblemNull() { + ApiProblemException source = new ApiProblemException(null, HttpStatus.BAD_REQUEST.value()); + + CustomerConsumerException result = mapper.from(source); + + assertEquals("Upstream service returned an error.", result.getMessage()); + } + + @Test + @DisplayName("from -> uses fallback message when detail is blank") + void shouldUseFallbackMessageWhenDetailBlank() { + ProblemDetail problem = ProblemDetail.forStatus(HttpStatus.BAD_REQUEST); + problem.setDetail(""); + problem.setProperty("errorCode", "ERR"); + + ApiProblemException source = new ApiProblemException(problem, HttpStatus.BAD_REQUEST.value()); + + CustomerConsumerException result = mapper.from(source); + + assertEquals("Upstream service returned an error.", result.getMessage()); + } + + @Test + @DisplayName("from -> uses INTERNAL_SERVER_ERROR when status invalid") + void shouldFallbackToInternalServerError() { + ProblemDetail problem = ProblemDetail.forStatus(HttpStatus.BAD_REQUEST); + problem.setDetail("Any detail"); + problem.setProperties(Map.of("errorCode", "ERR")); + + ApiProblemException source = new ApiProblemException(problem, 999); + + CustomerConsumerException result = mapper.from(source); + + assertEquals(HttpStatus.INTERNAL_SERVER_ERROR, result.getStatus()); + } +} diff --git a/samples/spring-boot-4/customer-service-consumer/src/test/java/consumer/service/client/impl/CustomerServiceClientImplTest.java b/samples/spring-boot-4/customer-service-consumer/src/test/java/consumer/service/client/impl/CustomerServiceClientImplTest.java new file mode 100644 index 0000000..542ab20 --- /dev/null +++ b/samples/spring-boot-4/customer-service-consumer/src/test/java/consumer/service/client/impl/CustomerServiceClientImplTest.java @@ -0,0 +1,154 @@ +package consumer.service.client.impl; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +import io.github.blueprintplatform.contracts.customer.CustomerDto; +import io.github.blueprintplatform.openapi.generics.contract.envelope.ServiceResponse; +import io.github.blueprintplatform.openapi.generics.contract.paging.Page; +import io.github.blueprintplatform.openapi.generics.contract.paging.SortDirection; +import io.github.blueprintplatform.samples.customerservice.client.adapter.CustomerClientAdapter; +import io.github.blueprintplatform.samples.customerservice.client.common.problem.ApiProblemException; +import io.github.blueprintplatform.samples.customerservice.client.customer.CustomerSortField; +import io.github.blueprintplatform.samples.customerservice.client.generated.dto.CustomerCreateRequest; +import io.github.blueprintplatform.samples.customerservice.client.generated.dto.CustomerUpdateRequest; +import io.github.blueprintplatform.samples.customerservice.consumer.api.dto.CustomerConsumerCreateRequest; +import io.github.blueprintplatform.samples.customerservice.consumer.api.dto.CustomerConsumerUpdateRequest; +import io.github.blueprintplatform.samples.customerservice.consumer.common.exception.CustomerConsumerException; +import io.github.blueprintplatform.samples.customerservice.consumer.common.mapper.CustomerConsumerExceptionMapper; +import io.github.blueprintplatform.samples.customerservice.consumer.service.client.impl.CustomerServiceClientImpl; +import io.github.blueprintplatform.samples.customerservice.consumer.service.client.mapper.CustomerRequestMapper; +import java.util.List; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; + +@Tag("unit") +@DisplayName("Unit Test: CustomerServiceClientImpl") +class CustomerServiceClientImplTest { + + private final CustomerClientAdapter adapter = mock(CustomerClientAdapter.class); + private final CustomerConsumerExceptionMapper exceptionMapper = + mock(CustomerConsumerExceptionMapper.class); + private final CustomerRequestMapper requestMapper = mock(CustomerRequestMapper.class); + + private final CustomerServiceClientImpl service = + new CustomerServiceClientImpl(adapter, exceptionMapper, requestMapper); + + @Test + @DisplayName("createCustomer -> maps request and returns response") + void createCustomer_success() { + var consumerReq = new CustomerConsumerCreateRequest("John", "john@mail.com"); + var mappedReq = new CustomerCreateRequest().name("John").email("john@mail.com"); + + var dto = new CustomerDto(1, "John", "john@mail.com"); + var response = ServiceResponse.of(dto); + + when(requestMapper.from(consumerReq)).thenReturn(mappedReq); + when(adapter.createCustomer(mappedReq)).thenReturn(response); + + var result = service.createCustomer(consumerReq); + + assertNotNull(result); + assertEquals(dto, result.getData()); + + verify(requestMapper).from(consumerReq); + verify(adapter).createCustomer(mappedReq); + } + + @Test + @DisplayName("createCustomer -> maps exception") + void createCustomer_exception() { + var consumerReq = new CustomerConsumerCreateRequest("John", "john@mail.com"); + var mappedReq = new CustomerCreateRequest(); + + var apiEx = mock(ApiProblemException.class); + var mappedEx = mock(CustomerConsumerException.class); + + when(requestMapper.from(consumerReq)).thenReturn(mappedReq); + when(adapter.createCustomer(mappedReq)).thenThrow(apiEx); + when(exceptionMapper.from(apiEx)).thenReturn(mappedEx); + + var thrown = + assertThrows(CustomerConsumerException.class, () -> service.createCustomer(consumerReq)); + + assertSame(mappedEx, thrown); + } + + @Test + @DisplayName("getCustomer -> success") + void getCustomer_success() { + var dto = new CustomerDto(1, "John", "john@mail.com"); + var response = ServiceResponse.of(dto); + + when(adapter.getCustomer(1)).thenReturn(response); + + var result = service.getCustomer(1); + + assertEquals(dto, result.getData()); + verify(adapter).getCustomer(1); + } + + @Test + @DisplayName("getCustomers -> success") + void getCustomers_success() { + var dto = new CustomerDto(1, "John", "john@mail.com"); + var page = Page.of(List.of(dto), 0, 5, 1); + var response = ServiceResponse.of(page); + + when(adapter.getCustomers(any(), any(), any(), any(), any(), any())).thenReturn(response); + + var result = + service.getCustomers("John", null, 0, 5, CustomerSortField.CUSTOMER_ID, SortDirection.ASC); + + assertNotNull(result); + assertEquals(1, result.getData().content().size()); + } + + @Test + @DisplayName("updateCustomer -> maps request") + void updateCustomer_success() { + var consumerReq = new CustomerConsumerUpdateRequest("Jane", "jane@mail.com"); + var mappedReq = new CustomerUpdateRequest().name("Jane").email("jane@mail.com"); + + var dto = new CustomerDto(1, "Jane", "jane@mail.com"); + var response = ServiceResponse.of(dto); + + when(requestMapper.from(consumerReq)).thenReturn(mappedReq); + when(adapter.updateCustomer(1, mappedReq)).thenReturn(response); + + var result = service.updateCustomer(1, consumerReq); + + assertEquals(dto, result.getData()); + + verify(requestMapper).from(consumerReq); + verify(adapter).updateCustomer(1, mappedReq); + } + + @Test + @DisplayName("deleteCustomer -> success") + void deleteCustomer_success() { + ServiceResponse response = ServiceResponse.of(null); + + when(adapter.deleteCustomer(1)).thenReturn(response); + + var result = service.deleteCustomer(1); + + assertNotNull(result); + verify(adapter).deleteCustomer(1); + } + + @Test + @DisplayName("deleteCustomer -> exception mapped") + void deleteCustomer_exception() { + var apiEx = mock(ApiProblemException.class); + var mappedEx = mock(CustomerConsumerException.class); + + when(adapter.deleteCustomer(1)).thenThrow(apiEx); + when(exceptionMapper.from(apiEx)).thenReturn(mappedEx); + + var thrown = assertThrows(CustomerConsumerException.class, () -> service.deleteCustomer(1)); + + assertSame(mappedEx, thrown); + } +} diff --git a/samples/spring-boot-4/customer-service-consumer/src/test/java/consumer/service/client/mapper/CustomerRequestMapperTest.java b/samples/spring-boot-4/customer-service-consumer/src/test/java/consumer/service/client/mapper/CustomerRequestMapperTest.java new file mode 100644 index 0000000..72e7bd5 --- /dev/null +++ b/samples/spring-boot-4/customer-service-consumer/src/test/java/consumer/service/client/mapper/CustomerRequestMapperTest.java @@ -0,0 +1,50 @@ +package consumer.service.client.mapper; + +import static org.junit.jupiter.api.Assertions.*; + +import io.github.blueprintplatform.samples.customerservice.client.generated.dto.CustomerCreateRequest; +import io.github.blueprintplatform.samples.customerservice.client.generated.dto.CustomerUpdateRequest; +import io.github.blueprintplatform.samples.customerservice.consumer.api.dto.CustomerConsumerCreateRequest; +import io.github.blueprintplatform.samples.customerservice.consumer.api.dto.CustomerConsumerUpdateRequest; +import io.github.blueprintplatform.samples.customerservice.consumer.service.client.mapper.CustomerRequestMapper; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; + +@Tag("unit") +@DisplayName("Unit Test: CustomerRequestMapper") +class CustomerRequestMapperTest { + + private final CustomerRequestMapper mapper = new CustomerRequestMapper(); + + @Test + @DisplayName("from(CustomerConsumerCreateRequest) -> maps fields correctly") + void shouldMapCreateRequest() { + var source = new CustomerConsumerCreateRequest("John", "john@example.com"); + + CustomerCreateRequest result = mapper.from(source); + + assertNotNull(result); + assertEquals("John", result.getName()); + assertEquals("john@example.com", result.getEmail()); + } + + @Test + @DisplayName("from(CustomerConsumerUpdateRequest) -> maps fields correctly") + void shouldMapUpdateRequest() { + var source = new CustomerConsumerUpdateRequest("Jane", "jane@example.com"); + + CustomerUpdateRequest result = mapper.from(source); + + assertNotNull(result); + assertEquals("Jane", result.getName()); + assertEquals("jane@example.com", result.getEmail()); + } + + @Test + @DisplayName("from(null) -> returns null") + void shouldReturnNullWhenSourceIsNull() { + assertNull(mapper.from((CustomerConsumerCreateRequest) null)); + assertNull(mapper.from((CustomerConsumerUpdateRequest) null)); + } +} diff --git a/samples/spring-boot-4/customer-service/.dockerignore b/samples/spring-boot-4/customer-service/.dockerignore new file mode 100644 index 0000000..efd6206 --- /dev/null +++ b/samples/spring-boot-4/customer-service/.dockerignore @@ -0,0 +1,5 @@ +target/ +.git +.gitignore +Dockerfile +README.md \ No newline at end of file diff --git a/samples/spring-boot-4/customer-service/Dockerfile b/samples/spring-boot-4/customer-service/Dockerfile new file mode 100644 index 0000000..5b7052e --- /dev/null +++ b/samples/spring-boot-4/customer-service/Dockerfile @@ -0,0 +1,35 @@ +FROM maven:3.9.12-eclipse-temurin-21-noble AS builder +WORKDIR /app + +COPY pom.xml pom.xml +COPY src src + +RUN --mount=type=cache,target=/root/.m2,sharing=locked \ + mvn -q -T 2C -DskipTests --no-transfer-progress clean package && \ + JAR="$(ls -1 target/customer-service-*.jar | head -n 1)" && \ + java -Djarmode=layertools -jar "$JAR" extract + +FROM eclipse-temurin:21.0.10_7-jre-noble +WORKDIR /app + +RUN useradd -r -u 10001 -g root -s /bin/false -d /app appuser && \ + mkdir -p /app/logs && \ + chown -R 10001:0 /app + +LABEL org.opencontainers.image.title="customer-service-sb4" \ + org.opencontainers.image.description="Customer Service Spring Boot 4" \ + org.opencontainers.image.source="https://github.com/blueprint-platform/openapi-generics" \ + org.opencontainers.image.licenses="MIT" + +COPY --from=builder --chown=10001:0 /app/dependencies/ ./ +COPY --from=builder --chown=10001:0 /app/snapshot-dependencies/ ./ +COPY --from=builder --chown=10001:0 /app/spring-boot-loader/ ./ +COPY --from=builder --chown=10001:0 /app/application/ ./ + +USER 10001 + +ENV SPRING_PROFILES_ACTIVE=default \ + JAVA_TOOL_OPTIONS="-XX:MaxRAMPercentage=75 -XX:+UseG1GC -Dfile.encoding=UTF-8" + +EXPOSE 8094 +ENTRYPOINT ["java","org.springframework.boot.loader.launch.JarLauncher"] \ No newline at end of file diff --git a/samples/customer-service/README.md b/samples/spring-boot-4/customer-service/README.md similarity index 68% rename from samples/customer-service/README.md rename to samples/spring-boot-4/customer-service/README.md index 3785c2e..315c81d 100644 --- a/samples/customer-service/README.md +++ b/samples/spring-boot-4/customer-service/README.md @@ -3,9 +3,9 @@ > **Reference implementation: exposing a Spring Boot API that produces a clean, deterministic OpenAPI for contract-aligned, generics-aware clients** [![Java 21](https://img.shields.io/badge/Java-21-red?logo=openjdk)](https://openjdk.org/projects/jdk/21/) -[![Spring Boot](https://img.shields.io/badge/Spring%20Boot-3.5.13-green?logo=springboot)](https://spring.io/projects/spring-boot) -[![Springdoc](https://img.shields.io/badge/Springdoc-2.8.16-brightgreen)](https://springdoc.org/) -[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](../../LICENSE) +[![Spring Boot](https://img.shields.io/badge/Spring%20Boot-4.x-green?logo=springboot)](https://spring.io/projects/spring-boot) +[![Springdoc](https://img.shields.io/badge/Springdoc-3.x-brightgreen)](https://springdoc.org/) +[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](../../../LICENSE) --- @@ -20,7 +20,7 @@ --- > This is a minimal reference implementation. -> See the [Adoption Guides](../../docs/adoption/server-side-adoption.md) for rules, constraints, and architecture. +> See the [Adoption Guides](../../../docs/adoption/server-side-adoption.md) for rules, constraints, and architecture. ## πŸš€ Start here (what you actually want) @@ -61,9 +61,9 @@ Everything is handled by the starter. ## πŸ”— Related Modules -* **[Contract](../../openapi-generics-contract/README.md)** -* **[Server Starter](../../openapi-generics-server-starter/README.md)** -* **[Client Codegen](../../openapi-generics-java-codegen-parent/README.md)** +* **[Contract](../../../openapi-generics-contract/README.md)** +* **[Server Starter](../../../openapi-generics-server-starter/README.md)** +* **[Client Codegen](../../../openapi-generics-java-codegen-parent/README.md)** * **[Client Sample](../customer-service-client/README.md)** --- @@ -71,7 +71,7 @@ Everything is handled by the starter. ## πŸ§ͺ Verify quickly ```bash -curl http://localhost:8084/customer-service/v1/customers/1 +curl http://localhost:8094/customer-service/customers/1 ``` Expected: @@ -101,10 +101,10 @@ Contract β†’ OpenAPI β†’ Client will be correct ## 🌐 OpenAPI endpoints * Swagger UI - [http://localhost:8084/customer-service/swagger-ui/index.html](http://localhost:8084/customer-service/swagger-ui/index.html) + [http://localhost:8094/customer-service/swagger-ui/index.html](http://localhost:8094/customer-service/swagger-ui/index.html) * OpenAPI YAML - [http://localhost:8084/customer-service/v3/api-docs.yaml](http://localhost:8084/customer-service/v3/api-docs.yaml) + [http://localhost:8094/customer-service/v3/api-docs.yaml](http://localhost:8094/customer-service/v3/api-docs.yaml) --- diff --git a/samples/spring-boot-4/customer-service/pom.xml b/samples/spring-boot-4/customer-service/pom.xml new file mode 100644 index 0000000..a1447a3 --- /dev/null +++ b/samples/spring-boot-4/customer-service/pom.xml @@ -0,0 +1,210 @@ + + + 4.0.0 + + + org.springframework.boot + spring-boot-starter-parent + 4.0.5 + + + + io.github.blueprint-platform.samples + customer-service-sb4 + 0.9.0 + customer-service-sb4 + Spring Boot reference producer for the OpenAPI Generics platform + https://github.com/blueprint-platform/openapi-generics + + + + MIT License + https://opensource.org/licenses/MIT + repo + + + + + https://github.com/blueprint-platform/openapi-generics + scm:git:https://github.com/blueprint-platform/openapi-generics.git + scm:git:ssh://git@github.com:blueprint-platform/openapi-generics.git + v0.9.0 + + + + + bsayli + Baris Sayli + https://github.com/bsayli + + + + + 21 + UTF-8 + UTF-8 + 0.9.0 + 0.9.0 + 3.0.2 + 0.8.14 + + + + + + + io.github.blueprint-platform + openapi-generics-server-starter + ${openapi-generics-server-starter.version} + + + + io.github.blueprint-platform.samples + customer-contract + ${customer-contract.version} + + + + org.springframework.boot + spring-boot-starter-web + + + + org.springframework.boot + spring-boot-starter-validation + + + + org.springdoc + springdoc-openapi-starter-webmvc-ui + ${springdoc-openapi-starter.version} + + + + org.springframework.boot + spring-boot-configuration-processor + true + + + + org.springframework.boot + spring-boot-starter-test + test + + + + org.springframework.boot + spring-boot-starter-webmvc-test + test + + + + + + + + + org.springframework.boot + spring-boot-maven-plugin + + io.github.blueprintplatform.samples.customerservice.CustomerServiceApplication + + true + + + + + + org.apache.maven.plugins + maven-dependency-plugin + + + + properties + + + + + + + org.apache.maven.plugins + maven-compiler-plugin + + ${java.version} + ${project.build.sourceEncoding} + + + + + org.apache.maven.plugins + maven-surefire-plugin + + @{argLine} -javaagent:${org.mockito:mockito-core:jar} + + **/*Test.java + + + + + + org.apache.maven.plugins + maven-failsafe-plugin + + + default + + integration-test + verify + + + @{argLine} -javaagent:${org.mockito:mockito-core:jar} + + **/*IT.java + + + + + + + + org.jacoco + jacoco-maven-plugin + ${jacoco-maven-plugin.version} + + + prepare-agent + + prepare-agent + + + + + prepare-agent-integration + + prepare-agent-integration + + + + + report + verify + + report + + + + + report-integration + verify + + report-integration + + + + + + + + \ No newline at end of file diff --git a/samples/spring-boot-4/customer-service/src/main/java/io/github/blueprintplatform/samples/customerservice/CustomerServiceApplication.java b/samples/spring-boot-4/customer-service/src/main/java/io/github/blueprintplatform/samples/customerservice/CustomerServiceApplication.java new file mode 100644 index 0000000..376c0fd --- /dev/null +++ b/samples/spring-boot-4/customer-service/src/main/java/io/github/blueprintplatform/samples/customerservice/CustomerServiceApplication.java @@ -0,0 +1,13 @@ +package io.github.blueprintplatform.samples.customerservice; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.boot.context.properties.ConfigurationPropertiesScan; + +@SpringBootApplication +@ConfigurationPropertiesScan(basePackages = "io.github.blueprintplatform.samples.customerservice") +public class CustomerServiceApplication { + public static void main(String[] args) { + SpringApplication.run(CustomerServiceApplication.class, args); + } +} diff --git a/samples/spring-boot-4/customer-service/src/main/java/io/github/blueprintplatform/samples/customerservice/api/controller/CustomerController.java b/samples/spring-boot-4/customer-service/src/main/java/io/github/blueprintplatform/samples/customerservice/api/controller/CustomerController.java new file mode 100644 index 0000000..a606907 --- /dev/null +++ b/samples/spring-boot-4/customer-service/src/main/java/io/github/blueprintplatform/samples/customerservice/api/controller/CustomerController.java @@ -0,0 +1,88 @@ +package io.github.blueprintplatform.samples.customerservice.api.controller; + +import static io.github.blueprintplatform.samples.customerservice.api.version.ApiVersions.V1; + +import io.github.blueprintplatform.contracts.customer.CustomerDto; +import io.github.blueprintplatform.openapi.generics.contract.envelope.Meta; +import io.github.blueprintplatform.openapi.generics.contract.envelope.ServiceResponse; +import io.github.blueprintplatform.openapi.generics.contract.paging.Page; +import io.github.blueprintplatform.openapi.generics.contract.paging.SortDirection; +import io.github.blueprintplatform.samples.customerservice.api.dto.*; +import io.github.blueprintplatform.samples.customerservice.common.api.sort.SortField; +import io.github.blueprintplatform.samples.customerservice.service.CustomerService; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import jakarta.validation.Valid; +import jakarta.validation.constraints.Max; +import jakarta.validation.constraints.Min; +import java.net.URI; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.*; +import org.springframework.web.servlet.support.ServletUriComponentsBuilder; + +@RestController +@RequestMapping(value = "/customers", produces = MediaType.APPLICATION_JSON_VALUE) +@Validated +public class CustomerController { + + private final CustomerService customerService; + + public CustomerController(CustomerService customerService) { + this.customerService = customerService; + } + + @PostMapping(version = V1, consumes = MediaType.APPLICATION_JSON_VALUE) + public ResponseEntity> createCustomer( + @Valid @RequestBody CustomerCreateRequest request) { + + CustomerDto created = customerService.createCustomer(request); + + URI location = + ServletUriComponentsBuilder.fromCurrentRequest() + .path("/{id}") + .buildAndExpand(created.customerId()) + .toUri(); + + return ResponseEntity.created(location).body(ServiceResponse.of(created)); + } + + @GetMapping(path = "/{customerId}", version = V1) + public ResponseEntity> getCustomer( + @PathVariable @Min(1) Integer customerId) { + + CustomerDto dto = customerService.getCustomer(customerId); + return ResponseEntity.ok(ServiceResponse.of(dto)); + } + + @GetMapping(version = V1) + public ResponseEntity>> getCustomers( + @ModelAttribute CustomerSearchCriteria criteria, + @RequestParam(defaultValue = "0") @Min(0) int page, + @RequestParam(defaultValue = "5") @Min(1) @Max(10) int size, + @RequestParam(defaultValue = "customerId") SortField sortBy, + @RequestParam(defaultValue = "asc") SortDirection direction) { + + var paged = customerService.getCustomers(criteria, page, size, sortBy, direction); + var meta = Meta.now(sortBy.value(), direction); + + return ResponseEntity.ok(ServiceResponse.of(paged, meta)); + } + + @PutMapping(path = "/{customerId}", version = V1, consumes = MediaType.APPLICATION_JSON_VALUE) + public ResponseEntity> updateCustomer( + @PathVariable @Min(1) Integer customerId, @Valid @RequestBody CustomerUpdateRequest request) { + + CustomerDto updated = customerService.updateCustomer(customerId, request); + return ResponseEntity.ok(ServiceResponse.of(updated)); + } + + @DeleteMapping(path = "/{customerId}", version = V1) + @ResponseStatus(HttpStatus.NO_CONTENT) + @ApiResponse(responseCode = "204", description = "Customer deleted") + public ResponseEntity deleteCustomer(@PathVariable @Min(1) Integer customerId) { + customerService.deleteCustomer(customerId); + return ResponseEntity.noContent().build(); + } +} diff --git a/samples/spring-boot-4/customer-service/src/main/java/io/github/blueprintplatform/samples/customerservice/api/dto/CustomerCreateRequest.java b/samples/spring-boot-4/customer-service/src/main/java/io/github/blueprintplatform/samples/customerservice/api/dto/CustomerCreateRequest.java new file mode 100644 index 0000000..580bb42 --- /dev/null +++ b/samples/spring-boot-4/customer-service/src/main/java/io/github/blueprintplatform/samples/customerservice/api/dto/CustomerCreateRequest.java @@ -0,0 +1,8 @@ +package io.github.blueprintplatform.samples.customerservice.api.dto; + +import jakarta.validation.constraints.Email; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; + +public record CustomerCreateRequest( + @NotBlank @Size(min = 2, max = 80) String name, @NotBlank @Email String email) {} diff --git a/samples/spring-boot-4/customer-service/src/main/java/io/github/blueprintplatform/samples/customerservice/api/dto/CustomerSearchCriteria.java b/samples/spring-boot-4/customer-service/src/main/java/io/github/blueprintplatform/samples/customerservice/api/dto/CustomerSearchCriteria.java new file mode 100644 index 0000000..c0f5baf --- /dev/null +++ b/samples/spring-boot-4/customer-service/src/main/java/io/github/blueprintplatform/samples/customerservice/api/dto/CustomerSearchCriteria.java @@ -0,0 +1,6 @@ +package io.github.blueprintplatform.samples.customerservice.api.dto; + +import org.springdoc.core.annotations.ParameterObject; + +@ParameterObject +public record CustomerSearchCriteria(String name, String email) {} diff --git a/samples/spring-boot-4/customer-service/src/main/java/io/github/blueprintplatform/samples/customerservice/api/dto/CustomerUpdateRequest.java b/samples/spring-boot-4/customer-service/src/main/java/io/github/blueprintplatform/samples/customerservice/api/dto/CustomerUpdateRequest.java new file mode 100644 index 0000000..aa3bf51 --- /dev/null +++ b/samples/spring-boot-4/customer-service/src/main/java/io/github/blueprintplatform/samples/customerservice/api/dto/CustomerUpdateRequest.java @@ -0,0 +1,8 @@ +package io.github.blueprintplatform.samples.customerservice.api.dto; + +import jakarta.validation.constraints.Email; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; + +public record CustomerUpdateRequest( + @NotBlank @Size(min = 2, max = 80) String name, @NotBlank @Email String email) {} diff --git a/samples/spring-boot-4/customer-service/src/main/java/io/github/blueprintplatform/samples/customerservice/api/error/ApiRequestExceptionHandler.java b/samples/spring-boot-4/customer-service/src/main/java/io/github/blueprintplatform/samples/customerservice/api/error/ApiRequestExceptionHandler.java new file mode 100644 index 0000000..0c63ba8 --- /dev/null +++ b/samples/spring-boot-4/customer-service/src/main/java/io/github/blueprintplatform/samples/customerservice/api/error/ApiRequestExceptionHandler.java @@ -0,0 +1,456 @@ +package io.github.blueprintplatform.samples.customerservice.api.error; + +import static io.github.blueprintplatform.samples.customerservice.api.error.ProblemSupport.*; +import static io.github.blueprintplatform.samples.customerservice.common.api.ApiConstants.ErrorCode.BAD_REQUEST; +import static io.github.blueprintplatform.samples.customerservice.common.api.ApiConstants.ErrorCode.NOT_FOUND; +import static io.github.blueprintplatform.samples.customerservice.common.api.ApiConstants.ErrorCode.VALIDATION_FAILED; + +import com.fasterxml.jackson.databind.exc.InvalidFormatException; +import com.fasterxml.jackson.databind.exc.UnrecognizedPropertyException; +import io.github.blueprintplatform.openapi.generics.contract.error.ErrorItem; +import io.github.blueprintplatform.samples.customerservice.common.i18n.LocalizedMessageResolver; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.validation.ConstraintViolation; +import jakarta.validation.ConstraintViolationException; +import java.util.List; +import java.util.Optional; +import org.jspecify.annotations.Nullable; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.TypeMismatchException; +import org.springframework.core.annotation.Order; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpStatus; +import org.springframework.http.HttpStatusCode; +import org.springframework.http.ProblemDetail; +import org.springframework.http.ResponseEntity; +import org.springframework.http.converter.HttpMessageNotReadableException; +import org.springframework.validation.BindException; +import org.springframework.validation.FieldError; +import org.springframework.web.HttpRequestMethodNotSupportedException; +import org.springframework.web.bind.MethodArgumentNotValidException; +import org.springframework.web.bind.MissingRequestHeaderException; +import org.springframework.web.bind.MissingServletRequestParameterException; +import org.springframework.web.bind.ServletRequestBindingException; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.RestControllerAdvice; +import org.springframework.web.context.request.ServletWebRequest; +import org.springframework.web.context.request.WebRequest; +import org.springframework.web.method.annotation.MethodArgumentTypeMismatchException; +import org.springframework.web.servlet.mvc.method.annotation.ResponseEntityExceptionHandler; +import org.springframework.web.servlet.resource.NoResourceFoundException; + +@RestControllerAdvice +@Order(1) +public class ApiRequestExceptionHandler extends ResponseEntityExceptionHandler { + + private static final Logger log = LoggerFactory.getLogger(ApiRequestExceptionHandler.class); + + private static final String KEY_PROBLEM_TITLE_NOT_FOUND = "problem.title.not_found"; + private static final String KEY_PROBLEM_DETAIL_NOT_FOUND = "problem.detail.not_found"; + + private static final String KEY_PROBLEM_TITLE_BAD_REQUEST = "problem.title.bad_request"; + private static final String KEY_PROBLEM_DETAIL_BAD_REQUEST = "problem.detail.bad_request"; + private static final String KEY_PROBLEM_DETAIL_PARAM_INVALID = "request.param.invalid"; + + private static final String KEY_PROBLEM_TITLE_VALIDATION_FAILED = + "problem.title.validation_failed"; + private static final String KEY_PROBLEM_DETAIL_VALIDATION_FAILED = + "problem.detail.validation_failed"; + + private static final String KEY_PROBLEM_TITLE_METHOD_NOT_ALLOWED = + "problem.title.method_not_allowed"; + private static final String KEY_PROBLEM_DETAIL_METHOD_NOT_ALLOWED = + "problem.detail.method_not_allowed"; + + private static final String KEY_ENDPOINT_NOT_FOUND = "request.endpoint.not_found"; + private static final String KEY_METHOD_NOT_SUPPORTED = "request.method.not_supported"; + private static final String KEY_PARAM_REQUIRED_MISSING = "request.param.required_missing"; + private static final String KEY_HEADER_REQUIRED_MISSING = "request.header.missing"; + private static final String KEY_PARAM_TYPE_MISMATCH = "request.param.type_mismatch"; + + private static final String KEY_REQUEST_BODY_INVALID = "request.body.invalid"; + private static final String KEY_REQUEST_BODY_FIELD_UNRECOGNIZED = + "request.body.field.unrecognized"; + private static final String KEY_REQUEST_BODY_INVALID_FORMAT = "request.body.invalid_format"; + + private static final String ERROR_CODE_METHOD_NOT_ALLOWED = "METHOD_NOT_ALLOWED"; + private static final String FALLBACK_INVALID = "invalid"; + private static final String FALLBACK_UNKNOWN = "unknown"; + + private final LocalizedMessageResolver messageResolver; + + public ApiRequestExceptionHandler(LocalizedMessageResolver messageResolver) { + this.messageResolver = messageResolver; + } + + @Override + protected @Nullable ResponseEntity handleMethodArgumentNotValid( + MethodArgumentNotValidException ex, + HttpHeaders headers, + HttpStatusCode status, + WebRequest request) { + HttpServletRequest req = ((ServletWebRequest) request).getRequest(); + + List errors = + ex.getBindingResult().getFieldErrors().stream().map(this::toErrorItem).toList(); + + ProblemDetail pd = buildValidationProblem(req); + attachErrors(pd, VALIDATION_FAILED, errors); + + return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(pd); + } + + @ExceptionHandler(ConstraintViolationException.class) + public ProblemDetail handleConstraintViolation( + ConstraintViolationException ex, HttpServletRequest req) { + + List errors = ex.getConstraintViolations().stream().map(this::toErrorItem).toList(); + + ProblemDetail pd = buildValidationProblem(req); + attachErrors(pd, VALIDATION_FAILED, errors); + return pd; + } + + @ExceptionHandler(BindException.class) + public ProblemDetail handleBindException(BindException ex, HttpServletRequest req) { + List errors = ex.getFieldErrors().stream().map(this::toErrorItem).toList(); + + ProblemDetail pd = buildValidationProblem(req); + attachErrors(pd, VALIDATION_FAILED, errors); + return pd; + } + + @Override + protected @Nullable ResponseEntity handleHttpMessageNotReadable( + HttpMessageNotReadableException ex, + HttpHeaders headers, + HttpStatusCode status, + WebRequest request) { + + HttpServletRequest req = ((ServletWebRequest) request).getRequest(); + Throwable cause = ex.getCause(); + + if (cause instanceof InvalidFormatException invalidFormatException) { + return handleInvalidFormat(invalidFormatException, req); + } + + if (cause instanceof UnrecognizedPropertyException unrecognizedPropertyException) { + return handleUnrecognized(unrecognizedPropertyException, req); + } + + String raw = Optional.ofNullable(cause).map(Throwable::getMessage).orElseGet(ex::getMessage); + log.warn("Bad request (not readable): {}", raw); + + ProblemDetail pd = buildBadRequestBodyProblem(req); + attachErrors( + pd, + BAD_REQUEST, + List.of( + error( + BAD_REQUEST, + messageResolver.getMessage(KEY_REQUEST_BODY_INVALID), + null, + null, + null))); + + return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(pd); + } + + @Override + protected @Nullable ResponseEntity handleNoResourceFoundException( + NoResourceFoundException ex, HttpHeaders headers, HttpStatusCode status, WebRequest request) { + + HttpServletRequest req = ((ServletWebRequest) request).getRequest(); + + log.warn("Endpoint not found: {}", ex.getResourcePath()); + + ProblemDetail pd = + baseProblem( + type(TYPE_NOT_FOUND), + HttpStatus.NOT_FOUND, + messageResolver.getMessage(KEY_PROBLEM_TITLE_NOT_FOUND), + messageResolver.getMessage(KEY_PROBLEM_DETAIL_NOT_FOUND), + req); + + attachErrors( + pd, + NOT_FOUND, + List.of( + error( + NOT_FOUND, messageResolver.getMessage(KEY_ENDPOINT_NOT_FOUND), null, null, null))); + + return ResponseEntity.status(HttpStatus.NOT_FOUND).body(pd); + } + + @Override + protected @Nullable ResponseEntity handleHttpRequestMethodNotSupported( + HttpRequestMethodNotSupportedException ex, + HttpHeaders headers, + HttpStatusCode status, + WebRequest request) { + + HttpServletRequest req = ((ServletWebRequest) request).getRequest(); + String method = ex.getMethod(); + + ProblemDetail pd = + baseProblem( + type(TYPE_METHOD_NOT_ALLOWED), + HttpStatus.METHOD_NOT_ALLOWED, + messageResolver.getMessage(KEY_PROBLEM_TITLE_METHOD_NOT_ALLOWED), + messageResolver.getMessage(KEY_PROBLEM_DETAIL_METHOD_NOT_ALLOWED), + req); + + attachErrors( + pd, + ERROR_CODE_METHOD_NOT_ALLOWED, + List.of( + error( + ERROR_CODE_METHOD_NOT_ALLOWED, + messageResolver.getMessage(KEY_METHOD_NOT_SUPPORTED, method), + null, + null, + null))); + + return ResponseEntity.status(HttpStatus.METHOD_NOT_ALLOWED).body(pd); + } + + @Override + protected @Nullable ResponseEntity handleMissingServletRequestParameter( + MissingServletRequestParameterException ex, + HttpHeaders headers, + HttpStatusCode status, + WebRequest request) { + + HttpServletRequest req = ((ServletWebRequest) request).getRequest(); + String param = ex.getParameterName(); + + ProblemDetail pd = buildBadRequestParamProblem(req); + + attachErrors( + pd, + BAD_REQUEST, + List.of( + error( + BAD_REQUEST, + messageResolver.getMessage(KEY_PARAM_REQUIRED_MISSING, param), + param, + null, + null))); + + return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(pd); + } + + @Override + protected @Nullable ResponseEntity handleServletRequestBindingException( + ServletRequestBindingException ex, + HttpHeaders headers, + HttpStatusCode status, + WebRequest request) { + + HttpServletRequest req = ((ServletWebRequest) request).getRequest(); + ProblemDetail pd = buildBadRequestParamProblem(req); + + if (ex instanceof MissingRequestHeaderException missingHeaderEx) { + String header = missingHeaderEx.getHeaderName(); + + attachErrors( + pd, + BAD_REQUEST, + List.of( + error( + BAD_REQUEST, + messageResolver.getMessage(KEY_HEADER_REQUIRED_MISSING, header), + header, + null, + null))); + + return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(pd); + } + + attachErrors( + pd, + BAD_REQUEST, + List.of( + error( + BAD_REQUEST, + messageResolver.getMessage(KEY_PROBLEM_DETAIL_PARAM_INVALID), + null, + null, + null))); + + return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(pd); + } + + @Override + protected @Nullable ResponseEntity handleTypeMismatch( + TypeMismatchException ex, HttpHeaders headers, HttpStatusCode status, WebRequest request) { + + HttpServletRequest req = ((ServletWebRequest) request).getRequest(); + + String paramName = + ex instanceof MethodArgumentTypeMismatchException matme ? matme.getName() : null; + + String expected = + Optional.ofNullable(ex.getRequiredType()) + .map(Class::getSimpleName) + .orElse(FALLBACK_UNKNOWN); + + ProblemDetail pd = buildBadRequestParamProblem(req); + + attachErrors( + pd, + BAD_REQUEST, + List.of( + error( + BAD_REQUEST, + messageResolver.getMessage(KEY_PARAM_TYPE_MISMATCH, expected), + paramName, + null, + null))); + + return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(pd); + } + + private ResponseEntity handleInvalidFormat( + InvalidFormatException ex, HttpServletRequest req) { + String expectedType = expectedTypeName(ex); + String actualValue = safeValue(ex.getValue()); + + List errors = + ex.getPath().stream() + .map( + ref -> + error( + BAD_REQUEST, + messageResolver.getMessage( + KEY_REQUEST_BODY_INVALID_FORMAT, expectedType, actualValue), + ref.getFieldName(), + null, + null)) + .toList(); + + ProblemDetail pd = buildBadRequestBodyProblem(req); + + attachErrors( + pd, + BAD_REQUEST, + errors.isEmpty() + ? List.of( + error( + BAD_REQUEST, + messageResolver.getMessage(KEY_REQUEST_BODY_INVALID), + null, + null, + null)) + : errors); + + return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(pd); + } + + private ResponseEntity handleUnrecognized( + UnrecognizedPropertyException ex, HttpServletRequest req) { + String field = ex.getPropertyName(); + log.warn("Unrecognized field: '{}' (known: {})", field, ex.getKnownPropertyIds()); + + ProblemDetail pd = buildBadRequestBodyProblem(req); + + attachErrors( + pd, + BAD_REQUEST, + List.of( + error( + BAD_REQUEST, + messageResolver.getMessage(KEY_REQUEST_BODY_FIELD_UNRECOGNIZED, field), + field, + null, + null))); + + return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(pd); + } + + private ProblemDetail buildValidationProblem(HttpServletRequest req) { + return baseProblem( + type(TYPE_VALIDATION_FAILED), + HttpStatus.BAD_REQUEST, + messageResolver.getMessage(KEY_PROBLEM_TITLE_VALIDATION_FAILED), + messageResolver.getMessage(KEY_PROBLEM_DETAIL_VALIDATION_FAILED), + req); + } + + private ProblemDetail buildBadRequestParamProblem(HttpServletRequest req) { + return baseProblem( + type(TYPE_BAD_REQUEST), + HttpStatus.BAD_REQUEST, + messageResolver.getMessage(KEY_PROBLEM_TITLE_BAD_REQUEST), + messageResolver.getMessage(KEY_PROBLEM_DETAIL_PARAM_INVALID), + req); + } + + private ProblemDetail buildBadRequestBodyProblem(HttpServletRequest req) { + return baseProblem( + type(TYPE_BAD_REQUEST), + HttpStatus.BAD_REQUEST, + messageResolver.getMessage(KEY_PROBLEM_TITLE_BAD_REQUEST), + messageResolver.getMessage(KEY_PROBLEM_DETAIL_BAD_REQUEST), + req); + } + + private ErrorItem toErrorItem(FieldError fe) { + String field = fe.getField(); + String message = resolveMessageOrKey(fe.getDefaultMessage(), FALLBACK_INVALID); + return error(VALIDATION_FAILED, message, field, null, null); + } + + private ErrorItem toErrorItem(ConstraintViolation v) { + String field = v.getPropertyPath() == null ? "" : v.getPropertyPath().toString(); + + String resolvedTemplate = resolveMessageOrKey(v.getMessageTemplate(), null); + String message = + looksLikeMessageKeyTemplate(v.getMessageTemplate()) + ? resolvedTemplate + : resolveMessageOrKey(v.getMessage(), FALLBACK_INVALID); + + return error(VALIDATION_FAILED, message, field, null, null); + } + + private String resolveMessageOrKey(String keyOrText, String fallback) { + if (keyOrText == null) { + return fallback; + } + + String s = keyOrText.trim(); + if (s.isEmpty()) { + return fallback; + } + + if (looksLikeMessageKeyTemplate(s)) { + String key = extractKey(s); + return messageResolver.getMessage(key); + } + + return s; + } + + private boolean looksLikeMessageKeyTemplate(String s) { + return s != null && s.length() > 2 && s.startsWith("{") && s.endsWith("}"); + } + + private String extractKey(String template) { + return template.substring(1, template.length() - 1).trim(); + } + + private String expectedTypeName(InvalidFormatException ex) { + return Optional.ofNullable(ex.getTargetType()) + .map(Class::getSimpleName) + .orElse(FALLBACK_UNKNOWN); + } + + private String safeValue(Object v) { + if (v == null) { + return "null"; + } + String s = v.toString(); + return s.isBlank() ? "null" : s; + } +} diff --git a/samples/spring-boot-4/customer-service/src/main/java/io/github/blueprintplatform/samples/customerservice/api/error/ApplicationExceptionHandler.java b/samples/spring-boot-4/customer-service/src/main/java/io/github/blueprintplatform/samples/customerservice/api/error/ApplicationExceptionHandler.java new file mode 100644 index 0000000..77c2454 --- /dev/null +++ b/samples/spring-boot-4/customer-service/src/main/java/io/github/blueprintplatform/samples/customerservice/api/error/ApplicationExceptionHandler.java @@ -0,0 +1,86 @@ +package io.github.blueprintplatform.samples.customerservice.api.error; + +import static io.github.blueprintplatform.samples.customerservice.api.error.ProblemSupport.*; +import static io.github.blueprintplatform.samples.customerservice.common.api.ApiConstants.ErrorCode.INTERNAL_ERROR; +import static io.github.blueprintplatform.samples.customerservice.common.api.ApiConstants.ErrorCode.NOT_FOUND; + +import io.github.blueprintplatform.samples.customerservice.common.i18n.LocalizedMessageResolver; +import jakarta.servlet.http.HttpServletRequest; +import java.util.List; +import java.util.NoSuchElementException; +import java.util.Optional; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.core.annotation.Order; +import org.springframework.http.HttpStatus; +import org.springframework.http.ProblemDetail; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.RestControllerAdvice; + +@RestControllerAdvice +@Order(2) +public class ApplicationExceptionHandler { + + private static final Logger log = LoggerFactory.getLogger(ApplicationExceptionHandler.class); + + private static final String KEY_PROBLEM_TITLE_NOT_FOUND = "problem.title.not_found"; + private static final String KEY_PROBLEM_DETAIL_NOT_FOUND = "problem.detail.not_found"; + + private static final String KEY_PROBLEM_TITLE_INTERNAL_ERROR = "problem.title.internal_error"; + private static final String KEY_PROBLEM_DETAIL_INTERNAL_ERROR = "problem.detail.internal_error"; + + private static final String KEY_SERVER_INTERNAL_ERROR = "server.internal.error"; + private static final String KEY_ENDPOINT_RESOURCE_NOT_FOUND = "request.resource.not_found"; + + private static final String FALLBACK_RESOURCE_NOT_FOUND = "Resource not found."; + + private final LocalizedMessageResolver messageResolver; + + public ApplicationExceptionHandler(LocalizedMessageResolver messageResolver) { + this.messageResolver = messageResolver; + } + + @ExceptionHandler(NoSuchElementException.class) + public ProblemDetail handleNotFound(NoSuchElementException ex, HttpServletRequest req) { + ProblemDetail pd = + baseProblem( + type(TYPE_NOT_FOUND), + HttpStatus.NOT_FOUND, + messageResolver.getMessage(KEY_PROBLEM_TITLE_NOT_FOUND), + messageResolver.getMessage(KEY_PROBLEM_DETAIL_NOT_FOUND), + req); + + String msg = + Optional.ofNullable(ex.getMessage()) + .filter(s -> !s.isBlank()) + .orElseGet(() -> messageResolver.getMessage(KEY_ENDPOINT_RESOURCE_NOT_FOUND)); + + if (msg == null || msg.isBlank()) { + msg = FALLBACK_RESOURCE_NOT_FOUND; + } + + attachErrors(pd, NOT_FOUND, List.of(error(NOT_FOUND, msg, null, "Customer", null))); + return pd; + } + + @ExceptionHandler(Exception.class) + public ProblemDetail handleGeneric(Exception ex, HttpServletRequest req) { + log.error("Unhandled exception", ex); + + String detail = messageResolver.getMessage(KEY_SERVER_INTERNAL_ERROR); + if (detail == null || detail.isBlank()) { + detail = messageResolver.getMessage(KEY_PROBLEM_DETAIL_INTERNAL_ERROR); + } + + ProblemDetail pd = + baseProblem( + type(TYPE_INTERNAL_ERROR), + HttpStatus.INTERNAL_SERVER_ERROR, + messageResolver.getMessage(KEY_PROBLEM_TITLE_INTERNAL_ERROR), + detail, + req); + + attachErrors(pd, INTERNAL_ERROR, List.of(error(INTERNAL_ERROR, detail, null, null, null))); + return pd; + } +} diff --git a/samples/spring-boot-4/customer-service/src/main/java/io/github/blueprintplatform/samples/customerservice/api/error/ProblemSupport.java b/samples/spring-boot-4/customer-service/src/main/java/io/github/blueprintplatform/samples/customerservice/api/error/ProblemSupport.java new file mode 100644 index 0000000..683f941 --- /dev/null +++ b/samples/spring-boot-4/customer-service/src/main/java/io/github/blueprintplatform/samples/customerservice/api/error/ProblemSupport.java @@ -0,0 +1,53 @@ +package io.github.blueprintplatform.samples.customerservice.api.error; + +import io.github.blueprintplatform.openapi.generics.contract.error.ErrorItem; +import io.github.blueprintplatform.openapi.generics.contract.error.ProblemExtensions; +import jakarta.servlet.http.HttpServletRequest; +import java.net.URI; +import java.util.List; +import java.util.Optional; +import org.springframework.http.HttpStatus; +import org.springframework.http.ProblemDetail; +import org.springframework.web.util.UriComponentsBuilder; + +final class ProblemSupport { + + static final String KEY_ERROR_CODE = "errorCode"; + static final String KEY_EXTENSIONS = "extensions"; + + static final String TYPE_NOT_FOUND = "not-found"; + static final String TYPE_VALIDATION_FAILED = "validation-failed"; + static final String TYPE_BAD_REQUEST = "bad-request"; + static final String TYPE_INTERNAL_ERROR = "internal-error"; + static final String TYPE_METHOD_NOT_ALLOWED = "method-not-allowed"; + + private static final String PROBLEM_BASE = "urn:customer-service:problem:"; + + private ProblemSupport() {} + + static URI type(String slug) { + return URI.create(PROBLEM_BASE + slug); + } + + static ProblemDetail baseProblem( + URI type, HttpStatus status, String title, String detail, HttpServletRequest req) { + + ProblemDetail pd = ProblemDetail.forStatusAndDetail(status, detail); + pd.setType(type); + pd.setTitle(title); + + String path = Optional.ofNullable(req.getRequestURI()).orElse("/"); + pd.setInstance(UriComponentsBuilder.fromPath(path).build().toUri()); + + return pd; + } + + static ErrorItem error(String code, String message, String field, String resource, String id) { + return new ErrorItem(code, message, field, resource, id); + } + + static void attachErrors(ProblemDetail pd, String errorCode, List errors) { + pd.setProperty(KEY_ERROR_CODE, errorCode); + pd.setProperty(KEY_EXTENSIONS, ProblemExtensions.ofErrors(errors)); + } +} diff --git a/samples/spring-boot-4/customer-service/src/main/java/io/github/blueprintplatform/samples/customerservice/api/version/ApiVersions.java b/samples/spring-boot-4/customer-service/src/main/java/io/github/blueprintplatform/samples/customerservice/api/version/ApiVersions.java new file mode 100644 index 0000000..1a8bc31 --- /dev/null +++ b/samples/spring-boot-4/customer-service/src/main/java/io/github/blueprintplatform/samples/customerservice/api/version/ApiVersions.java @@ -0,0 +1,7 @@ +package io.github.blueprintplatform.samples.customerservice.api.version; + +public final class ApiVersions { + public static final String V1 = "1.0"; + + private ApiVersions() {} +} diff --git a/samples/spring-boot-4/customer-service/src/main/java/io/github/blueprintplatform/samples/customerservice/common/api/ApiConstants.java b/samples/spring-boot-4/customer-service/src/main/java/io/github/blueprintplatform/samples/customerservice/common/api/ApiConstants.java new file mode 100644 index 0000000..060823b --- /dev/null +++ b/samples/spring-boot-4/customer-service/src/main/java/io/github/blueprintplatform/samples/customerservice/common/api/ApiConstants.java @@ -0,0 +1,14 @@ +package io.github.blueprintplatform.samples.customerservice.common.api; + +public final class ApiConstants { + private ApiConstants() {} + + public static final class ErrorCode { + public static final String NOT_FOUND = "NOT_FOUND"; + public static final String BAD_REQUEST = "BAD_REQUEST"; + public static final String VALIDATION_FAILED = "VALIDATION_FAILED"; + public static final String INTERNAL_ERROR = "INTERNAL_ERROR"; + + private ErrorCode() {} + } +} diff --git a/samples/spring-boot-4/customer-service/src/main/java/io/github/blueprintplatform/samples/customerservice/common/api/sort/SortField.java b/samples/spring-boot-4/customer-service/src/main/java/io/github/blueprintplatform/samples/customerservice/common/api/sort/SortField.java new file mode 100644 index 0000000..5e6da03 --- /dev/null +++ b/samples/spring-boot-4/customer-service/src/main/java/io/github/blueprintplatform/samples/customerservice/common/api/sort/SortField.java @@ -0,0 +1,28 @@ +package io.github.blueprintplatform.samples.customerservice.common.api.sort; + +import com.fasterxml.jackson.annotation.JsonValue; + +public enum SortField { + CUSTOMER_ID("customerId"), + NAME("name"), + EMAIL("email"); + + private final String value; + + SortField(String value) { + this.value = value; + } + + public static SortField from(String s) { + if (s == null) return CUSTOMER_ID; + for (var f : values()) { + if (f.value.equalsIgnoreCase(s)) return f; + } + throw new IllegalArgumentException("Unsupported sort field: " + s); + } + + @JsonValue + public String value() { + return value; + } +} diff --git a/samples/spring-boot-4/customer-service/src/main/java/io/github/blueprintplatform/samples/customerservice/common/i18n/LocalizedMessageResolver.java b/samples/spring-boot-4/customer-service/src/main/java/io/github/blueprintplatform/samples/customerservice/common/i18n/LocalizedMessageResolver.java new file mode 100644 index 0000000..c706815 --- /dev/null +++ b/samples/spring-boot-4/customer-service/src/main/java/io/github/blueprintplatform/samples/customerservice/common/i18n/LocalizedMessageResolver.java @@ -0,0 +1,14 @@ +package io.github.blueprintplatform.samples.customerservice.common.i18n; + +import java.util.Locale; + +public interface LocalizedMessageResolver { + + String getMessage(String messageKey); + + String getMessage(String messageKey, Object... args); + + String getMessage(String messageKey, Locale locale); + + String getMessage(String messageKey, Locale locale, Object... args); +} diff --git a/samples/spring-boot-4/customer-service/src/main/java/io/github/blueprintplatform/samples/customerservice/common/i18n/impl/SpringLocalizedMessageResolver.java b/samples/spring-boot-4/customer-service/src/main/java/io/github/blueprintplatform/samples/customerservice/common/i18n/impl/SpringLocalizedMessageResolver.java new file mode 100644 index 0000000..5a5ad8d --- /dev/null +++ b/samples/spring-boot-4/customer-service/src/main/java/io/github/blueprintplatform/samples/customerservice/common/i18n/impl/SpringLocalizedMessageResolver.java @@ -0,0 +1,40 @@ +package io.github.blueprintplatform.samples.customerservice.common.i18n.impl; + +import io.github.blueprintplatform.samples.customerservice.common.i18n.LocalizedMessageResolver; +import io.github.blueprintplatform.samples.customerservice.common.i18n.locale.CurrentLocaleProvider; +import java.util.Locale; +import org.springframework.context.MessageSource; +import org.springframework.stereotype.Component; + +@Component +public class SpringLocalizedMessageResolver implements LocalizedMessageResolver { + + private final MessageSource messageSource; + private final CurrentLocaleProvider localeProvider; + + public SpringLocalizedMessageResolver( + MessageSource messageSource, CurrentLocaleProvider localeProvider) { + this.messageSource = messageSource; + this.localeProvider = localeProvider; + } + + @Override + public String getMessage(String messageKey) { + return messageSource.getMessage(messageKey, null, localeProvider.getCurrentLocale()); + } + + @Override + public String getMessage(String messageKey, Object... args) { + return messageSource.getMessage(messageKey, args, localeProvider.getCurrentLocale()); + } + + @Override + public String getMessage(String messageKey, Locale locale) { + return messageSource.getMessage(messageKey, null, locale); + } + + @Override + public String getMessage(String messageKey, Locale locale, Object... args) { + return messageSource.getMessage(messageKey, args, locale); + } +} diff --git a/samples/spring-boot-4/customer-service/src/main/java/io/github/blueprintplatform/samples/customerservice/common/i18n/locale/CurrentLocaleProvider.java b/samples/spring-boot-4/customer-service/src/main/java/io/github/blueprintplatform/samples/customerservice/common/i18n/locale/CurrentLocaleProvider.java new file mode 100644 index 0000000..ced83c9 --- /dev/null +++ b/samples/spring-boot-4/customer-service/src/main/java/io/github/blueprintplatform/samples/customerservice/common/i18n/locale/CurrentLocaleProvider.java @@ -0,0 +1,7 @@ +package io.github.blueprintplatform.samples.customerservice.common.i18n.locale; + +import java.util.Locale; + +public interface CurrentLocaleProvider { + Locale getCurrentLocale(); +} diff --git a/samples/spring-boot-4/customer-service/src/main/java/io/github/blueprintplatform/samples/customerservice/common/i18n/locale/impl/DefaultLocaleProvider.java b/samples/spring-boot-4/customer-service/src/main/java/io/github/blueprintplatform/samples/customerservice/common/i18n/locale/impl/DefaultLocaleProvider.java new file mode 100644 index 0000000..8a1b063 --- /dev/null +++ b/samples/spring-boot-4/customer-service/src/main/java/io/github/blueprintplatform/samples/customerservice/common/i18n/locale/impl/DefaultLocaleProvider.java @@ -0,0 +1,16 @@ +package io.github.blueprintplatform.samples.customerservice.common.i18n.locale.impl; + +import io.github.blueprintplatform.samples.customerservice.common.i18n.locale.CurrentLocaleProvider; +import java.util.Locale; +import org.springframework.stereotype.Component; + +@Component +public class DefaultLocaleProvider implements CurrentLocaleProvider { + + private static final Locale DEFAULT_LOCALE = Locale.ENGLISH; + + @Override + public Locale getCurrentLocale() { + return DEFAULT_LOCALE; + } +} diff --git a/samples/spring-boot-4/customer-service/src/main/java/io/github/blueprintplatform/samples/customerservice/common/openapi/OpenApiConfig.java b/samples/spring-boot-4/customer-service/src/main/java/io/github/blueprintplatform/samples/customerservice/common/openapi/OpenApiConfig.java new file mode 100644 index 0000000..e2f836a --- /dev/null +++ b/samples/spring-boot-4/customer-service/src/main/java/io/github/blueprintplatform/samples/customerservice/common/openapi/OpenApiConfig.java @@ -0,0 +1,35 @@ +package io.github.blueprintplatform.samples.customerservice.common.openapi; + +import io.swagger.v3.oas.models.OpenAPI; +import io.swagger.v3.oas.models.info.Info; +import io.swagger.v3.oas.models.servers.Server; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +@Configuration +public class OpenApiConfig { + + @Value("${app.openapi.version:${project.version:unknown}}") + private String version; + + @Value("${app.openapi.base-url:}") + private String baseUrl; + + @Bean + public OpenAPI customerServiceOpenAPI() { + var openapi = + new OpenAPI() + .info( + new Info() + .title(OpenApiConstants.TITLE) + .version(version) + .description(OpenApiConstants.DESCRIPTION)); + + if (baseUrl != null && !baseUrl.isBlank()) { + openapi.addServersItem( + new Server().url(baseUrl).description(OpenApiConstants.SERVER_DESCRIPTION)); + } + return openapi; + } +} diff --git a/samples/spring-boot-4/customer-service/src/main/java/io/github/blueprintplatform/samples/customerservice/common/openapi/OpenApiConstants.java b/samples/spring-boot-4/customer-service/src/main/java/io/github/blueprintplatform/samples/customerservice/common/openapi/OpenApiConstants.java new file mode 100644 index 0000000..6e3fdf5 --- /dev/null +++ b/samples/spring-boot-4/customer-service/src/main/java/io/github/blueprintplatform/samples/customerservice/common/openapi/OpenApiConstants.java @@ -0,0 +1,10 @@ +package io.github.blueprintplatform.samples.customerservice.common.openapi; + +public final class OpenApiConstants { + public static final String TITLE = "Customer Service API"; + public static final String DESCRIPTION = + "Customer Service API with type-safe generic responses using OpenAPI"; + public static final String SERVER_DESCRIPTION = "Local service URL"; + + private OpenApiConstants() {} +} diff --git a/samples/spring-boot-4/customer-service/src/main/java/io/github/blueprintplatform/samples/customerservice/config/JacksonConfig.java b/samples/spring-boot-4/customer-service/src/main/java/io/github/blueprintplatform/samples/customerservice/config/JacksonConfig.java new file mode 100644 index 0000000..fbe273f --- /dev/null +++ b/samples/spring-boot-4/customer-service/src/main/java/io/github/blueprintplatform/samples/customerservice/config/JacksonConfig.java @@ -0,0 +1,32 @@ +package io.github.blueprintplatform.samples.customerservice.config; + +import io.github.blueprintplatform.openapi.generics.contract.paging.SortDirection; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import tools.jackson.core.JacksonException; +import tools.jackson.core.JsonGenerator; +import tools.jackson.databind.SerializationContext; +import tools.jackson.databind.ValueSerializer; +import tools.jackson.databind.module.SimpleModule; + +@Configuration +public class JacksonConfig { + + @Bean + public SimpleModule sortDirectionModule() { + SimpleModule module = new SimpleModule(); + + module.addSerializer( + SortDirection.class, + new ValueSerializer<>() { + @Override + public void serialize(SortDirection value, JsonGenerator gen, SerializationContext ctxt) + throws JacksonException { + + gen.writeString(value.value()); + } + }); + + return module; + } +} diff --git a/samples/spring-boot-4/customer-service/src/main/java/io/github/blueprintplatform/samples/customerservice/config/WebConfig.java b/samples/spring-boot-4/customer-service/src/main/java/io/github/blueprintplatform/samples/customerservice/config/WebConfig.java new file mode 100644 index 0000000..fafe70a --- /dev/null +++ b/samples/spring-boot-4/customer-service/src/main/java/io/github/blueprintplatform/samples/customerservice/config/WebConfig.java @@ -0,0 +1,36 @@ +package io.github.blueprintplatform.samples.customerservice.config; + +import io.github.blueprintplatform.openapi.generics.contract.paging.SortDirection; +import io.github.blueprintplatform.samples.customerservice.common.api.sort.SortField; +import io.github.blueprintplatform.samples.customerservice.config.version.ApiOnlyVersionResolver; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Configuration; +import org.springframework.format.FormatterRegistry; +import org.springframework.web.servlet.config.annotation.ApiVersionConfigurer; +import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; + +@Configuration +public class WebConfig implements WebMvcConfigurer { + + private static final String API_VERSION_HEADER = "API-Version"; + + @Value("${server.servlet.context-path:}") + private String contextPath; + + @Value("${app.api.base-path:/customers}") + private String apiBasePath; + + @Override + public void addFormatters(FormatterRegistry registry) { + registry.addConverter(String.class, SortField.class, SortField::from); + registry.addConverter(String.class, SortDirection.class, SortDirection::from); + } + + @Override + public void configureApiVersioning(ApiVersionConfigurer configurer) { + configurer + .useVersionResolver( + new ApiOnlyVersionResolver(API_VERSION_HEADER, contextPath, apiBasePath)) + .setVersionRequired(false); + } +} diff --git a/samples/spring-boot-4/customer-service/src/main/java/io/github/blueprintplatform/samples/customerservice/config/version/ApiOnlyVersionResolver.java b/samples/spring-boot-4/customer-service/src/main/java/io/github/blueprintplatform/samples/customerservice/config/version/ApiOnlyVersionResolver.java new file mode 100644 index 0000000..8394d6a --- /dev/null +++ b/samples/spring-boot-4/customer-service/src/main/java/io/github/blueprintplatform/samples/customerservice/config/version/ApiOnlyVersionResolver.java @@ -0,0 +1,38 @@ +package io.github.blueprintplatform.samples.customerservice.config.version; + +import jakarta.servlet.http.HttpServletRequest; +import org.springframework.web.accept.ApiVersionResolver; + +public class ApiOnlyVersionResolver implements ApiVersionResolver { + + private final String headerName; + private final String contextPath; + private final String apiBasePath; + + public ApiOnlyVersionResolver(String headerName, String contextPath, String apiBasePath) { + + this.headerName = headerName; + this.contextPath = normalize(contextPath); + this.apiBasePath = normalize(apiBasePath); + } + + @Override + public String resolveVersion(HttpServletRequest request) { + + String uri = request.getRequestURI(); + String fullApiPath = contextPath + apiBasePath; + + if (uri.equals(fullApiPath) || uri.startsWith(fullApiPath + "/")) { + return request.getHeader(headerName); + } + + return null; + } + + private String normalize(String path) { + if (path == null || path.isBlank()) { + return ""; + } + return path.startsWith("/") ? path : "/" + path; + } +} diff --git a/samples/spring-boot-4/customer-service/src/main/java/io/github/blueprintplatform/samples/customerservice/service/CustomerService.java b/samples/spring-boot-4/customer-service/src/main/java/io/github/blueprintplatform/samples/customerservice/service/CustomerService.java new file mode 100644 index 0000000..77f7b0d --- /dev/null +++ b/samples/spring-boot-4/customer-service/src/main/java/io/github/blueprintplatform/samples/customerservice/service/CustomerService.java @@ -0,0 +1,26 @@ +package io.github.blueprintplatform.samples.customerservice.service; + +import io.github.blueprintplatform.contracts.customer.CustomerDto; +import io.github.blueprintplatform.openapi.generics.contract.paging.Page; +import io.github.blueprintplatform.openapi.generics.contract.paging.SortDirection; +import io.github.blueprintplatform.samples.customerservice.api.dto.CustomerCreateRequest; +import io.github.blueprintplatform.samples.customerservice.api.dto.CustomerSearchCriteria; +import io.github.blueprintplatform.samples.customerservice.api.dto.CustomerUpdateRequest; +import io.github.blueprintplatform.samples.customerservice.common.api.sort.SortField; + +public interface CustomerService { + CustomerDto createCustomer(CustomerCreateRequest request); + + CustomerDto getCustomer(Integer customerId); + + Page getCustomers( + CustomerSearchCriteria criteria, + int page, + int size, + SortField sortBy, + SortDirection direction); + + CustomerDto updateCustomer(Integer customerId, CustomerUpdateRequest request); + + void deleteCustomer(Integer customerId); +} diff --git a/samples/spring-boot-4/customer-service/src/main/java/io/github/blueprintplatform/samples/customerservice/service/impl/CustomerServiceImpl.java b/samples/spring-boot-4/customer-service/src/main/java/io/github/blueprintplatform/samples/customerservice/service/impl/CustomerServiceImpl.java new file mode 100644 index 0000000..d3a7dd1 --- /dev/null +++ b/samples/spring-boot-4/customer-service/src/main/java/io/github/blueprintplatform/samples/customerservice/service/impl/CustomerServiceImpl.java @@ -0,0 +1,132 @@ +package io.github.blueprintplatform.samples.customerservice.service.impl; + +import io.github.blueprintplatform.contracts.customer.CustomerDto; +import io.github.blueprintplatform.openapi.generics.contract.paging.Page; +import io.github.blueprintplatform.openapi.generics.contract.paging.SortDirection; +import io.github.blueprintplatform.samples.customerservice.api.dto.CustomerCreateRequest; +import io.github.blueprintplatform.samples.customerservice.api.dto.CustomerSearchCriteria; +import io.github.blueprintplatform.samples.customerservice.api.dto.CustomerUpdateRequest; +import io.github.blueprintplatform.samples.customerservice.common.api.sort.SortField; +import io.github.blueprintplatform.samples.customerservice.service.CustomerService; +import java.util.Comparator; +import java.util.List; +import java.util.NavigableMap; +import java.util.NoSuchElementException; +import java.util.concurrent.ConcurrentSkipListMap; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.stream.Stream; +import org.springframework.stereotype.Service; + +@Service +public class CustomerServiceImpl implements CustomerService { + + private static final int MAX_PAGE_SIZE = 10; + private final AtomicInteger idSeq = new AtomicInteger(0); + private final NavigableMap store = new ConcurrentSkipListMap<>(); + + public CustomerServiceImpl() { + createCustomer(new CustomerCreateRequest("Ahmet Yilmaz", "ahmet.yilmaz@example.com")); + createCustomer(new CustomerCreateRequest("John Smith", "john.smith@example.com")); + createCustomer(new CustomerCreateRequest("Carlos Hernandez", "carlos.hernandez@example.com")); + createCustomer(new CustomerCreateRequest("Ananya Patel", "ananya.patel@example.com")); + createCustomer(new CustomerCreateRequest("Sofia Rossi", "sofia.rossi@example.com")); + createCustomer(new CustomerCreateRequest("Hans MΓΌller", "hans.muller@example.com")); + createCustomer(new CustomerCreateRequest("Yuki Tanaka", "yuki.tanaka@example.com")); + createCustomer(new CustomerCreateRequest("Amina El-Sayed", "amina.elsayed@example.com")); + createCustomer(new CustomerCreateRequest("Lucas Silva", "lucas.silva@example.com")); + createCustomer(new CustomerCreateRequest("Chloe Dubois", "chloe.dubois@example.com")); + createCustomer(new CustomerCreateRequest("Andrei Popescu", "andrei.popescu@example.com")); + createCustomer(new CustomerCreateRequest("Fatima Al-Harbi", "fatima.alharbi@example.com")); + createCustomer(new CustomerCreateRequest("Emily Johnson", "emily.johnson@example.com")); + createCustomer(new CustomerCreateRequest("Zanele Ndlovu", "zanele.ndlovu@example.com")); + createCustomer(new CustomerCreateRequest("Mateo GonzΓ‘lez", "mateo.gonzalez@example.com")); + createCustomer(new CustomerCreateRequest("Olga Ivanova", "olga.ivanova@example.com")); + createCustomer(new CustomerCreateRequest("Wei Chen", "wei.chen@example.com")); + } + + @Override + public CustomerDto createCustomer(CustomerCreateRequest request) { + int id = idSeq.incrementAndGet(); + CustomerDto dto = new CustomerDto(id, request.name(), request.email()); + store.put(id, dto); + return dto; + } + + @Override + public CustomerDto getCustomer(Integer customerId) { + CustomerDto dto = store.get(customerId); + if (dto == null) throw new NoSuchElementException("Customer not found: " + customerId); + return dto; + } + + @Override + public Page getCustomers( + CustomerSearchCriteria criteria, + int page, + int size, + SortField sortBy, + SortDirection direction) { + var filtered = applyFilters(store.values().stream(), criteria); + var sorted = filtered.sorted(buildComparator(sortBy, direction)).toList(); + return paginate(sorted, page, size); + } + + @Override + public CustomerDto updateCustomer(Integer customerId, CustomerUpdateRequest request) { + CustomerDto existing = store.get(customerId); + if (existing == null) throw new NoSuchElementException("Customer not found: " + customerId); + CustomerDto updated = new CustomerDto(existing.customerId(), request.name(), request.email()); + store.put(customerId, updated); + return updated; + } + + @Override + public void deleteCustomer(Integer customerId) { + store.remove(customerId); + } + + private Stream applyFilters(Stream stream, CustomerSearchCriteria c) { + if (c == null) return stream; + + if (c.name() != null && !c.name().isBlank()) { + String q = c.name().toLowerCase(); + stream = stream.filter(x -> x.name() != null && x.name().toLowerCase().contains(q)); + } + if (c.email() != null && !c.email().isBlank()) { + String q = c.email().toLowerCase(); + stream = stream.filter(x -> x.email() != null && x.email().toLowerCase().contains(q)); + } + return stream; + } + + private Comparator buildComparator(SortField sortBy, SortDirection dir) { + Comparator cmp = + switch (sortBy) { + case CUSTOMER_ID -> + Comparator.comparing( + CustomerDto::customerId, Comparator.nullsLast(Integer::compareTo)); + case NAME -> + Comparator.comparing( + CustomerDto::name, Comparator.nullsLast(String::compareToIgnoreCase)); + case EMAIL -> + Comparator.comparing( + CustomerDto::email, Comparator.nullsLast(String::compareToIgnoreCase)); + }; + return (dir == SortDirection.DESC) ? cmp.reversed() : cmp; + } + + private Page paginate(List items, int page, int size) { + int p = Math.clamp(page, 0, Integer.MAX_VALUE); + int s = Math.clamp(size, 1, MAX_PAGE_SIZE); + + long total = items.size(); + long fromL = Math.min((long) p * s, total); + long toL = Math.min(fromL + s, total); + + int from = (int) fromL; + int to = (int) toL; + + var slice = items.subList(from, to); + return Page.of(slice, p, s, total); + } +} diff --git a/samples/spring-boot-4/customer-service/src/main/resources/application.yml b/samples/spring-boot-4/customer-service/src/main/resources/application.yml new file mode 100644 index 0000000..da8daee --- /dev/null +++ b/samples/spring-boot-4/customer-service/src/main/resources/application.yml @@ -0,0 +1,42 @@ +server: + port: ${APP_PORT:8094} + servlet: + context-path: /customer-service + +spring: + jackson: + deserialization: + fail-on-unknown-properties: true + mvc: + problemdetails: + enabled: true + application: + name: customer-service + profiles: + active: ${SPRING_PROFILES_ACTIVE:local} + web: + error: + include-message: always + include-binding-errors: always + include-stacktrace: never + include-exception: false + +logging: + level: + root: INFO + org.springframework.web: INFO + io.github.blueprintplatform: DEBUG + +app: + openapi: + version: @project.version@ + base-url: "http://localhost:${server.port}${server.servlet.context-path:}" + wrapper: + # Optional: extra annotation for generated client models + # class-extra-annotation: "@com.fasterxml.jackson.annotation.JsonIgnoreProperties(ignoreUnknown = true)" + api: + base-path: /customers + +springdoc: + default-consumes-media-type: application/json + default-produces-media-type: application/json \ No newline at end of file diff --git a/samples/spring-boot-4/customer-service/src/main/resources/messages.properties b/samples/spring-boot-4/customer-service/src/main/resources/messages.properties new file mode 100644 index 0000000..a4e9555 --- /dev/null +++ b/samples/spring-boot-4/customer-service/src/main/resources/messages.properties @@ -0,0 +1,21 @@ +problem.title.not_found=Resource not found +problem.title.internal_error=Internal server error +problem.title.bad_request=Bad request +problem.title.validation_failed=Validation failed +problem.title.method_not_allowed=Method not allowed +problem.detail.not_found=Requested resource was not found. +problem.detail.internal_error=Unexpected error occurred. +problem.detail.bad_request=Malformed request body. +problem.detail.validation_failed=One or more fields are invalid. +problem.detail.method_not_allowed=The request method is not supported for this resource. +request.body.invalid=Invalid JSON payload. +request.body.field.unrecognized=Unrecognized field: ''{0}'' +request.body.invalid_format=Invalid format: expected {0}, value {1} +request.method.not_supported=HTTP method not supported: {0} +request.param.invalid=One or more parameters are invalid. +request.param.required_missing=Missing required parameter: {0} +request.header.missing=Required request header ''{0}'' is missing +request.param.type_mismatch=Invalid value (expected {0}). +request.endpoint.not_found=Endpoint not found. +request.resource.not_found=Resource not found. +server.internal.error=Internal server error. Please try again later. \ No newline at end of file diff --git a/samples/spring-boot-4/customer-service/src/test/java/io/github/blueprintplatform/samples/customerservice/CustomerServiceApplicationIT.java b/samples/spring-boot-4/customer-service/src/test/java/io/github/blueprintplatform/samples/customerservice/CustomerServiceApplicationIT.java new file mode 100644 index 0000000..fc203ee --- /dev/null +++ b/samples/spring-boot-4/customer-service/src/test/java/io/github/blueprintplatform/samples/customerservice/CustomerServiceApplicationIT.java @@ -0,0 +1,18 @@ +package io.github.blueprintplatform.samples.customerservice; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; +import org.springframework.boot.test.context.SpringBootTest; + +@SpringBootTest +@Tag("integration") +@DisplayName("Integration Test: Application Context") +class CustomerServiceApplicationIT { + + @Test + @DisplayName("Spring context should load without issues") + void contextLoads() { + // If the context fails to start, this test will fail + } +} diff --git a/samples/spring-boot-4/customer-service/src/test/java/io/github/blueprintplatform/samples/customerservice/api/controller/CustomerControllerIT.java b/samples/spring-boot-4/customer-service/src/test/java/io/github/blueprintplatform/samples/customerservice/api/controller/CustomerControllerIT.java new file mode 100644 index 0000000..7feb952 --- /dev/null +++ b/samples/spring-boot-4/customer-service/src/test/java/io/github/blueprintplatform/samples/customerservice/api/controller/CustomerControllerIT.java @@ -0,0 +1,442 @@ +package io.github.blueprintplatform.samples.customerservice.api.controller; + +import static org.hamcrest.Matchers.endsWith; +import static org.hamcrest.Matchers.hasItems; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.doNothing; +import static org.mockito.Mockito.when; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.put; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; + +import io.github.blueprintplatform.contracts.customer.CustomerDto; +import io.github.blueprintplatform.openapi.generics.contract.paging.Page; +import io.github.blueprintplatform.openapi.generics.contract.paging.SortDirection; +import io.github.blueprintplatform.samples.customerservice.api.dto.CustomerCreateRequest; +import io.github.blueprintplatform.samples.customerservice.api.dto.CustomerSearchCriteria; +import io.github.blueprintplatform.samples.customerservice.api.dto.CustomerUpdateRequest; +import io.github.blueprintplatform.samples.customerservice.api.error.ApiRequestExceptionHandler; +import io.github.blueprintplatform.samples.customerservice.api.error.ApplicationExceptionHandler; +import io.github.blueprintplatform.samples.customerservice.common.api.sort.SortField; +import io.github.blueprintplatform.samples.customerservice.config.JacksonConfig; +import io.github.blueprintplatform.samples.customerservice.service.CustomerService; +import io.github.blueprintplatform.samples.customerservice.testconfig.TestControllerMocksConfig; +import java.util.List; +import java.util.NoSuchElementException; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; +import org.mockito.Mockito; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.webmvc.test.autoconfigure.WebMvcTest; +import org.springframework.context.annotation.Import; +import org.springframework.http.MediaType; +import org.springframework.test.web.servlet.MockMvc; +import tools.jackson.databind.ObjectMapper; + +@WebMvcTest(controllers = CustomerController.class) +@Import({ + ApiRequestExceptionHandler.class, + ApplicationExceptionHandler.class, + TestControllerMocksConfig.class, + JacksonConfig.class +}) +@Tag("integration") +class CustomerControllerIT { + + private static final String API_VERSION_HEADER = "API-Version"; + private static final String API_VERSION = "1.0"; + + @Autowired private MockMvc mvc; + @Autowired private ObjectMapper om; + @Autowired private CustomerService customerService; + + @Test + @DisplayName("POST /customers -> 201 Created, Location header ve ServiceResponse(data, meta)") + void createCustomer_created201_withLocation() throws Exception { + var req = new CustomerCreateRequest("John Smith", "john.smith@example.com"); + var dto = new CustomerDto(1, req.name(), req.email()); + when(customerService.createCustomer(any(CustomerCreateRequest.class))).thenReturn(dto); + + mvc.perform( + post("/customers") + .header(API_VERSION_HEADER, API_VERSION) + .contentType(MediaType.APPLICATION_JSON) + .content(om.writeValueAsBytes(req))) + .andExpect(status().isCreated()) + .andExpect(header().string("Location", endsWith("/customers/1"))) + .andExpect(content().contentTypeCompatibleWith(MediaType.APPLICATION_JSON)) + .andExpect(jsonPath("$.data.customerId").value(1)) + .andExpect(jsonPath("$.data.name").value("John Smith")) + .andExpect(jsonPath("$.data.email").value("john.smith@example.com")) + .andExpect(jsonPath("$.meta.serverTime").exists()) + .andExpect(jsonPath("$.meta.sort").isArray()); + } + + @Test + @DisplayName("POST /customers -> 400 validation error (MethodArgumentNotValid)") + void createCustomer_validationError_methodArgumentNotValid() throws Exception { + var badJson = + """ + {"name":"","email":"not-an-email"} + """; + + mvc.perform( + post("/customers") + .header(API_VERSION_HEADER, API_VERSION) + .contentType(MediaType.APPLICATION_JSON) + .content(badJson)) + .andExpect(status().isBadRequest()) + .andExpect(content().contentTypeCompatibleWith(MediaType.APPLICATION_PROBLEM_JSON)) + .andExpect(jsonPath("$.type").value("urn:customer-service:problem:validation-failed")) + .andExpect(jsonPath("$.title").value("Validation failed")) + .andExpect(jsonPath("$.status").value(400)) + .andExpect(jsonPath("$.detail").value("One or more fields are invalid.")) + .andExpect(jsonPath("$.instance").value("/customers")) + .andExpect(jsonPath("$.errorCode").value("VALIDATION_FAILED")) + .andExpect(jsonPath("$.extensions.errors").isArray()) + .andExpect(jsonPath("$.extensions.errors.length()").value(3)) + .andExpect(jsonPath("$.extensions.errors[*].code", hasItems("VALIDATION_FAILED"))) + .andExpect(jsonPath("$.extensions.errors[*].field", hasItems("name", "email"))) + .andExpect( + jsonPath( + "$.extensions.errors[*].message", + hasItems( + "must not be blank", + "must be a well-formed email address", + "size must be between 2 and 80"))); + } + + @Test + @DisplayName("POST /customers -> 400 invalid JSON (HttpMessageNotReadable)") + void createCustomer_badJson_notReadable() throws Exception { + var malformed = "{ \"name\": \"John\", \"email\": }"; + + mvc.perform( + post("/customers") + .header(API_VERSION_HEADER, API_VERSION) + .contentType(MediaType.APPLICATION_JSON) + .content(malformed)) + .andExpect(status().isBadRequest()) + .andExpect(content().contentTypeCompatibleWith(MediaType.APPLICATION_PROBLEM_JSON)) + .andExpect(jsonPath("$.type").value("urn:customer-service:problem:bad-request")) + .andExpect(jsonPath("$.title").value("Bad request")) + .andExpect(jsonPath("$.status").value(400)) + .andExpect(jsonPath("$.detail").value("Malformed request body.")) + .andExpect(jsonPath("$.instance").value("/customers")) + .andExpect(jsonPath("$.errorCode").value("BAD_REQUEST")) + .andExpect(jsonPath("$.extensions.errors[0].code").value("BAD_REQUEST")) + .andExpect(jsonPath("$.extensions.errors[0].message").value("Invalid JSON payload.")); + } + + @Test + @DisplayName("GET /customers/{id} -> 200 OK (one customer)") + void getCustomer_ok200() throws Exception { + var dto = new CustomerDto(1, "John Smith", "john.smith@example.com"); + when(customerService.getCustomer(1)).thenReturn(dto); + + mvc.perform(get("/customers/{id}", 1).header(API_VERSION_HEADER, API_VERSION)) + .andExpect(status().isOk()) + .andExpect(content().contentTypeCompatibleWith(MediaType.APPLICATION_JSON)) + .andExpect(jsonPath("$.data.customerId").value(1)) + .andExpect(jsonPath("$.data.name").value("John Smith")) + .andExpect(jsonPath("$.data.email").value("john.smith@example.com")) + .andExpect(jsonPath("$.meta.serverTime").exists()) + .andExpect(jsonPath("$.meta.sort").isArray()); + } + + @Test + @DisplayName("GET /customers/{id} -> 404 NOT_FOUND (NoSuchElementException)") + void getCustomer_notFound404() throws Exception { + when(customerService.getCustomer(99)) + .thenThrow(new NoSuchElementException("Customer not found: 99")); + + mvc.perform(get("/customers/{id}", 99).header(API_VERSION_HEADER, API_VERSION)) + .andExpect(status().isNotFound()) + .andExpect(content().contentTypeCompatibleWith(MediaType.APPLICATION_PROBLEM_JSON)) + .andExpect(jsonPath("$.type").value("urn:customer-service:problem:not-found")) + .andExpect(jsonPath("$.title").value("Resource not found")) + .andExpect(jsonPath("$.status").value(404)) + .andExpect(jsonPath("$.detail").value("Requested resource was not found.")) + .andExpect(jsonPath("$.instance").value("/customers/99")) + .andExpect(jsonPath("$.errorCode").value("NOT_FOUND")) + .andExpect(jsonPath("$.extensions.errors[0].code").value("NOT_FOUND")) + .andExpect(jsonPath("$.extensions.errors[0].message").value("Customer not found: 99")) + .andExpect(jsonPath("$.extensions.errors[0].resource").value("Customer")); + } + + @Test + @DisplayName("GET /customers/{id} -> 404 NOT_FOUND fallback message") + void getCustomer_notFound404_fallbackMessage() throws Exception { + when(customerService.getCustomer(77)).thenThrow(new NoSuchElementException("")); + + mvc.perform(get("/customers/{id}", 77).header(API_VERSION_HEADER, API_VERSION)) + .andExpect(status().isNotFound()) + .andExpect(content().contentTypeCompatibleWith(MediaType.APPLICATION_PROBLEM_JSON)) + .andExpect(jsonPath("$.type").value("urn:customer-service:problem:not-found")) + .andExpect(jsonPath("$.title").value("Resource not found")) + .andExpect(jsonPath("$.status").value(404)) + .andExpect(jsonPath("$.detail").value("Requested resource was not found.")) + .andExpect(jsonPath("$.instance").value("/customers/77")) + .andExpect(jsonPath("$.errorCode").value("NOT_FOUND")) + .andExpect(jsonPath("$.extensions.errors[0].code").value("NOT_FOUND")) + .andExpect(jsonPath("$.extensions.errors[0].message").value("Resource not found.")) + .andExpect(jsonPath("$.extensions.errors[0].resource").value("Customer")); + } + + @Test + @DisplayName("GET /customers/{id} -> 400 BAD_REQUEST (type mismatch)") + void getCustomer_typeMismatch400() throws Exception { + mvc.perform(get("/customers/{id}", "abc").header(API_VERSION_HEADER, API_VERSION)) + .andExpect(status().isBadRequest()) + .andExpect(content().contentTypeCompatibleWith(MediaType.APPLICATION_PROBLEM_JSON)) + .andExpect(jsonPath("$.type").value("urn:customer-service:problem:bad-request")) + .andExpect(jsonPath("$.title").value("Bad request")) + .andExpect(jsonPath("$.status").value(400)) + .andExpect(jsonPath("$.detail").value("One or more parameters are invalid.")) + .andExpect(jsonPath("$.instance").value("/customers/abc")) + .andExpect(jsonPath("$.errorCode").value("BAD_REQUEST")) + .andExpect(jsonPath("$.extensions.errors[0].code").value("BAD_REQUEST")) + .andExpect( + jsonPath("$.extensions.errors[0].message").value("Invalid value (expected Integer).")) + .andExpect(jsonPath("$.extensions.errors[0].field").value("customerId")); + } + + @Test + @DisplayName("GET /customers/{id} -> 400 validation error (@Min violation)") + void getCustomer_constraintViolation_min() throws Exception { + mvc.perform(get("/customers/{id}", 0).header(API_VERSION_HEADER, API_VERSION)) + .andExpect(status().isBadRequest()) + .andExpect(content().contentTypeCompatibleWith(MediaType.APPLICATION_PROBLEM_JSON)) + .andExpect(jsonPath("$.type").value("urn:customer-service:problem:validation-failed")) + .andExpect(jsonPath("$.title").value("Validation failed")) + .andExpect(jsonPath("$.status").value(400)) + .andExpect(jsonPath("$.detail").value("One or more fields are invalid.")) + .andExpect(jsonPath("$.instance").value("/customers/0")) + .andExpect(jsonPath("$.errorCode").value("VALIDATION_FAILED")) + .andExpect(jsonPath("$.extensions.errors[0].code").value("VALIDATION_FAILED")); + } + + @Test + @DisplayName("GET /customers/{id} -> 500 Internal Server Error (RFC 9457 ProblemDetail)") + void getCustomer_internalServerError_generic() throws Exception { + when(customerService.getCustomer(1)).thenThrow(new RuntimeException("Unexpected failure")); + + mvc.perform(get("/customers/{id}", 1).header(API_VERSION_HEADER, API_VERSION)) + .andExpect(status().isInternalServerError()) + .andExpect(content().contentTypeCompatibleWith(MediaType.APPLICATION_PROBLEM_JSON)) + .andExpect(jsonPath("$.type").value("urn:customer-service:problem:internal-error")) + .andExpect(jsonPath("$.title").value("Internal server error")) + .andExpect(jsonPath("$.status").value(500)) + .andExpect(jsonPath("$.detail").value("Internal server error. Please try again later.")) + .andExpect(jsonPath("$.instance").value("/customers/1")) + .andExpect(jsonPath("$.errorCode").value("INTERNAL_ERROR")) + .andExpect(jsonPath("$.extensions.errors[0].code").value("INTERNAL_ERROR")) + .andExpect( + jsonPath("$.extensions.errors[0].message") + .value("Internal server error. Please try again later.")); + } + + @Test + @DisplayName("GET /customers -> 200 OK, Page + default meta.sort") + void getCustomers_list200_defaultSort() throws Exception { + var d1 = new CustomerDto(1, "John Smith", "john.smith@example.com"); + var d2 = new CustomerDto(2, "Ahmet Yilmaz", "ahmet.yilmaz@example.com"); + var page = Page.of(List.of(d1, d2), 0, 5, 2); + + when(customerService.getCustomers( + any(CustomerSearchCriteria.class), + anyInt(), + anyInt(), + any(SortField.class), + any(SortDirection.class))) + .thenReturn(page); + + mvc.perform(get("/customers").header(API_VERSION_HEADER, API_VERSION)) + .andExpect(status().isOk()) + .andExpect(content().contentTypeCompatibleWith(MediaType.APPLICATION_JSON)) + .andExpect(jsonPath("$.data.content.length()").value(2)) + .andExpect(jsonPath("$.data.content[0].customerId").value(1)) + .andExpect(jsonPath("$.data.page").value(0)) + .andExpect(jsonPath("$.data.size").value(5)) + .andExpect(jsonPath("$.data.totalElements").value(2)) + .andExpect(jsonPath("$.data.totalPages").value(1)) + .andExpect(jsonPath("$.meta.serverTime").exists()) + .andExpect(jsonPath("$.meta.sort[0].field").value("customerId")) + .andExpect(jsonPath("$.meta.sort[0].direction").value("asc")); + } + + @Test + @DisplayName("GET /customers -> 200 OK, custom sort metadata") + void getCustomers_list200_customSort() throws Exception { + var d1 = new CustomerDto(2, "Jane Doe", "jane.doe@example.com"); + var d2 = new CustomerDto(1, "Ahmet Yilmaz", "ahmet.yilmaz@example.com"); + var page = Page.of(List.of(d1, d2), 0, 5, 2); + + when(customerService.getCustomers( + any(CustomerSearchCriteria.class), + anyInt(), + anyInt(), + any(SortField.class), + any(SortDirection.class))) + .thenReturn(page); + + mvc.perform( + get("/customers") + .header(API_VERSION_HEADER, API_VERSION) + .param("sortBy", "name") + .param("direction", "desc")) + .andExpect(status().isOk()) + .andExpect(content().contentTypeCompatibleWith(MediaType.APPLICATION_JSON)) + .andExpect(jsonPath("$.data.content.length()").value(2)) + .andExpect(jsonPath("$.meta.serverTime").exists()) + .andExpect(jsonPath("$.meta.sort[0].field").value("name")) + .andExpect(jsonPath("$.meta.sort[0].direction").value("desc")); + } + + @Test + @DisplayName("GET /customers -> 400 BAD_REQUEST (sortBy type mismatch)") + void getCustomers_sortBy_typeMismatch400() throws Exception { + mvc.perform(get("/customers").header(API_VERSION_HEADER, API_VERSION).param("sortBy", "foo")) + .andExpect(status().isBadRequest()) + .andExpect(content().contentTypeCompatibleWith(MediaType.APPLICATION_PROBLEM_JSON)) + .andExpect(jsonPath("$.type").value("urn:customer-service:problem:bad-request")) + .andExpect(jsonPath("$.title").value("Bad request")) + .andExpect(jsonPath("$.status").value(400)) + .andExpect(jsonPath("$.detail").value("One or more parameters are invalid.")) + .andExpect(jsonPath("$.instance").value("/customers")) + .andExpect(jsonPath("$.errorCode").value("BAD_REQUEST")) + .andExpect(jsonPath("$.extensions.errors[0].code").value("BAD_REQUEST")) + .andExpect( + jsonPath("$.extensions.errors[0].message").value("Invalid value (expected SortField).")) + .andExpect(jsonPath("$.extensions.errors[0].field").value("sortBy")); + } + + @Test + @DisplayName("GET /customers -> 400 validation error (page @Min)") + void getCustomers_page_validation_min() throws Exception { + mvc.perform(get("/customers").header(API_VERSION_HEADER, API_VERSION).param("page", "-1")) + .andExpect(status().isBadRequest()) + .andExpect(content().contentTypeCompatibleWith(MediaType.APPLICATION_PROBLEM_JSON)) + .andExpect(jsonPath("$.type").value("urn:customer-service:problem:validation-failed")) + .andExpect(jsonPath("$.title").value("Validation failed")) + .andExpect(jsonPath("$.status").value(400)) + .andExpect(jsonPath("$.detail").value("One or more fields are invalid.")) + .andExpect(jsonPath("$.instance").value("/customers")) + .andExpect(jsonPath("$.errorCode").value("VALIDATION_FAILED")) + .andExpect(jsonPath("$.extensions.errors[0].code").value("VALIDATION_FAILED")); + } + + @Test + @DisplayName("GET /customers -> 400 validation error (size @Min)") + void getCustomers_size_validation_min() throws Exception { + mvc.perform(get("/customers").header(API_VERSION_HEADER, API_VERSION).param("size", "0")) + .andExpect(status().isBadRequest()) + .andExpect(content().contentTypeCompatibleWith(MediaType.APPLICATION_PROBLEM_JSON)) + .andExpect(jsonPath("$.type").value("urn:customer-service:problem:validation-failed")) + .andExpect(jsonPath("$.title").value("Validation failed")) + .andExpect(jsonPath("$.status").value(400)) + .andExpect(jsonPath("$.detail").value("One or more fields are invalid.")) + .andExpect(jsonPath("$.instance").value("/customers")) + .andExpect(jsonPath("$.errorCode").value("VALIDATION_FAILED")) + .andExpect(jsonPath("$.extensions.errors[0].code").value("VALIDATION_FAILED")); + } + + @Test + @DisplayName("GET /customers -> 400 validation error (size @Max)") + void getCustomers_size_validation_max() throws Exception { + mvc.perform(get("/customers").header(API_VERSION_HEADER, API_VERSION).param("size", "11")) + .andExpect(status().isBadRequest()) + .andExpect(content().contentTypeCompatibleWith(MediaType.APPLICATION_PROBLEM_JSON)) + .andExpect(jsonPath("$.type").value("urn:customer-service:problem:validation-failed")) + .andExpect(jsonPath("$.title").value("Validation failed")) + .andExpect(jsonPath("$.status").value(400)) + .andExpect(jsonPath("$.detail").value("One or more fields are invalid.")) + .andExpect(jsonPath("$.instance").value("/customers")) + .andExpect(jsonPath("$.errorCode").value("VALIDATION_FAILED")) + .andExpect(jsonPath("$.extensions.errors[0].code").value("VALIDATION_FAILED")); + } + + @Test + @DisplayName("PUT /customers/{id} -> 200 OK (update)") + void updateCustomer_ok200() throws Exception { + var req = new CustomerUpdateRequest("Jane Doe", "jane.doe@example.com"); + var updated = new CustomerDto(1, req.name(), req.email()); + when(customerService.updateCustomer(1, req)).thenReturn(updated); + + mvc.perform( + put("/customers/{id}", 1) + .header(API_VERSION_HEADER, API_VERSION) + .contentType(MediaType.APPLICATION_JSON) + .content(om.writeValueAsBytes(req))) + .andExpect(status().isOk()) + .andExpect(content().contentTypeCompatibleWith(MediaType.APPLICATION_JSON)) + .andExpect(jsonPath("$.data.customerId").value(1)) + .andExpect(jsonPath("$.data.name").value("Jane Doe")) + .andExpect(jsonPath("$.data.email").value("jane.doe@example.com")) + .andExpect(jsonPath("$.meta.serverTime").exists()) + .andExpect(jsonPath("$.meta.sort").isArray()); + } + + @Test + @DisplayName("PUT /customers/{id} -> 400 validation error (MethodArgumentNotValid)") + void updateCustomer_validationError_methodArgumentNotValid() throws Exception { + var badJson = + """ + {"name":"","email":"bad-email"} + """; + + mvc.perform( + put("/customers/{id}", 1) + .contentType(MediaType.APPLICATION_JSON) + .header(API_VERSION_HEADER, API_VERSION) + .content(badJson)) + .andExpect(status().isBadRequest()) + .andExpect(content().contentTypeCompatibleWith(MediaType.APPLICATION_PROBLEM_JSON)) + .andExpect(jsonPath("$.type").value("urn:customer-service:problem:validation-failed")) + .andExpect(jsonPath("$.title").value("Validation failed")) + .andExpect(jsonPath("$.status").value(400)) + .andExpect(jsonPath("$.detail").value("One or more fields are invalid.")) + .andExpect(jsonPath("$.instance").value("/customers/1")) + .andExpect(jsonPath("$.errorCode").value("VALIDATION_FAILED")) + .andExpect(jsonPath("$.extensions.errors").isArray()) + .andExpect(jsonPath("$.extensions.errors.length()").value(3)) + .andExpect(jsonPath("$.extensions.errors[*].field", hasItems("name", "email"))); + } + + @Test + @DisplayName("DELETE /customers/{id} -> 204 No Content") + void deleteCustomer_noContent204() throws Exception { + doNothing().when(customerService).deleteCustomer(1); + + mvc.perform(delete("/customers/{id}", 1).header(API_VERSION_HEADER, API_VERSION)) + .andExpect(status().isNoContent()) + .andExpect(content().string("")); + } + + @Test + @DisplayName("DELETE /customers -> 405 method not allowed") + void collectionDelete_methodNotAllowed405() throws Exception { + mvc.perform(delete("/customers").header(API_VERSION_HEADER, API_VERSION)) + .andExpect(status().isMethodNotAllowed()) + .andExpect(content().contentTypeCompatibleWith(MediaType.APPLICATION_PROBLEM_JSON)) + .andExpect(jsonPath("$.type").value("urn:customer-service:problem:method-not-allowed")) + .andExpect(jsonPath("$.title").value("Method not allowed")) + .andExpect(jsonPath("$.status").value(405)) + .andExpect( + jsonPath("$.detail").value("The request method is not supported for this resource.")) + .andExpect(jsonPath("$.instance").value("/customers")) + .andExpect(jsonPath("$.errorCode").value("METHOD_NOT_ALLOWED")) + .andExpect(jsonPath("$.extensions.errors[0].code").value("METHOD_NOT_ALLOWED")) + .andExpect( + jsonPath("$.extensions.errors[0].message").value("HTTP method not supported: DELETE")); + } + + @AfterEach + void resetMocks() { + Mockito.reset(customerService); + } +} diff --git a/samples/spring-boot-4/customer-service/src/test/java/io/github/blueprintplatform/samples/customerservice/api/controller/CustomerControllerTest.java b/samples/spring-boot-4/customer-service/src/test/java/io/github/blueprintplatform/samples/customerservice/api/controller/CustomerControllerTest.java new file mode 100644 index 0000000..fbf03ed --- /dev/null +++ b/samples/spring-boot-4/customer-service/src/test/java/io/github/blueprintplatform/samples/customerservice/api/controller/CustomerControllerTest.java @@ -0,0 +1,172 @@ +package io.github.blueprintplatform.samples.customerservice.api.controller; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +import io.github.blueprintplatform.contracts.customer.CustomerDto; +import io.github.blueprintplatform.openapi.generics.contract.envelope.Meta; +import io.github.blueprintplatform.openapi.generics.contract.envelope.ServiceResponse; +import io.github.blueprintplatform.openapi.generics.contract.paging.Page; +import io.github.blueprintplatform.openapi.generics.contract.paging.Sort; +import io.github.blueprintplatform.openapi.generics.contract.paging.SortDirection; +import io.github.blueprintplatform.samples.customerservice.api.dto.CustomerCreateRequest; +import io.github.blueprintplatform.samples.customerservice.api.dto.CustomerSearchCriteria; +import io.github.blueprintplatform.samples.customerservice.api.dto.CustomerUpdateRequest; +import io.github.blueprintplatform.samples.customerservice.common.api.sort.SortField; +import io.github.blueprintplatform.samples.customerservice.service.CustomerService; +import java.util.List; +import org.junit.jupiter.api.*; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.mock.web.MockHttpServletRequest; +import org.springframework.web.context.request.RequestContextHolder; +import org.springframework.web.context.request.ServletRequestAttributes; + +@Tag("unit") +@ExtendWith(MockitoExtension.class) +@DisplayName("Unit Test: CustomerController") +class CustomerControllerTest { + + @Mock private CustomerService customerService; + + @InjectMocks private CustomerController controller; + + private CustomerDto dto1; + private CustomerDto dto2; + + @BeforeEach + void setUp() { + dto1 = new CustomerDto(1, "John Smith", "john.smith@example.com"); + dto2 = new CustomerDto(2, "Ahmet Yilmaz", "ahmet.yilmaz@example.com"); + + MockHttpServletRequest request = new MockHttpServletRequest(); + request.setMethod("POST"); + request.setRequestURI("/customers"); + RequestContextHolder.setRequestAttributes(new ServletRequestAttributes(request)); + } + + @AfterEach + void tearDown() { + RequestContextHolder.resetRequestAttributes(); + } + + @Test + @DisplayName("POST /customers -> 201 Created + ServiceResponse(data, meta)") + void createCustomer_shouldReturnCreated() { + var req = new CustomerCreateRequest("John Smith", "john.smith@example.com"); + when(customerService.createCustomer(req)).thenReturn(dto1); + + ResponseEntity> resp = controller.createCustomer(req); + + assertEquals(HttpStatus.CREATED, resp.getStatusCode()); + assertNotNull(resp.getHeaders().getLocation(), "Location header should be set"); + + var body = resp.getBody(); + assertNotNull(body); + + assertEquals(dto1, body.getData()); + assertNotNull(body.getMeta()); + assertNotNull(body.getMeta().serverTime()); + + verify(customerService).createCustomer(req); + } + + @Test + @DisplayName("GET /customers/{id} -> 200 OK + ServiceResponse(data, meta)") + void getCustomer_shouldReturnOk() { + when(customerService.getCustomer(1)).thenReturn(dto1); + + ResponseEntity> resp = controller.getCustomer(1); + + assertEquals(HttpStatus.OK, resp.getStatusCode()); + + var body = resp.getBody(); + assertNotNull(body); + + assertEquals(dto1, body.getData()); + assertNotNull(body.getMeta()); + assertNotNull(body.getMeta().serverTime()); + + verify(customerService).getCustomer(1); + } + + @Test + @DisplayName("GET /customers -> 200 OK + Page + Meta.sort") + void getCustomers_shouldReturnPaged() { + var page = Page.of(List.of(dto1, dto2), 0, 5, 2); + var criteria = new CustomerSearchCriteria(null, null); + var sortBy = SortField.CUSTOMER_ID; + var direction = SortDirection.ASC; + + when(customerService.getCustomers(criteria, 0, 5, sortBy, direction)).thenReturn(page); + + ResponseEntity>> resp = + controller.getCustomers(criteria, 0, 5, sortBy, direction); + + assertEquals(HttpStatus.OK, resp.getStatusCode()); + + var body = resp.getBody(); + assertNotNull(body); + + // Page assertions + Page data = body.getData(); + assertNotNull(data); + assertEquals(2, data.content().size()); + assertEquals(0, data.page()); + assertEquals(5, data.size()); + assertEquals(2, data.totalElements()); + assertEquals(1, data.totalPages()); + + // Meta + sort assertions (openapi-generics-contract: field is String) + Meta meta = body.getMeta(); + assertNotNull(meta); + assertNotNull(meta.serverTime()); + + assertNotNull(meta.sort()); + assertFalse(meta.sort().isEmpty()); + + Sort s = meta.sort().get(0); + assertEquals(sortBy.value(), s.field()); + assertEquals(direction, s.direction()); + + verify(customerService).getCustomers(criteria, 0, 5, sortBy, direction); + } + + @Test + @DisplayName("PUT /customers/{id} -> 200 OK + ServiceResponse(data, meta)") + void updateCustomer_shouldReturnOk() { + var req = new CustomerUpdateRequest("John Smith", "john.smith@example.com"); + var updated = new CustomerDto(1, req.name(), req.email()); + when(customerService.updateCustomer(1, req)).thenReturn(updated); + + ResponseEntity> resp = controller.updateCustomer(1, req); + + assertEquals(HttpStatus.OK, resp.getStatusCode()); + + var body = resp.getBody(); + assertNotNull(body); + + assertEquals(updated, body.getData()); + assertNotNull(body.getMeta()); + assertNotNull(body.getMeta().serverTime()); + + verify(customerService).updateCustomer(1, req); + } + + @Test + @DisplayName("DELETE /customers/{id} -> 204 No Content") + void deleteCustomer_shouldReturnNoContent() { + doNothing().when(customerService).deleteCustomer(1); + + ResponseEntity resp = controller.deleteCustomer(1); + + assertEquals(HttpStatus.NO_CONTENT, resp.getStatusCode()); + assertNull(resp.getBody()); + + verify(customerService).deleteCustomer(1); + } +} diff --git a/samples/spring-boot-4/customer-service/src/test/java/io/github/blueprintplatform/samples/customerservice/api/dto/CustomerCreateRequestValidationTest.java b/samples/spring-boot-4/customer-service/src/test/java/io/github/blueprintplatform/samples/customerservice/api/dto/CustomerCreateRequestValidationTest.java new file mode 100644 index 0000000..8e4ba2a --- /dev/null +++ b/samples/spring-boot-4/customer-service/src/test/java/io/github/blueprintplatform/samples/customerservice/api/dto/CustomerCreateRequestValidationTest.java @@ -0,0 +1,42 @@ +package io.github.blueprintplatform.samples.customerservice.api.dto; + +import static org.assertj.core.api.Assertions.assertThat; + +import jakarta.validation.Validation; +import jakarta.validation.Validator; +import jakarta.validation.ValidatorFactory; +import org.junit.jupiter.api.*; + +@Tag("unit") +@DisplayName("DTO Validation: CustomerCreateRequest") +class CustomerCreateRequestValidationTest { + + private static Validator validator; + + @BeforeAll + static void setup() { + ValidatorFactory factory = Validation.buildDefaultValidatorFactory(); + validator = factory.getValidator(); + } + + @Test + @DisplayName("name/email valid -> no violations") + void validPayload_shouldPass() { + var dto = new CustomerCreateRequest("John Smith", "john.smith@example.com"); + assertThat(validator.validate(dto)).isEmpty(); + } + + @Test + @DisplayName("blank name -> validation fails") + void blankName_shouldFail() { + var dto = new CustomerCreateRequest(" ", "john.smith@example.com"); + assertThat(validator.validate(dto)).isNotEmpty(); + } + + @Test + @DisplayName("invalid email -> validation fails") + void invalidEmail_shouldFail() { + var dto = new CustomerCreateRequest("John Smith", "not-an-email"); + assertThat(validator.validate(dto)).isNotEmpty(); + } +} diff --git a/samples/spring-boot-4/customer-service/src/test/java/io/github/blueprintplatform/samples/customerservice/api/dto/CustomerUpdateRequestValidationTest.java b/samples/spring-boot-4/customer-service/src/test/java/io/github/blueprintplatform/samples/customerservice/api/dto/CustomerUpdateRequestValidationTest.java new file mode 100644 index 0000000..4e95c1d --- /dev/null +++ b/samples/spring-boot-4/customer-service/src/test/java/io/github/blueprintplatform/samples/customerservice/api/dto/CustomerUpdateRequestValidationTest.java @@ -0,0 +1,37 @@ +package io.github.blueprintplatform.samples.customerservice.api.dto; + +import static org.assertj.core.api.Assertions.assertThat; + +import jakarta.validation.Validation; +import jakarta.validation.Validator; +import jakarta.validation.ValidatorFactory; +import org.junit.jupiter.api.*; + +@Tag("unit") +@DisplayName("DTO Validation: CustomerUpdateRequest") +class CustomerUpdateRequestValidationTest { + + private static Validator validator; + + @BeforeAll + static void setup() { + ValidatorFactory factory = Validation.buildDefaultValidatorFactory(); + validator = factory.getValidator(); + } + + @Test + @DisplayName("valid update -> no violations") + void validUpdate_shouldPass() { + var dto = new CustomerUpdateRequest("Jane Doe", "jane.doe@example.com"); + assertThat(validator.validate(dto)).isEmpty(); + } + + @Test + @DisplayName("blank name or invalid email -> violations") + void invalidUpdate_shouldFail() { + var dto1 = new CustomerUpdateRequest("", "jane.doe@example.com"); + var dto2 = new CustomerUpdateRequest("Jane Doe", "bad-email"); + assertThat(validator.validate(dto1)).isNotEmpty(); + assertThat(validator.validate(dto2)).isNotEmpty(); + } +} diff --git a/samples/spring-boot-4/customer-service/src/test/java/io/github/blueprintplatform/samples/customerservice/common/api/response/PageTest.java b/samples/spring-boot-4/customer-service/src/test/java/io/github/blueprintplatform/samples/customerservice/common/api/response/PageTest.java new file mode 100644 index 0000000..0e0fb2a --- /dev/null +++ b/samples/spring-boot-4/customer-service/src/test/java/io/github/blueprintplatform/samples/customerservice/common/api/response/PageTest.java @@ -0,0 +1,67 @@ +package io.github.blueprintplatform.samples.customerservice.common.api.response; + +import static org.junit.jupiter.api.Assertions.*; + +import io.github.blueprintplatform.openapi.generics.contract.paging.Page; +import java.util.ArrayList; +import java.util.List; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; + +@Tag("unit") +@DisplayName("Unit Test: Page") +class PageTest { + + @Test + @DisplayName("of() -> temel metrikler doğru hesaplanΔ±r") + void of_basic() { + List content = List.of("a", "b", "c", "d", "e", "f"); + int page = 1; // 0-index + int size = 2; + long total = content.size(); + + Page p = Page.of(content.subList(page * size, page * size + size), page, size, total); + + assertEquals(2, p.content().size()); + assertEquals(1, p.page()); + assertEquals(2, p.size()); + assertEquals(6, p.totalElements()); + assertEquals(3, p.totalPages()); // ceil(6/2)=3 + assertTrue(p.hasNext()); // page=1, totalPages=3 => next var + assertTrue(p.hasPrev()); // page>0 => prev var + } + + @Test + @DisplayName("of() -> content null ise boş listeye sabitlenir") + void of_nullContent() { + Page p = Page.of(null, 0, 10, 0); + assertNotNull(p.content()); + assertTrue(p.content().isEmpty()); + } + + @Test + @DisplayName("of() -> content kopyalanΔ±r ve dışarΔ±dan değiştirilemez") + void of_immutableContent() { + List src = new ArrayList<>(List.of("x", "y")); + Page p = Page.of(src, 0, 10, 2); + + src.add("z"); + assertEquals(2, p.content().size()); + + assertThrows(UnsupportedOperationException.class, () -> p.content().add("w")); + } + + @Test + @DisplayName("of() -> son sayfada hasNext=false, ilk sayfada hasPrev=false") + void of_navFlags() { + List content = List.of(1, 2, 3, 4, 5); + Page first = Page.of(content.subList(0, 2), 0, 2, content.size()); + assertFalse(first.hasPrev()); + assertTrue(first.hasNext()); + + Page last = Page.of(content.subList(4, 5), 2, 2, content.size()); + assertTrue(last.hasPrev()); + assertFalse(last.hasNext()); + } +} diff --git a/samples/spring-boot-4/customer-service/src/test/java/io/github/blueprintplatform/samples/customerservice/service/impl/CustomerServiceImplTest.java b/samples/spring-boot-4/customer-service/src/test/java/io/github/blueprintplatform/samples/customerservice/service/impl/CustomerServiceImplTest.java new file mode 100644 index 0000000..ce3119c --- /dev/null +++ b/samples/spring-boot-4/customer-service/src/test/java/io/github/blueprintplatform/samples/customerservice/service/impl/CustomerServiceImplTest.java @@ -0,0 +1,152 @@ +package io.github.blueprintplatform.samples.customerservice.service.impl; + +import static org.junit.jupiter.api.Assertions.*; + +import io.github.blueprintplatform.contracts.customer.CustomerDto; +import io.github.blueprintplatform.openapi.generics.contract.paging.Page; +import io.github.blueprintplatform.openapi.generics.contract.paging.SortDirection; +import io.github.blueprintplatform.samples.customerservice.api.dto.CustomerCreateRequest; +import io.github.blueprintplatform.samples.customerservice.api.dto.CustomerSearchCriteria; +import io.github.blueprintplatform.samples.customerservice.api.dto.CustomerUpdateRequest; +import io.github.blueprintplatform.samples.customerservice.common.api.sort.SortField; +import io.github.blueprintplatform.samples.customerservice.service.CustomerService; +import java.util.NoSuchElementException; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; + +@Tag("unit") +@DisplayName("Unit Test: CustomerServiceImpl") +class CustomerServiceImplTest { + + private CustomerService service; + + @BeforeEach + void setUp() { + service = new CustomerServiceImpl(); + } + + @Test + @DisplayName("Initial seed pagination: total=17, page0 size=10, page1 size=7") + void initialSeed_paginationShouldBeCorrect() { + Page p0 = + service.getCustomers( + new CustomerSearchCriteria(null, null), + 0, + 100, + SortField.CUSTOMER_ID, + SortDirection.ASC); + + assertEquals(17, p0.totalElements()); + assertEquals(2, p0.totalPages()); + assertEquals(10, p0.content().size()); + assertTrue(p0.hasNext()); + assertFalse(p0.hasPrev()); + + Page p1 = + service.getCustomers( + new CustomerSearchCriteria(null, null), + 1, + 100, + SortField.CUSTOMER_ID, + SortDirection.ASC); + + assertEquals(17, p1.totalElements()); + assertEquals(2, p1.totalPages()); + assertEquals(7, p1.content().size()); + assertFalse(p1.hasNext()); + assertTrue(p1.hasPrev()); + } + + @Test + @DisplayName("createCustomer should assign incremental ID and store the record") + void createCustomer_shouldAssignIdAndStore() { + var req = new CustomerCreateRequest("Jane Doe", "jane.doe@example.com"); + CustomerDto created = service.createCustomer(req); + + assertNotNull(created); + assertNotNull(created.customerId()); + assertEquals("Jane Doe", created.name()); + assertEquals("jane.doe@example.com", created.email()); + + Page anyPage = + service.getCustomers( + new CustomerSearchCriteria(null, null), + 0, + 10, + SortField.CUSTOMER_ID, + SortDirection.ASC); + + assertEquals(18, anyPage.totalElements()); + CustomerDto fetched = service.getCustomer(created.customerId()); + assertEquals(created, fetched); + } + + @Test + @DisplayName("getCustomer should return existing customer") + void getCustomer_shouldReturn() { + Page p0 = + service.getCustomers( + new CustomerSearchCriteria(null, null), 0, 1, SortField.CUSTOMER_ID, SortDirection.ASC); + + CustomerDto any = p0.content().getFirst(); + CustomerDto found = service.getCustomer(any.customerId()); + assertEquals(any, found); + } + + @Test + @DisplayName("getCustomer should throw for missing id") + void getCustomer_shouldThrowWhenMissing() { + assertThrows(NoSuchElementException.class, () -> service.getCustomer(999_999)); + } + + @Test + @DisplayName("updateCustomer should update name and email") + void updateCustomer_shouldUpdate() { + CustomerDto base = + service.createCustomer(new CustomerCreateRequest("Temp", "temp@example.com")); + var req = new CustomerUpdateRequest("Temp Updated", "temp.updated@example.com"); + + CustomerDto updated = service.updateCustomer(base.customerId(), req); + + assertEquals(base.customerId(), updated.customerId()); + assertEquals("Temp Updated", updated.name()); + assertEquals("temp.updated@example.com", updated.email()); + } + + @Test + @DisplayName("updateCustomer should throw when customer does not exist") + void updateCustomer_shouldThrowWhenMissing() { + var req = new CustomerUpdateRequest("X", "x@example.com"); + assertThrows(NoSuchElementException.class, () -> service.updateCustomer(123456, req)); + } + + @Test + @DisplayName("deleteCustomer should remove the record and decrease total") + void deleteCustomer_shouldRemove() { + CustomerDto base = + service.createCustomer(new CustomerCreateRequest("Mark Lee", "mark.lee@example.com")); + + Page before = + service.getCustomers( + new CustomerSearchCriteria(null, null), + 0, + 10, + SortField.CUSTOMER_ID, + SortDirection.ASC); + long totalBefore = before.totalElements(); + + service.deleteCustomer(base.customerId()); + + Page after = + service.getCustomers( + new CustomerSearchCriteria(null, null), + 0, + 10, + SortField.CUSTOMER_ID, + SortDirection.ASC); + assertEquals(totalBefore - 1, after.totalElements()); + assertThrows(NoSuchElementException.class, () -> service.getCustomer(base.customerId())); + } +} diff --git a/samples/spring-boot-4/customer-service/src/test/java/io/github/blueprintplatform/samples/customerservice/testconfig/TestControllerMocksConfig.java b/samples/spring-boot-4/customer-service/src/test/java/io/github/blueprintplatform/samples/customerservice/testconfig/TestControllerMocksConfig.java new file mode 100644 index 0000000..f25085f --- /dev/null +++ b/samples/spring-boot-4/customer-service/src/test/java/io/github/blueprintplatform/samples/customerservice/testconfig/TestControllerMocksConfig.java @@ -0,0 +1,82 @@ +package io.github.blueprintplatform.samples.customerservice.testconfig; + +import io.github.blueprintplatform.samples.customerservice.common.i18n.LocalizedMessageResolver; +import io.github.blueprintplatform.samples.customerservice.service.CustomerService; +import org.mockito.Mockito; +import org.springframework.boot.test.context.TestConfiguration; +import org.springframework.context.annotation.Bean; + +@TestConfiguration +public class TestControllerMocksConfig { + + @Bean + public CustomerService customerService() { + return Mockito.mock(CustomerService.class); + } + + @Bean + public LocalizedMessageResolver messageResolver() { + var mr = Mockito.mock(LocalizedMessageResolver.class); + + Mockito.when(mr.getMessage("problem.title.not_found")).thenReturn("Resource not found"); + Mockito.when(mr.getMessage("problem.title.internal_error")).thenReturn("Internal server error"); + Mockito.when(mr.getMessage("problem.title.bad_request")).thenReturn("Bad request"); + Mockito.when(mr.getMessage("problem.title.validation_failed")).thenReturn("Validation failed"); + Mockito.when(mr.getMessage("problem.title.method_not_allowed")) + .thenReturn("Method not allowed"); + + Mockito.when(mr.getMessage("problem.detail.not_found")) + .thenReturn("Requested resource was not found."); + Mockito.when(mr.getMessage("problem.detail.internal_error")) + .thenReturn("Unexpected error occurred."); + Mockito.when(mr.getMessage("problem.detail.bad_request")).thenReturn("Malformed request body."); + Mockito.when(mr.getMessage("problem.detail.validation_failed")) + .thenReturn("One or more fields are invalid."); + Mockito.when(mr.getMessage("problem.detail.method_not_allowed")) + .thenReturn("The request method is not supported for this resource."); + + Mockito.when(mr.getMessage("request.body.invalid")).thenReturn("Invalid JSON payload."); + Mockito.when(mr.getMessage("request.endpoint.not_found")).thenReturn("Endpoint not found."); + Mockito.when(mr.getMessage("request.resource.not_found")).thenReturn("Resource not found."); + Mockito.when(mr.getMessage("request.param.invalid")) + .thenReturn("One or more parameters are invalid."); + + Mockito.when(mr.getMessage("server.internal.error")) + .thenReturn("Internal server error. Please try again later."); + + Mockito.when(mr.getMessage("request.method.not_supported", "GET")) + .thenReturn("HTTP method not supported: GET"); + Mockito.when(mr.getMessage("request.method.not_supported", "POST")) + .thenReturn("HTTP method not supported: POST"); + Mockito.when(mr.getMessage("request.method.not_supported", "PUT")) + .thenReturn("HTTP method not supported: PUT"); + Mockito.when(mr.getMessage("request.method.not_supported", "DELETE")) + .thenReturn("HTTP method not supported: DELETE"); + + Mockito.when(mr.getMessage("request.param.required_missing", "page")) + .thenReturn("Missing required parameter: page"); + Mockito.when(mr.getMessage("request.param.required_missing", "size")) + .thenReturn("Missing required parameter: size"); + Mockito.when(mr.getMessage("request.param.required_missing", "sortBy")) + .thenReturn("Missing required parameter: sortBy"); + Mockito.when(mr.getMessage("request.param.required_missing", "direction")) + .thenReturn("Missing required parameter: direction"); + + Mockito.when(mr.getMessage("request.header.missing", "Authorization")) + .thenReturn("Required request header 'Authorization' is missing"); + + Mockito.when(mr.getMessage("request.param.type_mismatch", "Integer")) + .thenReturn("Invalid value (expected Integer)."); + Mockito.when(mr.getMessage("request.param.type_mismatch", "SortDirection")) + .thenReturn("Invalid value (expected SortDirection)."); + Mockito.when(mr.getMessage("request.param.type_mismatch", "SortField")) + .thenReturn("Invalid value (expected SortField)."); + + Mockito.when(mr.getMessage("request.body.field.unrecognized", "foo")) + .thenReturn("Unrecognized field: 'foo'"); + Mockito.when(mr.getMessage("request.body.invalid_format", "String", "123")) + .thenReturn("Invalid format: expected String, value 123"); + + return mr; + } +} diff --git a/samples/spring-boot-4/docker-compose.yml b/samples/spring-boot-4/docker-compose.yml new file mode 100644 index 0000000..7617684 --- /dev/null +++ b/samples/spring-boot-4/docker-compose.yml @@ -0,0 +1,31 @@ +services: + customer-service: + container_name: customer-service-sb4 + build: + context: ./customer-service + dockerfile: Dockerfile + image: customer-service-sb4:latest + restart: on-failure + environment: + APP_PORT: ${APP_PORT:-8094} + SPRING_PROFILES_ACTIVE: ${SPRING_PROFILES_ACTIVE:-local} + JAVA_OPTS: ${JAVA_OPTS:-} + ports: + - "${APP_PORT:-8094}:${APP_PORT:-8094}" + + customer-service-consumer: + container_name: customer-service-consumer-sb4 + build: + context: ./customer-service-consumer + dockerfile: Dockerfile + image: customer-service-consumer-sb4:latest + restart: on-failure + depends_on: + - customer-service + environment: + APP_PORT: ${CONSUMER_APP_PORT:-8095} + SPRING_PROFILES_ACTIVE: ${SPRING_PROFILES_ACTIVE:-local} + CUSTOMER_API_BASE_URL: http://customer-service-sb4:8094/customer-service + JAVA_OPTS: ${JAVA_OPTS:-} + ports: + - "${CONSUMER_APP_PORT:-8095}:${CONSUMER_APP_PORT:-8095}" \ No newline at end of file