diff --git a/README.md b/README.md index 0caa3d4..b9a5f47 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,7 @@ -# Spring Boot + OpenAPI Generator — Type-Safe Generics for Clean API Clients +# Spring Boot + OpenAPI Generator — End-to-End Generics-Aware API Clients [![Build](https://github.com/bsayli/spring-boot-openapi-generics-clients/actions/workflows/build.yml/badge.svg)](https://github.com/bsayli/spring-boot-openapi-generics-clients/actions/workflows/build.yml) -[![Release](https://img.shields.io/github/v/release/bsayli/spring-boot-openapi-generics-clients?logo=github&label=release)](https://github.com/bsayli/spring-boot-openapi-generics-clients/releases/latest) +[![Release](https://img.shields.io/github/v/release/bsayli/spring-boot-openapi-generics-clients?logo=github\&label=release)](https://github.com/bsayli/spring-boot-openapi-generics-clients/releases/latest) [![codecov](https://codecov.io/gh/bsayli/spring-boot-openapi-generics-clients/branch/main/graph/badge.svg)](https://codecov.io/gh/bsayli/spring-boot-openapi-generics-clients) [![Java](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.4.10-green?logo=springboot)](https://spring.io/projects/spring-boot) @@ -13,356 +13,296 @@

OpenAPI Generics Cover
- Type-safe API responses without boilerplate — powered by Spring Boot & OpenAPI Generator + End-to-end generics-aware OpenAPI clients — unified { data, meta } responses without boilerplate.

-**Type-safe client generation with Spring Boot & OpenAPI using generics.** -This repository demonstrates how to extend OpenAPI Generator to work with generics in order to avoid boilerplate, reduce -duplicated wrappers, and keep client code clean. +**Modern, type-safe OpenAPI client generation** — powered by **Spring Boot 3.4**, **Java 21**, and **OpenAPI Generator +7.16.0**. +This repository demonstrates a production-grade architecture where backend and client are fully aligned through +generics, enabling nested generic envelopes (ServiceResponse>) and RFC 7807 ProblemDetail (Problem Details for +HTTP APIs)–based error handling. --- ## 📑 Table of Contents -- 📦 [Modules](#-modules-in-this-repository) -- 🛠 [Compatibility Matrix](#-compatibility-matrix) -- 🚀 [Problem Statement](#-problem-statement) -- 💡 [Solution](#-solution) -- ⚡ [Quick Start](#-quick-start) -- 🧩 [Tech Stack & Features](#-tech-stack--features) -- ✅ [Key Features](#-key-features) -- ✨ [Usage Example (Adapter Interface)](#-usage-example-adapter-interface) -- 📦 [Related Modules (Quick View)](#-related-modules-quick-view) -- 📘 [Adoption Guides](#-adoption-guides) -- 🔗 [References & Links](#-references--links) - -> *A practical reference for type-safe OpenAPI client generation using Spring Boot 3.4, Java 21, and Mustache templates.* - -### 📦 Modules in this Repository - -This repository consists of two main modules: - -- [**customer-service**](customer-service/README.md) — Sample API producer (Spring Boot microservice + OpenAPI spec) -- [**customer-service-client**](customer-service-client/README.md) — Generated Java client (generics support via custom - templates) +* 📦 [Modules](#-modules) +* 🚀 [Problem & Motivation](#-problem--motivation) +* 💡 [Solution Overview](#-solution-overview) +* ⚙️ [New Architecture Highlights](#-new-architecture-highlights) +* ⚡ [Quick Start](#-quick-start) +* 🖼 [Generated Client Wrapper — Before & After](#-generated-client-wrapper--before--after) +* 🧱 [Example Responses](#-example-responses) +* 🧩 [Tech Stack](#-tech-stack) +* ✅ [Key Features](#-key-features) +* ✨ [Usage Example](#-usage-example) +* 📘 [Adoption Guides](#-adoption-guides) +* 🔗 [References & Links](#-references--links) + +> *A clean architecture pattern for building generics-aware OpenAPI clients that stay fully type-safe, consistent, and +boilerplate-free.* --- -### 🔧 Compatibility Matrix +## 📦 Modules -| Component | Version | -|-------------------------|---------| -| **Java** | 21 | -| **Spring Boot** | 3.4.10 | -| **Springdoc OpenAPI** | 2.8.13 | -| **OpenAPI Generator** | 7.16.0 | -| **Apache HttpClient 5** | 5.5 | +* [**customer-service**](customer-service/README.md) — sample backend exposing `/v3/api-docs.yaml` via Springdoc +* [**customer-service-client**](customer-service-client/README.md) — generated OpenAPI client with generics-aware + wrappers --- -## 🚀 Problem Statement +## 🚀 Problem & Motivation -Most backend teams standardize responses with a generic wrapper like `ServiceResponse`. -However, **OpenAPI Generator does not natively support generics** — instead, it generates one wrapper per endpoint ( -duplicating fields like `status`, `message`, and `errors`). +OpenAPI Generator, by default, does not handle **generic response types**. +When backend APIs wrap payloads in `ServiceResponse` (e.g., the unified `{ data, meta }` envelope), +the generator produces **duplicated models per endpoint** instead of a single reusable generic base. -This creates: +This results in: -* ❌ Dozens of almost-identical classes -* ❌ High maintenance overhead -* ❌ No single place to evolve the response envelope +* ❌ Dozens of almost-identical response classes +* ❌ Higher maintenance overhead +* ❌ Harder to evolve a single envelope contract across services --- -## 💡 Solution - -This project shows how to: +## 💡 Solution Overview -* Customize **Springdoc** to mark wrapper schemas in OpenAPI -* Add a **tiny Mustache partial** so the generator emits thin shells extending a reusable generic base -* Keep **compile-time type safety** without repetitive mappers +This project provides a **full-stack pattern** to align Spring Boot services and OpenAPI clients: ---- +### Server-Side (Producer) -## 🧠 How It Works (Under the Hood) +A `Springdoc` customizer automatically scans controller return types and marks generic wrappers (`ServiceResponse`) +using vendor extensions: -At generation time, the reference service **auto-registers** wrapper schemas in the OpenAPI doc: - -* A Spring `OpenApiCustomizer` scans controller return types and unwraps `ResponseEntity`, `CompletionStage`, Reactor ( - `Mono`/`Flux`), etc. until it reaches `ServiceResponse`. -* For every discovered `T`, it adds a `ServiceResponse{T}` schema that composes the base envelope + the concrete `data` - type, and marks it with vendor extensions: - - * `x-api-wrapper: true` - * `x-api-wrapper-datatype: ` +```yaml +x-api-wrapper: true +x-api-wrapper-datatype: CustomerDto +x-data-container: Page +x-data-item: CustomerDto +``` -The Java client then uses a tiny Mustache override to render **thin shells** for those marked schemas: +### Client-Side (Consumer) -```mustache -// api_wrapper.mustache -import {{commonPackage}}.ServiceClientResponse; +Mustache overlays redefine OpenAPI templates to generate **thin, type-safe wrappers** extending a reusable base class +`ServiceClientResponse`. -public class {{classname}} - extends ServiceClientResponse<{{vendorExtensions.x-api-wrapper-datatype}}> { -} -``` - -This is what turns e.g. `ServiceResponseCustomerCreateResponse` into: +**Example generated output:** ```java -public class ServiceResponseCustomerCreateResponse - extends ServiceClientResponse { +public class ServiceResponseCustomerDto extends ServiceClientResponse { } ``` +This pattern supports **nested generics** like `ServiceClientResponse>` and maps all error responses +into **ProblemDetail** objects. + --- -## ⚡ Quick Start +## ⚙️ New Architecture Highlights + +

+ OpenAPI Generics Architecture +
+ End-to-end generics-aware architecture: from Spring Boot producer to OpenAPI client consumer. +

-Run the reference service: +| Layer | Description | +|-----------------------|---------------------------------------------------------------------------| +| **Server (Producer)** | Publishes OpenAPI 3.1 spec with auto-registered wrapper schemas | +| **Client (Consumer)** | Uses OpenAPI Generator 7.16.0 + Mustache overlays for generics support | +| **Envelope Model** | Unified `{ data, meta }` response structure | +| **Error Handling** | RFC 7807-compliant `ProblemDetail` decoding into `ClientProblemException` | +| **Nested Generics** | Full support for `ServiceResponse>` | -```bash -cd customer-service -mvn spring-boot:run -``` +--- -Generate and build the client: +## ⚡ Quick Start ```bash -cd customer-service-client -mvn clean install +# Run the backend service +cd customer-service && mvn spring-boot:run + +# Generate and build the OpenAPI client +cd ../customer-service-client && mvn clean install ``` -Use the generated API: +Generated wrappers appear under: -```java -ServiceClientResponse response = - customerControllerApi.createCustomer(request); +``` +target/generated-sources/openapi/src/gen/java ``` -### 🖼 Swagger Screenshot - -Here’s what the `create customer` endpoint looks like in Swagger UI after running the service: +Each wrapper extends `ServiceClientResponse` and aligns perfectly with the `{ data, meta }` envelope model. -![Customer create example](docs/images/swagger-customer-create.png) +--- -### 🖼 Generated Client Wrapper +## 🖼 Generated Client Wrapper — Before & After -Comparison of how OpenAPI Generator outputs looked **before** vs **after** adding the generics-aware wrapper: +Comparison of how OpenAPI Generator outputs looked **before** vs **after** enabling the generics-aware wrapper support. **Before (duplicated full model):** -![Generated client (before)](docs/images/generated-client-wrapper-before.png) +

+ Generated client before generics support +
+ Each endpoint generated its own full response model — duplicated data and meta fields across classes. +

**After (thin generic wrapper):** -![Generated client (after)](docs/images/generated-client-wrapper-after.png) - ---- - -## ✅ Verify in 60 Seconds - -1. Clone this repo -2. Run `mvn clean install -q` -3. Open `customer-service-client/target/generated-sources/...` -4. See the generated wrappers → they now extend a **generic base class** instead of duplicating fields. - -You don’t need to write a single line of code — the generator does the work. - ---- - -## 🛠 Tech Stack & Features - -* 🚀 **Java 21** — modern language features -* 🍃 **Spring Boot 3.4.10** — microservice foundation -* 📖 **Springdoc OpenAPI** — API documentation -* 🔧 **OpenAPI Generator 7.x** — client code generation -* 🧩 **Custom Mustache templates** — generics-aware wrappers -* 🧪 **JUnit 5 + MockWebServer** — integration testing -* 🌐 **Apache HttpClient 5** — connection pooling & timeouts - ---- +

+ Generated client after generics support +
+ Now every endpoint extends the reusable ServiceClientResponse<Page<T>> base, eliminating boilerplate and preserving type safety. +

-## 📦 Next Steps — Dependency-Based Adoption +--- + +## 🧱 Example Responses + +The unified envelope applies to both single and paged responses. Below is a paged example: + +### Paged Example (`ServiceClientResponse>`) + +```json +{ + "data": { + "content": [ + { + "customerId": 1, + "name": "Jane Doe", + "email": "jane@example.com" + }, + { + "customerId": 2, + "name": "John Smith", + "email": "john@example.com" + } + ], + "page": 0, + "size": 5, + "totalElements": 37, + "totalPages": 8, + "hasNext": true, + "hasPrev": false + }, + "meta": { + "serverTime": "2025-01-01T12:34:56Z", + "sort": [ + { + "field": "CUSTOMER_ID", + "direction": "ASC" + } + ] + } +} +``` -This pattern is evolving toward a plug-and-play module approach, so teams can adopt it without manual template setup. +Client usage: -The long-term goal is to publish the core pieces as standalone modules, so that any project using -a generic response type like `ServiceResponse` can enable the same behavior with **just one dependency**: +```java +ServiceClientResponse> resp = + customerClientAdapter.getCustomers( + "Jane", null, 0, 5, SortField.CUSTOMER_ID, SortDirection.ASC); -- `io.github.bsayli:openapi-generics-autoreg` → **server-side**: automatically registers wrapper schemas in the OpenAPI - spec. -- `io.github.bsayli:openapi-generics-templates` → **client-side**: plugs into OpenAPI Generator for thin, type-safe - wrappers. +Page page = resp.getData(); +for( +CustomerDto c :page. -This will let teams adopt **generics-aware OpenAPI support** without copying customizers or Mustache templates — -just by adding a Maven/Gradle dependency. +content()){ + // ... + } +``` --- -## 📂 Project Structure +## 🧩 Tech Stack -```text -spring-boot-openapi-generics-clients/ - ├── customer-service/ # Sample Spring Boot microservice (API producer) - ├── customer-service-client/ # Generated client using custom templates - └── README.md # Root documentation -``` +| Component | Version | Purpose | +|-----------------------|---------|---------------------------------------| +| **Java** | 21 | Language baseline | +| **Spring Boot** | 3.4.10 | REST + OpenAPI provider | +| **Springdoc** | 2.8.13 | OpenAPI 3.1 integration | +| **OpenAPI Generator** | 7.16.0 | Generics-aware code generation | +| **HttpClient5** | 5.5 | Pooled, production-ready HTTP backend | --- -## 🧩 Key Features +## ✅ Key Features -* ✅ **Generic base model**: `ServiceClientResponse` -* ✅ **Thin wrappers**: endpoint-specific shells extending the base -* ✅ **Strong typing preserved**: `getData()` returns the exact payload type -* ✅ **No duplicated fields** across wrappers -* ✅ Easy to maintain and evolve +* 🔹 Unified `{ data, meta }` response model +* 🔹 Nested generics support — `ServiceResponse>` +* 🔹 RFC 7807-compliant error mapping (`ProblemDetail`) +* 🔹 Mustache overlay templates for thin wrapper generation +* 🔹 Seamless compatibility between backend and client +* 🔹 Zero boilerplate — clean, evolvable, and type-safe --- -### ✨ Usage Example: Adapter Interface - -Sometimes you don’t want to expose all the thin wrappers directly. -A simple adapter interface can consolidate them into clean, type-safe methods: +## ✨ Usage Example ```java public interface CustomerClientAdapter { - ServiceClientResponse createCustomer(CustomerCreateRequest request); + ServiceClientResponse createCustomer(CustomerCreateRequest request); ServiceClientResponse getCustomer(Integer customerId); - ServiceClientResponse getCustomers(); - - ServiceClientResponse updateCustomer( - Integer customerId, CustomerUpdateRequest request); - - ServiceClientResponse deleteCustomer(Integer customerId); + ServiceClientResponse> getCustomers(); } ``` ---- - -## 🔍 Why This Matters - -Without generics support, OpenAPI client generation creates bloated and repetitive code. -By applying this approach: - -* Development teams **save time** maintaining response models -* Client libraries become **cleaner and smaller** -* Easier for **new developers** to understand the contract -* Code stays **future-proof** when envelope fields evolve - ---- - -## 💼 Use Cases - -This pattern is useful when: - -* You have **multiple microservices** with a shared response structure -* You need to **evolve response envelopes** without breaking dozens of generated classes -* You want **type safety** in generated clients but without boilerplate - ---- - -## 🔧 How to Run - -1. **Start the reference service** - - ```bash - cd customer-service - mvn spring-boot:run - ``` - -2. **Generate the client** - - ```bash - cd customer-service-client - mvn clean install - ``` - -3. **Use the generated API** - - ```java - ServiceClientResponse response = - customerControllerApi.createCustomer(request); - ``` - ---- - -## 👤 Who Should Use This? - -* Backend developers maintaining multiple microservices -* API platform teams standardizing response envelopes -* Teams already invested in OpenAPI Generator looking to reduce boilerplate +This adapter defines a stable contract that hides generated artifacts and provides type-safe access to your APIs. --- -## ⚠️ Why Not Use It? +## 📘 Adoption Guides -This project may not be the right fit if: +See the detailed integration steps under [`docs/adoption`](docs/adoption): -* Your APIs do **not** use a common response wrapper -* You are fine with duplicated wrapper models -* You don’t generate client code from OpenAPI specs +* [Server-Side Adoption](docs/adoption/server-side-adoption.md) +* [Client-Side Adoption](docs/adoption/client-side-adoption.md) --- -## 📦 Related Modules (Quick View) - -| Module | Description | Docs | -|--------------------------------|---------------------------------------------|---------------------------------------------| -| 🟢 **customer-service** | Spring Boot sample API (producer) | [README](customer-service/README.md) | -| 🔵 **customer-service-client** | Generated Java client with generics support | [README](customer-service-client/README.md) | - ---- - -## 📘 Adoption Guides -Looking to integrate this approach into your own project? -See the detailed guides under [`docs/adoption`](docs/adoption): +## 🔗 References & Links -- [Server-Side Adoption](docs/adoption/server-side-adoption.md) -- [Client-Side Adoption](docs/adoption/client-side-adoption.md) +* 🌐 [GitHub Pages — Adoption Guides](https://bsayli.github.io/spring-boot-openapi-generics-clients/) +* ---- +📘 [Medium — Type-Safe Generic API Responses](https://medium.com/@baris.sayli/type-safe-generic-api-responses-with-spring-boot-3-4-openapi-generator-and-custom-templates-ccd93405fb04) -## 📂 References & Links +* -- 🌐 [GitHub Pages — Adoption Guides](https://bsayli.github.io/spring-boot-openapi-generics-clients/) -- 📘 [Medium — Why OpenAPI Clients Get Messy — and How to Keep Them Type-Safe](https://medium.com/@baris.sayli/type-safe-generic-api-responses-with-spring-boot-3-4-openapi-generator-and-custom-templates-ccd93405fb04) -- 💬 [Dev.to — How to Build Type-Safe OpenAPI Clients Without Boilerplate](https://dev.to/barissayli/spring-boot-openapi-generator-type-safe-generic-api-clients-without-boilerplate-3a8f) +💬 [Dev.to — Type-Safe OpenAPI Clients Without Boilerplate](https://dev.to/barissayli/spring-boot-openapi-generator-type-safe-generic-api-clients-without-boilerplate-3a8f) --- ## 🛡 License -This repository is licensed under **MIT** (see [LICENSE](LICENSE)). Submodules inherit the license. - ---- - -✅ **Note:** CLI examples should always be provided on a single line. -If parameters include spaces or special characters, wrap them in quotes `"..."`. +Licensed under **MIT** — see [LICENSE](LICENSE). --- ## 💬 Feedback -If you spot any mistakes in this README or have questions about the project, feel free to open an issue or start a discussion. I’m happy to improve the documentation and clarify concepts further! +If you spot an error or have suggestions, open an issue or join the discussion — contributions are welcome. +💭 [Start a discussion →](https://github.com/bsayli/spring-boot-openapi-generics-clients/discussions) --- ## 🤝 Contributing Contributions, issues, and feature requests are welcome! -Feel free to [open an issue](../../issues) or submit a PR. +Feel free to [open an issue](https://github.com/bsayli/spring-boot-openapi-generics-clients/issues) or submit a PR. --- ## ⭐ Support -If you found this project useful, please consider giving it a star ⭐ on GitHub — it helps others discover it too! +If you found this project helpful, please give it a ⭐ on GitHub — it helps others discover it. --- -**Barış Saylı** -[GitHub](https://github.com/bsayli) · [Medium](https://medium.com/@baris.sayli) \ No newline at end of file +**Barış Saylı** +[GitHub](https://github.com/bsayli) · [Medium](https://medium.com/@baris.sayli) diff --git a/customer-service-client/README.md b/customer-service-client/README.md index 342248b..73ada96 100644 --- a/customer-service-client/README.md +++ b/customer-service-client/README.md @@ -5,18 +5,16 @@ [![OpenAPI Generator](https://img.shields.io/badge/OpenAPI%20Generator-7.16.0-blue?logo=openapiinitiative)](https://openapi-generator.tech/) [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](../LICENSE) -Generated Java client for the **customer-service**, showcasing **type-safe generic responses** with OpenAPI + a -custom Mustache template (wrapping payloads in a reusable `ServiceClientResponse`). - -This module demonstrates how to evolve OpenAPI Generator with minimal customization to support generic response -envelopes — avoiding duplicated wrappers and preserving strong typing. +Generated Java client for the **customer-service**, showcasing **type‑safe generic responses** and **nested generics** +with a minimal OpenAPI Generator Mustache overlay. The client maps successful responses into a reusable envelope +`ServiceClientResponse` and decodes non‑2xx responses into RFC7807-compliant `ProblemDetail` via a custom exception. --- -## 🔧 TL;DR: Generate in 1 minute +## 🔧 TL;DR — Generate in 1 Minute ```bash -# 1) Start the customer service server (in another shell) +# 1) Start the customer-service (in another shell) cd customer-service && mvn -q spring-boot:run # 2) Pull the OpenAPI spec into the client module @@ -30,257 +28,114 @@ mvn -q clean install *Generated sources → `target/generated-sources/openapi/src/gen/java`* -> ℹ️ **Multi-module builds:** If your project is multi-module, ensure the generated path is compiled via -`build-helper-maven-plugin` (already configured in this repo’s `pom.xml`). +> ℹ️ **Multi-module builds:** If your project is multi-module, ensure the generated path is compiled. (Handled via +> `build-helper-maven-plugin` in this repo.) --- ## ✅ What You Get -* Generated code using **OpenAPI Generator** (`restclient` with Spring Framework `RestClient`). -* A reusable generic base: `io.github.bsayli.openapi.client.common.ServiceClientResponse`. -* Thin wrappers per endpoint (e.g. `ServiceResponseCustomerCreateResponse`, `ServiceResponseCustomerUpdateResponse`). -* Spring Boot configuration to auto-expose the client as beans. -* Focused integration tests using **OkHttp MockWebServer** covering all CRUD endpoints. - ---- - -## 🚀 Quick Pipeline (3 Steps) - -1. **Run the sample service** - -```bash -cd customer-service -mvn spring-boot:run -# Service base URL: http://localhost:8084/customer-service -``` - -2. **Pull the OpenAPI spec into this module** - -```bash -cd customer-service-client -curl -s http://localhost:8084/customer-service/v3/api-docs.yaml \ - -o src/main/resources/customer-api-docs.yaml -``` - -3. **Generate & build the client** - -```bash -mvn -q clean install -``` - -### What got generated? - -Look for these classes under `target/generated-sources/openapi/src/gen/java`: - -* `io.github.bsayli.openapi.client.generated.dto.ServiceResponseCustomerCreateResponse` -* `...ServiceResponseCustomerUpdateResponse`, etc. - -Each is a **thin shell** extending `ServiceClientResponse`. +* Java client using **OpenAPI Generator 7.16.0** with the **Spring `RestClient`** library. +* A reusable generic base: `io.github.bsayli.openapi.client.common.ServiceClientResponse` containing `data` and + `meta`. +* **Nested generics support**: wrappers such as `ServiceClientResponse>`. +* **RFC 7807 Problem decoding** via `ClientProblemException`. +* **Spring Boot configuration** for pooled HttpClient5 + `RestClientCustomizer` for error handling. +* Adapter pattern for clean, type-safe service integration. --- -## 🚫 Not a Published Library (Re-generate in your project) - -This module is a **reference demo**, not a published library. -To apply the same approach in your own project: - -1. Generate your own OpenAPI spec (`/v3/api-docs.yaml`). -2. Copy the two Mustache templates (`api_wrapper.mustache`, `model.mustache`) into your project. -3. Run OpenAPI Generator with your spec + templates → you’ll get type-safe wrappers. - -> ⚠️ **Do not add `customer-service-client` as a Maven/Gradle dependency in your project.** -> Instead, re-generate your own client using **your service’s OpenAPI spec** and the provided Mustache templates. - -## 📘 Adoption Guides - -Looking to integrate this approach into your own project? -See the detailed guides under [`docs/adoption`](../docs/adoption): - -- [Server-Side Adoption](../docs/adoption/server-side-adoption.md) -- [Client-Side Adoption](../docs/adoption/client-side-adoption.md) - ---- - -## 📦 Prerequisites - -Before generating or using the client, make sure you have: - -* **Java 21** or newer -* **Maven 3.9+** (or Gradle 8+ if you adapt the build) -* A running instance of the `customer-service` exposing its OpenAPI spec -* OpenAPI spec (saved locally in this repo as `src/main/resources/customer-api-docs.yaml`) - ---- - -## 🎯 Scope & Non-Goals - -This module focuses on **generics-aware client generation** only. Specifically, it demonstrates: - -* Marking wrapper schemas via OpenAPI **vendor extensions** (e.g., `x-api-wrapper`, `x-api-wrapper-datatype`) -* A tiny **Mustache overlay** that emits **thin wrapper classes** extending a reusable `ServiceClientResponse` -* How those wrappers enable **compile-time type safety** in consumer code (see the adapter example) - -**Out of scope (non-goals):** - -* Runtime concerns such as error handling strategies for non-2xx responses, retries, logging, metrics, circuit-breaking -* Business validation, pagination conventions, or API design guidelines -* Authentication/authorization configuration -* Packaging and publishing this client as a reusable library - -If you need those capabilities, add them in your host application or platform code. This repo is intentionally minimal -to keep the focus on the **wrapper generation pattern**. - ---- - -## 🔄 How thin wrappers are produced (end-to-end flow) - -``` -Controller returns `ServiceResponse` - │ - ▼ -Springdoc `OpenApiCustomizer` discovers `T` and marks wrapper schemas -(vendor extensions: `x-api-wrapper: true`, `x-api-wrapper-datatype: `) - │ - ▼ -OpenAPI spec (YAML/JSON) contains `ServiceResponse{T}` schemas with vendor extensions - │ - ▼ -OpenAPI Generator runs with a tiny Mustache overlay -(`api_wrapper.mustache`, `model.mustache`) - │ - ▼ -Generated Java classes: -- Thin wrappers like `ServiceResponseCustomerCreateResponse` - extending `ServiceClientResponse` -- No duplicated envelope fields - │ - ▼ -Consumer code (e.g., adapter) gets compile-time type safety +## 🧠 How the Thin Wrappers Are Produced + +Server marks wrapper schemas with vendor extensions. The Mustache overlay generates thin wrappers extending the generic +base. + +```mustache +{{! Generics-aware thin wrapper }} +import {{commonPackage}}.ServiceClientResponse; +{{#vendorExtensions.x-data-container}} +import {{commonPackage}}.{{vendorExtensions.x-data-container}}; +{{/vendorExtensions.x-data-container}} + +{{#vendorExtensions.x-class-extra-annotation}} +{{{vendorExtensions.x-class-extra-annotation}}} +{{/vendorExtensions.x-class-extra-annotation}} +public class {{classname}} extends ServiceClientResponse< + {{#vendorExtensions.x-data-container}} + {{vendorExtensions.x-data-container}}<{{vendorExtensions.x-data-item}}> + {{/vendorExtensions.x-data-container}} + {{^vendorExtensions.x-data-container}} + {{vendorExtensions.x-api-wrapper-datatype}} + {{/vendorExtensions.x-data-container}} +> {} ``` -**Key files** +**Example outputs:** -* Template directory: `src/main/resources/openapi-templates/` -* Templates (overlay): `src/main/resources/openapi-templates/api_wrapper.mustache`, `.../model.mustache` -* Generated output: `target/generated-sources/openapi/src/gen/java` -* Packages (from `pom.xml`): `apiPackage`, `modelPackage`, `invokerPackage` +* `ServiceResponseCustomerDto` → `extends ServiceClientResponse` +* `ServiceResponsePageCustomerDto` → `extends ServiceClientResponse>` --- -## 🧰 Troubleshooting (quick) +## 📦 Core Components -* **No thin wrappers generated?** - Ensure wrapper schemas in your OpenAPI spec include the vendor extensions: - `x-api-wrapper: true` and `x-api-wrapper-datatype`. - Confirm your generator points to the correct `` **and** that effective templates are copied (see - the `maven-dependency-plugin` + `maven-resources-plugin` steps). - If unsure, delete `target/` and run `mvn clean install`. - -* **Wrong packages or missing classes?** - Ensure `apiPackage`, `modelPackage`, and `invokerPackage` in the plugin configuration match what you expect. - Delete `target/` and re-run: `mvn clean install`. - -* **Spec is stale?** - Re-pull it: - - ```bash - curl -s http://localhost:8084/customer-service/v3/api-docs.yaml \ - -o src/main/resources/customer-api-docs.yaml - mvn -q clean install - ``` - -* **Validation/annotations not found at runtime?** - Some dependencies (e.g., `spring-web`, `jakarta.*`) are marked **provided**. - Your host app must supply them on the classpath. - -* **Base URL not applied?** - If you use the Spring configuration, set `customer.api.base-url` correctly and ensure the `RestClient` bean is - created. - ---- - -## 🧩 Using the Client - -### Option A — Quick Start (simple RestClient for demos/dev) +**Envelope and Meta:** ```java +public class ServiceClientResponse { + private T data; + private ClientMeta meta; +} -package your.pkg; - -import io.github.bsayli.openapi.client.generated.api.CustomerControllerApi; -import io.github.bsayli.openapi.client.generated.invoker.ApiClient; -import org.springframework.beans.factory.annotation.Value; -import org.springframework.context.annotation.Bean; -import org.springframework.context.annotation.Configuration; -import org.springframework.web.client.RestClient; - -@Configuration -public class CustomerApiClientConfig { - - @Bean - RestClient customerRestClient(RestClient.Builder builder) { - return builder.build(); - } - - @Bean - ApiClient customerApiClient(RestClient customerRestClient, - @Value("${customer.api.base-url}") String baseUrl) { - return new ApiClient(customerRestClient).setBasePath(baseUrl); - } +public record ClientMeta(Instant serverTime, List sort) { +} - @Bean - CustomerControllerApi customerControllerApi(ApiClient customerApiClient) { - return new CustomerControllerApi(customerApiClient); - } +public record Page(List content, int page, int size, + long totalElements, int totalPages, + boolean hasNext, boolean hasPrev) { } ``` -**application.properties:** +**Problem Exception:** -```properties -customer.api.base-url=http://localhost:8084/customer-service +```java +public class ClientProblemException extends RuntimeException { + private final transient ProblemDetail problem; + private final int status; +} ``` -> **Note — demo only:** -> The configuration above wires the generated client beans for quick local demos. -> In production, you should encapsulate `CustomerControllerApi` behind your own Adapter (see [✅ Using the Client in Another Microservice](#-using-the-client-in-another-microservice) below). -> This keeps generated code isolated and lets your services depend only on stable adapter interfaces. - --- -### Option B — Recommended (production-ready with HttpClient5 pooling) - -If you want more control (connection pooling, timeouts, etc.), you can wire the client with **Apache HttpClient5**: +## ⚙️ Spring Configuration (Production-Ready) ```java -import io.github.bsayli.openapi.client.generated.api.CustomerControllerApi; -import io.github.bsayli.openapi.client.generated.invoker.ApiClient; - -import java.time.Duration; - -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.springframework.beans.factory.annotation.Value; -import org.springframework.context.annotation.Bean; -import org.springframework.context.annotation.Configuration; -import org.springframework.http.client.HttpComponentsClientHttpRequestFactory; -import org.springframework.web.client.RestClient; @Configuration public class CustomerApiClientConfig { + @Bean + RestClientCustomizer problemDetailStatusHandler(ObjectMapper om) { + return builder -> builder.defaultStatusHandler( + HttpStatusCode::isError, + (request, response) -> { + ProblemDetail pd = null; + try (var is = response.getBody()) { + pd = om.readValue(is, ProblemDetail.class); + } catch (Exception ignore) { + } + throw new ClientProblemException(pd, response.getStatusCode().value()); + }); + } + @Bean(destroyMethod = "close") CloseableHttpClient customerHttpClient( @Value("${customer.api.max-connections-total:64}") int maxTotal, @Value("${customer.api.max-connections-per-route:16}") int maxPerRoute) { - var cm = PoolingHttpClientConnectionManagerBuilder.create() .setMaxConnTotal(maxTotal) .setMaxConnPerRoute(maxPerRoute) .build(); - return HttpClients.custom() .setConnectionManager(cm) .evictExpiredConnections() @@ -290,50 +145,23 @@ public class CustomerApiClientConfig { .build(); } - @Bean - HttpComponentsClientHttpRequestFactory customerRequestFactory( - CloseableHttpClient customerHttpClient, - @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 f = new HttpComponentsClientHttpRequestFactory(customerHttpClient); - f.setConnectTimeout(Duration.ofSeconds(connect)); - f.setConnectionRequestTimeout(Duration.ofSeconds(connReq)); - f.setReadTimeout(Duration.ofSeconds(read)); - return f; - } - @Bean RestClient customerRestClient(RestClient.Builder builder, - HttpComponentsClientHttpRequestFactory rf) { - return builder.requestFactory(rf).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); + HttpComponentsClientHttpRequestFactory rf, + List customizers) { + builder.requestFactory(rf); + if (customizers != null) customizers.forEach(c -> c.customize(builder)); + return builder.build(); } } ``` -> **Requires:** `org.apache.httpcomponents.client5:httpclient5` (already included in this module). - **application.properties:** ```properties -# Base URL customer.api.base-url=http://localhost:8084/customer-service -# HttpClient5 pool settings customer.api.max-connections-total=64 customer.api.max-connections-per-route=16 -# Timeouts (in seconds) customer.api.connect-timeout-seconds=10 customer.api.connection-request-timeout-seconds=10 customer.api.read-timeout-seconds=15 @@ -341,306 +169,114 @@ customer.api.read-timeout-seconds=15 --- -### Option C — Manual Wiring (no Spring context) - -```java -var rest = RestClient.builder().baseUrl("http://localhost:8084/customer-service").build(); -var apiClient = new io.github.bsayli.openapi.client.generated.invoker.ApiClient(rest) - .setBasePath("http://localhost:8084/customer-service"); -var customerApi = new io.github.bsayli.openapi.client.generated.api.CustomerControllerApi(apiClient); -``` - ---- - ## 🧩 Adapter Pattern Example -For larger applications, encapsulate the generated API in an adapter: - ```java -package io.github.bsayli.openapi.client.adapter.impl; - -import io.github.bsayli.openapi.client.adapter.CustomerClientAdapter; -import io.github.bsayli.openapi.client.common.ServiceClientResponse; -import io.github.bsayli.openapi.client.generated.api.CustomerControllerApi; -import io.github.bsayli.openapi.client.generated.dto.*; -import org.springframework.stereotype.Service; @Service public class CustomerClientAdapterImpl implements CustomerClientAdapter { + private final CustomerControllerApi api; - private final CustomerControllerApi customerControllerApi; - - public CustomerClientAdapterImpl(CustomerControllerApi customerControllerApi) { - this.customerControllerApi = customerControllerApi; - } - - @Override - public ServiceClientResponse createCustomer(CustomerCreateRequest request) { - return customerControllerApi.createCustomer(request); - } - - @Override - public ServiceClientResponse getCustomer(Integer customerId) { - return customerControllerApi.getCustomer(customerId); - } - - @Override - public ServiceClientResponse getCustomers() { - return customerControllerApi.getCustomers(); - } - - @Override - public ServiceClientResponse updateCustomer(Integer customerId, CustomerUpdateRequest request) { - return customerControllerApi.updateCustomer(customerId, request); + public CustomerClientAdapterImpl(CustomerControllerApi api) { + this.api = api; } @Override - public ServiceClientResponse deleteCustomer(Integer customerId) { - return customerControllerApi.deleteCustomer(customerId); + public ServiceClientResponse> getCustomers( + String name, String email, Integer page, Integer size, + SortField sortBy, SortDirection direction) { + return api.getCustomers( + name, email, page, size, + sortBy != null ? sortBy.value() : SortField.CUSTOMER_ID.value(), + direction != null ? direction.value() : SortDirection.ASC.value()); } } ``` -This ensures: +**Benefits:** * Generated code stays isolated. -* Business code depends only on the adapter interface. -* Naming conventions are consistent with the service (createCustomer, getCustomer, getCustomers, updateCustomer, - deleteCustomer). +* Business logic depends only on stable interfaces. +* Client evolution never leaks across services. --- -## 🔗 Using the Client in Another Microservice - -When another microservice (e.g., `payment-service`) depends on `customer-service-client`, the recommended approach is to wrap the generated adapter behind a stable interface in your own project. - -This keeps generated code fully encapsulated and exposes only a clean contract to the rest of your service. - ---- - -### Example Structure in `payment-service` - -``` -com.example.payment.client.customer - ├─ CustomerServiceClient.java (interface) - └─ CustomerServiceClientImpl.java (implementation using injected adapter) -``` - -### Interface +## 🚀 Quick Usage Example ```java -package com.example.payment.client.customer; - -import io.github.bsayli.openapi.client.common.ServiceClientResponse; -import io.github.bsayli.openapi.client.generated.dto.*; - -public interface CustomerServiceClient { - - ServiceClientResponse createCustomer(CustomerCreateRequest request); - - ServiceClientResponse getCustomer(Integer customerId); - - ServiceClientResponse getCustomers(); - - ServiceClientResponse updateCustomer(Integer customerId, CustomerUpdateRequest request); - - ServiceClientResponse deleteCustomer(Integer customerId); -} +var resp = customerClientAdapter.getCustomer(42); +var dto = resp.getData(); +var serverTime = resp.getMeta().serverTime(); ``` -### Implementation +Error handling: ```java -package com.example.payment.client.customer; - -import io.github.bsayli.openapi.client.adapter.CustomerClientAdapter; -import io.github.bsayli.openapi.client.common.ServiceClientResponse; -import io.github.bsayli.openapi.client.generated.dto.*; -import org.springframework.stereotype.Service; - -@Service -public class CustomerServiceClientImpl implements CustomerServiceClient { - - private final CustomerClientAdapter adapter; - - public CustomerServiceClientImpl(CustomerClientAdapter adapter) { - this.adapter = adapter; - } - - @Override - public ServiceClientResponse createCustomer(CustomerCreateRequest request) { - return adapter.createCustomer(request); - } - - @Override - public ServiceClientResponse getCustomer(Integer customerId) { - return adapter.getCustomer(customerId); - } - - @Override - public ServiceClientResponse getCustomers() { - return adapter.getCustomers(); - } - - @Override - public ServiceClientResponse updateCustomer(Integer customerId, CustomerUpdateRequest request) { - return adapter.updateCustomer(customerId, request); - } - - @Override - public ServiceClientResponse deleteCustomer(Integer customerId) { - return adapter.deleteCustomer(customerId); - } +try{ + customerClientAdapter.getCustomer(999); +}catch( +ClientProblemException ex){ +var pd = ex.getProblem(); +// pd.getTitle(), pd.getDetail(), pd.getErrorCode(), etc. } ``` -### Why This Matters - -* **Encapsulation**: Generated code (`CustomerControllerApi`, DTOs, wrappers) stays hidden. -* **Stable API**: Your microservice depends only on `CustomerServiceClient`. -* **Flexibility**: If client generation changes, your service contract remains intact. -* **Consistency**: All outbound calls to `customer-service` go through one interface. - -✅ With this pattern, you can safely evolve generated clients without leaking implementation details across microservices. - --- -## 🧩 How the Generics Work - -The template at `src/main/resources/openapi-templates/api_wrapper.mustache` emits wrappers like: - -```java -import io.github.bsayli.openapi.client.common.ServiceClientResponse; - -// e.g., ServiceResponseCustomerCreateResponse -public class ServiceResponseCustomerCreateResponse - extends ServiceClientResponse { -} -``` - -Only this Mustache partial is customized. All other models use stock templates. - -### Template overlay (Mustache) +## 📘 Adoption Summary -This module overlays **two** tiny Mustache files on top of the stock Java generator: +1. Mark wrapper schemas in the OpenAPI spec: -* `src/main/resources/openapi-templates/api_wrapper.mustache` -* `src/main/resources/openapi-templates/model.mustache` + * `x-api-wrapper: true` + * `x-api-wrapper-datatype: ` + * Optionally `x-data-container` and `x-data-item` +2. Keep templates under `src/main/resources/openapi-templates/`. +3. Run OpenAPI Generator → wrappers extend `ServiceClientResponse` automatically. -At build time, the Maven `maven-dependency-plugin` unpacks the upstream templates and the -`maven-resources-plugin` overlays the two local files. That’s what enables thin generic wrappers. - -**Disable templates (optional):** -set `` to a non-existent path or comment the overlay steps in `pom.xml` -to compare stock output vs generic wrappers. +For detailed steps, see [`../docs/adoption`](../docs/adoption). --- -## 🧪 Tests - -Integration test with MockWebServer: - -```bash -mvn -q -DskipITs=false test -``` +## 🧰 Troubleshooting -It enqueues responses for **all CRUD operations** and asserts correct mapping into the respective wrappers (e.g. -`ServiceResponseCustomerCreateResponse`, `ServiceResponseCustomerUpdateResponse`). +* **No thin wrappers?** Check vendor extensions + template directory. +* **Nested generics missing?** Ensure `x-data-container` and `x-data-item` exist. +* **ProblemDetail not thrown?** Verify your `RestClientCustomizer`. +* **Provided deps:** Ensure your host app includes `jakarta.validation` & Spring Web. --- ## 📚 Notes -* **Provided (host app must supply):** - - `org.springframework.boot:spring-boot-starter-web` - - `org.springframework.boot:spring-boot-starter` - - `jakarta.validation:jakarta.validation-api` ← required for generated model annotations (`@NotNull`, `@Size`, etc.) - - `jakarta.annotation:jakarta.annotation-api` - - These are marked as **provided** in the POM. Your host application must include them on the classpath. - -* **Included runtime dependency:** - - `org.apache.httpcomponents.client5:httpclient5` - Required if you use **Option B (HttpClient5 pooling)**. Already declared as a normal dependency in the POM. - -* **Generator & Toolchain:** - - Java 21 - - OpenAPI Generator 7.16.0 - - Generator options: `useJakartaEe=true`, `serializationLibrary=jackson`, `dateLibrary=java8`, - `useBeanValidation=true` - - Note: `useBeanValidation=true` → generated code includes **Jakarta Validation** annotations (requires - `jakarta.validation-api`). - -* **Frameworks (host app / examples):** - - Spring Boot 3.4.10 - - Jakarta Validation API 3.1.1 - - Apache HttpClient 5.5 (only needed if using Option B) - -* **OpenAPI spec location:** - `src/main/resources/customer-api-docs.yaml` - - To refresh the spec after restarting the service: - ```bash - curl -s http://localhost:8084/customer-service/v3/api-docs.yaml \ - -o src/main/resources/customer-api-docs.yaml - mvn -q clean install - ``` +* **Toolchain:** Java 21, OpenAPI Generator 7.16.0 +* **Generator options:** `useSpringBoot3=true`, `useJakartaEe=true`, `serializationLibrary=jackson`, `dateLibrary=java8` +* **OpenAPI spec:** `src/main/resources/customer-api-docs.yaml` +* Optional `x-class-extra-annotation` adds annotations on generated wrappers. --- -### Optional: Extra Class Annotations - -*(⚙️ advanced feature — use only if needed)* - -The generator also supports an **optional vendor extension** to attach annotations directly on top of the generated -wrapper classes. - -For example, if the OpenAPI schema contains: - -```yaml -components: - schemas: - ServiceResponseCustomerDeleteResponse: - type: object - x-api-wrapper: true - x-api-wrapper-datatype: CustomerDeleteResponse - x-class-extra-annotation: "@com.fasterxml.jackson.annotation.JsonIgnoreProperties(ignoreUnknown = true)" -``` - -The generated wrapper becomes: - -```java - -@com.fasterxml.jackson.annotation.JsonIgnoreProperties(ignoreUnknown = true) -public class ServiceResponseCustomerDeleteResponse - extends io.github.bsayli.openapi.client.common.ServiceClientResponse { -} -``` +## 📦 Related Module -By default this feature is **not required** and we recommend using the plain `ServiceClientResponse` wrappers -as-is. However, the hook is available if your project needs to enforce additional annotations (e.g., Jackson, Jakarta -Validation) -on top of generated wrapper classes. +Generated from the OpenAPI spec exposed by: +* [customer-service](../customer-service/README.md) — sample Spring Boot microservice (API producer). --- -## 📦 Related Module +## 💬 Feedback -This client is generated from the OpenAPI spec exposed by: - -* [customer-service](../customer-service/README.md) — Sample Spring Boot microservice (API producer). +If you spot an error or have suggestions, open an issue or join the discussion — contributions are welcome. +💭 [Start a discussion →](https://github.com/bsayli/spring-boot-openapi-generics-clients/discussions) --- ## 🤝 Contributing -Contributions, issues, and feature requests are welcome! -Feel free to [open an issue](../../issues) or submit a PR. +Contributions, issues, and feature requests are welcome! +Feel free to [open an issue](https://github.com/bsayli/spring-boot-openapi-generics-clients/issues) or submit a PR. --- ## 🛡 License This repository is licensed under **MIT** (root `LICENSE`). Submodules inherit the license. - diff --git a/customer-service-client/pom.xml b/customer-service-client/pom.xml index 12e36be..bffe0a6 100644 --- a/customer-service-client/pom.xml +++ b/customer-service-client/pom.xml @@ -6,7 +6,7 @@ io.github.bsayli customer-service-client - 0.6.8 + 0.7.0 customer-service-client Generated client (RestClient) using generics-aware OpenAPI templates jar diff --git a/customer-service-client/src/main/java/io/github/bsayli/openapi/client/adapter/CustomerClientAdapter.java b/customer-service-client/src/main/java/io/github/bsayli/openapi/client/adapter/CustomerClientAdapter.java index 1ee320f..431136a 100644 --- a/customer-service-client/src/main/java/io/github/bsayli/openapi/client/adapter/CustomerClientAdapter.java +++ b/customer-service-client/src/main/java/io/github/bsayli/openapi/client/adapter/CustomerClientAdapter.java @@ -1,16 +1,31 @@ package io.github.bsayli.openapi.client.adapter; +import io.github.bsayli.openapi.client.common.Page; import io.github.bsayli.openapi.client.common.ServiceClientResponse; -import io.github.bsayli.openapi.client.generated.dto.*; +import io.github.bsayli.openapi.client.common.sort.SortDirection; +import io.github.bsayli.openapi.client.common.sort.SortField; +import io.github.bsayli.openapi.client.generated.dto.CustomerCreateRequest; +import io.github.bsayli.openapi.client.generated.dto.CustomerDeleteResponse; +import io.github.bsayli.openapi.client.generated.dto.CustomerDto; +import io.github.bsayli.openapi.client.generated.dto.CustomerUpdateRequest; public interface CustomerClientAdapter { - ServiceClientResponse createCustomer(CustomerCreateRequest request); + + ServiceClientResponse createCustomer(CustomerCreateRequest request); ServiceClientResponse getCustomer(Integer customerId); - ServiceClientResponse getCustomers(); + ServiceClientResponse> getCustomers(); + + ServiceClientResponse> getCustomers( + String name, + String email, + Integer page, + Integer size, + SortField sortBy, + SortDirection direction); - ServiceClientResponse updateCustomer( + ServiceClientResponse updateCustomer( Integer customerId, CustomerUpdateRequest request); ServiceClientResponse deleteCustomer(Integer customerId); diff --git a/customer-service-client/src/main/java/io/github/bsayli/openapi/client/adapter/config/CustomerApiClientConfig.java b/customer-service-client/src/main/java/io/github/bsayli/openapi/client/adapter/config/CustomerApiClientConfig.java index d25c1c6..3a83ac6 100644 --- a/customer-service-client/src/main/java/io/github/bsayli/openapi/client/adapter/config/CustomerApiClientConfig.java +++ b/customer-service-client/src/main/java/io/github/bsayli/openapi/client/adapter/config/CustomerApiClientConfig.java @@ -1,20 +1,41 @@ package io.github.bsayli.openapi.client.adapter.config; +import com.fasterxml.jackson.databind.ObjectMapper; +import io.github.bsayli.openapi.client.common.error.ClientProblemException; import io.github.bsayli.openapi.client.generated.api.CustomerControllerApi; +import io.github.bsayli.openapi.client.generated.dto.ProblemDetail; import io.github.bsayli.openapi.client.generated.invoker.ApiClient; import java.time.Duration; +import java.util.List; 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.springframework.beans.factory.annotation.Value; +import org.springframework.boot.web.client.RestClientCustomizer; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; +import org.springframework.http.HttpStatusCode; import org.springframework.http.client.HttpComponentsClientHttpRequestFactory; import org.springframework.web.client.RestClient; @Configuration public class CustomerApiClientConfig { + @Bean + RestClientCustomizer problemDetailStatusHandler(ObjectMapper om) { + return builder -> + builder.defaultStatusHandler( + HttpStatusCode::isError, + (request, response) -> { + ProblemDetail pd = null; + try (var is = response.getBody()) { + pd = om.readValue(is, ProblemDetail.class); + } catch (Exception ignore) { + } + throw new ClientProblemException(pd, response.getStatusCode().value()); + }); + } + @Bean(destroyMethod = "close") CloseableHttpClient customerHttpClient( @Value("${customer.api.max-connections-total:64}") int maxTotal, @@ -51,8 +72,14 @@ HttpComponentsClientHttpRequestFactory customerRequestFactory( @Bean RestClient customerRestClient( - RestClient.Builder builder, HttpComponentsClientHttpRequestFactory customerRequestFactory) { - return builder.requestFactory(customerRequestFactory).build(); + RestClient.Builder builder, + HttpComponentsClientHttpRequestFactory customerRequestFactory, + List customizers) { + builder.requestFactory(customerRequestFactory); + if (customizers != null) { + customizers.forEach(c -> c.customize(builder)); + } + return builder.build(); } @Bean diff --git a/customer-service-client/src/main/java/io/github/bsayli/openapi/client/adapter/impl/CustomerClientAdapterImpl.java b/customer-service-client/src/main/java/io/github/bsayli/openapi/client/adapter/impl/CustomerClientAdapterImpl.java index f7e55db..ecb319a 100644 --- a/customer-service-client/src/main/java/io/github/bsayli/openapi/client/adapter/impl/CustomerClientAdapterImpl.java +++ b/customer-service-client/src/main/java/io/github/bsayli/openapi/client/adapter/impl/CustomerClientAdapterImpl.java @@ -1,50 +1,64 @@ package io.github.bsayli.openapi.client.adapter.impl; import io.github.bsayli.openapi.client.adapter.CustomerClientAdapter; +import io.github.bsayli.openapi.client.common.Page; import io.github.bsayli.openapi.client.common.ServiceClientResponse; +import io.github.bsayli.openapi.client.common.sort.SortDirection; +import io.github.bsayli.openapi.client.common.sort.SortField; import io.github.bsayli.openapi.client.generated.api.CustomerControllerApi; -import io.github.bsayli.openapi.client.generated.dto.CustomerCreateRequest; -import io.github.bsayli.openapi.client.generated.dto.CustomerCreateResponse; -import io.github.bsayli.openapi.client.generated.dto.CustomerDeleteResponse; -import io.github.bsayli.openapi.client.generated.dto.CustomerDto; -import io.github.bsayli.openapi.client.generated.dto.CustomerListResponse; -import io.github.bsayli.openapi.client.generated.dto.CustomerUpdateRequest; -import io.github.bsayli.openapi.client.generated.dto.CustomerUpdateResponse; +import io.github.bsayli.openapi.client.generated.dto.*; import org.springframework.stereotype.Service; @Service public class CustomerClientAdapterImpl implements CustomerClientAdapter { - private final CustomerControllerApi customerControllerApi; + private final CustomerControllerApi api; public CustomerClientAdapterImpl(CustomerControllerApi customerControllerApi) { - this.customerControllerApi = customerControllerApi; + this.api = customerControllerApi; } @Override - public ServiceClientResponse createCustomer( - CustomerCreateRequest request) { - return customerControllerApi.createCustomer(request); + public ServiceClientResponse createCustomer(CustomerCreateRequest request) { + return api.createCustomer(request); } @Override public ServiceClientResponse getCustomer(Integer customerId) { - return customerControllerApi.getCustomer(customerId); + return api.getCustomer(customerId); } @Override - public ServiceClientResponse getCustomers() { - return customerControllerApi.getCustomers(); + public ServiceClientResponse> getCustomers() { + return getCustomers(null, null, 0, 5, SortField.CUSTOMER_ID, SortDirection.ASC); } @Override - public ServiceClientResponse updateCustomer( + public ServiceClientResponse> getCustomers( + String name, + String email, + Integer page, + Integer size, + SortField sortBy, + SortDirection direction) { + + return api.getCustomers( + name, + email, + page, + size, + sortBy != null ? sortBy.value() : SortField.CUSTOMER_ID.value(), + direction != null ? direction.value() : SortDirection.ASC.value()); + } + + @Override + public ServiceClientResponse updateCustomer( Integer customerId, CustomerUpdateRequest request) { - return customerControllerApi.updateCustomer(customerId, request); + return api.updateCustomer(customerId, request); } @Override public ServiceClientResponse deleteCustomer(Integer customerId) { - return customerControllerApi.deleteCustomer(customerId); + return api.deleteCustomer(customerId); } } diff --git a/customer-service-client/src/main/java/io/github/bsayli/openapi/client/common/ClientErrorDetail.java b/customer-service-client/src/main/java/io/github/bsayli/openapi/client/common/ClientErrorDetail.java deleted file mode 100644 index 27217b2..0000000 --- a/customer-service-client/src/main/java/io/github/bsayli/openapi/client/common/ClientErrorDetail.java +++ /dev/null @@ -1,3 +0,0 @@ -package io.github.bsayli.openapi.client.common; - -public record ClientErrorDetail(String errorCode, String message) {} diff --git a/customer-service-client/src/main/java/io/github/bsayli/openapi/client/common/ClientMeta.java b/customer-service-client/src/main/java/io/github/bsayli/openapi/client/common/ClientMeta.java new file mode 100644 index 0000000..2bdfd5c --- /dev/null +++ b/customer-service-client/src/main/java/io/github/bsayli/openapi/client/common/ClientMeta.java @@ -0,0 +1,11 @@ +package io.github.bsayli.openapi.client.common; + +import io.github.bsayli.openapi.client.common.sort.ClientSort; +import java.time.Instant; +import java.util.List; + +public record ClientMeta(Instant serverTime, List sort) { + public ClientMeta { + sort = (sort == null) ? List.of() : List.copyOf(sort); + } +} diff --git a/customer-service-client/src/main/java/io/github/bsayli/openapi/client/common/Page.java b/customer-service-client/src/main/java/io/github/bsayli/openapi/client/common/Page.java new file mode 100644 index 0000000..a0fa75b --- /dev/null +++ b/customer-service-client/src/main/java/io/github/bsayli/openapi/client/common/Page.java @@ -0,0 +1,17 @@ +package io.github.bsayli.openapi.client.common; + +import java.util.List; + +public record Page( + List content, + int page, + int size, + long totalElements, + int totalPages, + boolean hasNext, + boolean hasPrev) { + + public Page { + content = (content == null) ? List.of() : List.copyOf(content); + } +} diff --git a/customer-service-client/src/main/java/io/github/bsayli/openapi/client/common/ServiceClientResponse.java b/customer-service-client/src/main/java/io/github/bsayli/openapi/client/common/ServiceClientResponse.java index ba0cc3e..1d72763 100644 --- a/customer-service-client/src/main/java/io/github/bsayli/openapi/client/common/ServiceClientResponse.java +++ b/customer-service-client/src/main/java/io/github/bsayli/openapi/client/common/ServiceClientResponse.java @@ -1,52 +1,21 @@ package io.github.bsayli.openapi.client.common; -import java.util.List; import java.util.Objects; public class ServiceClientResponse { - private Integer status; - private String message; - private List errors; private T data; + private ClientMeta meta; public ServiceClientResponse() {} - public ServiceClientResponse( - Integer status, String message, List errors, T data) { - this.status = status; - this.message = message; - this.errors = errors; + public ServiceClientResponse(T data, ClientMeta meta) { this.data = data; + this.meta = meta; } - public static ServiceClientResponse from( - Integer status, String message, List errors, T data) { - return new ServiceClientResponse<>(status, message, errors, data); - } - - public Integer getStatus() { - return status; - } - - public void setStatus(Integer status) { - this.status = status; - } - - public String getMessage() { - return message; - } - - public void setMessage(String message) { - this.message = message; - } - - public List getErrors() { - return errors; - } - - public void setErrors(List errors) { - this.errors = errors; + public static ServiceClientResponse of(T data, ClientMeta meta) { + return new ServiceClientResponse<>(data, meta); } public T getData() { @@ -57,33 +26,28 @@ public void setData(T data) { this.data = data; } + public ClientMeta getMeta() { + return meta; + } + + public void setMeta(ClientMeta meta) { + this.meta = meta; + } + @Override public boolean equals(Object o) { if (this == o) return true; if (!(o instanceof ServiceClientResponse that)) return false; - return Objects.equals(status, that.status) - && Objects.equals(message, that.message) - && Objects.equals(errors, that.errors) - && Objects.equals(data, that.data); + return Objects.equals(data, that.data) && Objects.equals(meta, that.meta); } @Override public int hashCode() { - return Objects.hash(status, message, errors, data); + return Objects.hash(data, meta); } @Override public String toString() { - return "ServiceClientResponse{" - + "status=" - + status - + ", message='" - + message - + '\'' - + ", errors=" - + errors - + ", data=" - + data - + '}'; + return "ServiceClientResponse{data=" + data + ", meta=" + meta + '}'; } } diff --git a/customer-service-client/src/main/java/io/github/bsayli/openapi/client/common/error/ClientProblemException.java b/customer-service-client/src/main/java/io/github/bsayli/openapi/client/common/error/ClientProblemException.java new file mode 100644 index 0000000..04f2671 --- /dev/null +++ b/customer-service-client/src/main/java/io/github/bsayli/openapi/client/common/error/ClientProblemException.java @@ -0,0 +1,54 @@ +package io.github.bsayli.openapi.client.common.error; + +import io.github.bsayli.openapi.client.generated.dto.ProblemDetail; +import java.io.Serial; +import java.io.Serializable; + +/** + * Wraps non-2xx HTTP responses decoded into RFC7807-style {@link ProblemDetail}. Thrown by + * RestClient defaultStatusHandler in client config. + */ +public class ClientProblemException extends RuntimeException implements Serializable { + + @Serial private static final long serialVersionUID = 1L; + + private final transient ProblemDetail problem; + private final int status; + + public ClientProblemException(ProblemDetail problem, int status) { + super(buildMessage(problem, status)); + this.problem = problem; + this.status = status; + } + + public ClientProblemException(ProblemDetail problem, int status, Throwable cause) { + super(buildMessage(problem, status), cause); + this.problem = problem; + this.status = status; + } + + private static String buildMessage(ProblemDetail pd, int status) { + if (pd == null) { + return "HTTP " + status + " (no problem body)"; + } + StringBuilder sb = new StringBuilder("HTTP ").append(status); + if (pd.getTitle() != null && !pd.getTitle().isBlank()) { + sb.append(" - ").append(pd.getTitle()); + } + if (pd.getDetail() != null && !pd.getDetail().isBlank()) { + sb.append(" | ").append(pd.getDetail()); + } + if (pd.getErrorCode() != null && !pd.getErrorCode().isBlank()) { + sb.append(" [code=").append(pd.getErrorCode()).append(']'); + } + return sb.toString(); + } + + public ProblemDetail getProblem() { + return problem; + } + + public int getStatus() { + return status; + } +} diff --git a/customer-service-client/src/main/java/io/github/bsayli/openapi/client/common/sort/ClientSort.java b/customer-service-client/src/main/java/io/github/bsayli/openapi/client/common/sort/ClientSort.java new file mode 100644 index 0000000..cf0cccb --- /dev/null +++ b/customer-service-client/src/main/java/io/github/bsayli/openapi/client/common/sort/ClientSort.java @@ -0,0 +1,13 @@ +package io.github.bsayli.openapi.client.common.sort; + +public record ClientSort(SortField field, SortDirection direction) { + + public ClientSort { + if (field == null) { + field = SortField.CUSTOMER_ID; + } + if (direction == null) { + direction = SortDirection.ASC; + } + } +} diff --git a/customer-service-client/src/main/java/io/github/bsayli/openapi/client/common/sort/SortDirection.java b/customer-service-client/src/main/java/io/github/bsayli/openapi/client/common/sort/SortDirection.java new file mode 100644 index 0000000..be17ad4 --- /dev/null +++ b/customer-service-client/src/main/java/io/github/bsayli/openapi/client/common/sort/SortDirection.java @@ -0,0 +1,21 @@ +package io.github.bsayli.openapi.client.common.sort; + +public enum SortDirection { + ASC("asc"), + DESC("desc"); + + private final String value; + + SortDirection(String value) { + this.value = value; + } + + public String value() { + return value; + } + + @Override + public String toString() { + return value; + } +} diff --git a/customer-service-client/src/main/java/io/github/bsayli/openapi/client/common/sort/SortField.java b/customer-service-client/src/main/java/io/github/bsayli/openapi/client/common/sort/SortField.java new file mode 100644 index 0000000..d21ff71 --- /dev/null +++ b/customer-service-client/src/main/java/io/github/bsayli/openapi/client/common/sort/SortField.java @@ -0,0 +1,22 @@ +package io.github.bsayli.openapi.client.common.sort; + +public enum SortField { + CUSTOMER_ID("customerId"), + NAME("name"), + EMAIL("email"); + + private final String value; + + SortField(String value) { + this.value = value; + } + + public String value() { + return value; + } + + @Override + public String toString() { + return value; + } +} diff --git a/customer-service-client/src/main/resources/customer-api-docs.yaml b/customer-service-client/src/main/resources/customer-api-docs.yaml index 40c3f91..8e5417d 100644 --- a/customer-service-client/src/main/resources/customer-api-docs.yaml +++ b/customer-service-client/src/main/resources/customer-api-docs.yaml @@ -2,7 +2,7 @@ openapi: 3.1.0 info: title: Customer Service API description: Customer Service API with type-safe generic responses using OpenAPI - version: 0.6.8 + version: 0.7.0 servers: - url: http://localhost:8084/customer-service description: Local service URL @@ -27,6 +27,30 @@ paths: application/json: schema: $ref: "#/components/schemas/ServiceResponseCustomerDto" + "400": + description: Bad Request + content: + application/problem+json: + schema: + $ref: "#/components/schemas/ProblemDetail" + "404": + description: Not Found + content: + application/problem+json: + schema: + $ref: "#/components/schemas/ProblemDetail" + "405": + description: Method Not Allowed + content: + application/problem+json: + schema: + $ref: "#/components/schemas/ProblemDetail" + "500": + description: Internal Server Error + content: + application/problem+json: + schema: + $ref: "#/components/schemas/ProblemDetail" put: tags: - customer-controller @@ -51,7 +75,31 @@ paths: content: application/json: schema: - $ref: "#/components/schemas/ServiceResponseCustomerUpdateResponse" + $ref: "#/components/schemas/ServiceResponseCustomerDto" + "400": + description: Bad Request + content: + application/problem+json: + schema: + $ref: "#/components/schemas/ProblemDetail" + "404": + description: Not Found + content: + application/problem+json: + schema: + $ref: "#/components/schemas/ProblemDetail" + "405": + description: Method Not Allowed + content: + application/problem+json: + schema: + $ref: "#/components/schemas/ProblemDetail" + "500": + description: Internal Server Error + content: + application/problem+json: + schema: + $ref: "#/components/schemas/ProblemDetail" delete: tags: - customer-controller @@ -71,18 +119,113 @@ paths: application/json: schema: $ref: "#/components/schemas/ServiceResponseCustomerDeleteResponse" + "400": + description: Bad Request + content: + application/problem+json: + schema: + $ref: "#/components/schemas/ProblemDetail" + "404": + description: Not Found + content: + application/problem+json: + schema: + $ref: "#/components/schemas/ProblemDetail" + "405": + description: Method Not Allowed + content: + application/problem+json: + schema: + $ref: "#/components/schemas/ProblemDetail" + "500": + description: Internal Server Error + content: + application/problem+json: + schema: + $ref: "#/components/schemas/ProblemDetail" /v1/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/ServiceResponseCustomerListResponse" + $ref: "#/components/schemas/ServiceResponsePageCustomerDto" + "400": + description: Bad Request + content: + application/problem+json: + schema: + $ref: "#/components/schemas/ProblemDetail" + "404": + description: Not Found + content: + application/problem+json: + schema: + $ref: "#/components/schemas/ProblemDetail" + "405": + description: Method Not Allowed + content: + application/problem+json: + schema: + $ref: "#/components/schemas/ProblemDetail" + "500": + description: Internal Server Error + content: + application/problem+json: + schema: + $ref: "#/components/schemas/ProblemDetail" post: tags: - customer-controller @@ -94,12 +237,36 @@ paths: $ref: "#/components/schemas/CustomerCreateRequest" required: true responses: - "201": - description: Created + "200": + description: OK content: application/json: schema: - $ref: "#/components/schemas/ServiceResponseCustomerCreateResponse" + $ref: "#/components/schemas/ServiceResponseCustomerDto" + "400": + description: Bad Request + content: + application/problem+json: + schema: + $ref: "#/components/schemas/ProblemDetail" + "404": + description: Not Found + content: + application/problem+json: + schema: + $ref: "#/components/schemas/ProblemDetail" + "405": + description: Method Not Allowed + content: + application/problem+json: + schema: + $ref: "#/components/schemas/ProblemDetail" + "500": + description: Internal Server Error + content: + application/problem+json: + schema: + $ref: "#/components/schemas/ProblemDetail" components: schemas: CustomerUpdateRequest: @@ -126,30 +293,39 @@ components: type: string email: type: string - CustomerUpdateResponse: + Meta: type: object properties: - customer: - $ref: "#/components/schemas/CustomerDto" - updatedAt: + serverTime: type: string format: date-time - ErrorDetail: - type: object - properties: - errorCode: - type: string - message: - type: string - ServiceResponseCustomerUpdateResponse: + sort: + type: array + items: + $ref: "#/components/schemas/Sort" + ServiceResponseCustomerDto: allOf: - $ref: "#/components/schemas/ServiceResponse" - type: object properties: data: - $ref: "#/components/schemas/CustomerUpdateResponse" + $ref: "#/components/schemas/CustomerDto" x-api-wrapper: true - x-api-wrapper-datatype: CustomerUpdateResponse + x-api-wrapper-datatype: CustomerDto + Sort: + type: object + properties: + field: + type: string + enum: + - customerId + - name + - email + direction: + type: string + enum: + - asc + - desc CustomerCreateRequest: type: object properties: @@ -164,57 +340,46 @@ components: required: - email - name - CustomerCreateResponse: + PageCustomerDto: type: object properties: - customer: - $ref: "#/components/schemas/CustomerDto" - createdAt: - type: string - format: date-time - ServiceResponseCustomerCreateResponse: - allOf: - - $ref: "#/components/schemas/ServiceResponse" - - type: object - properties: - data: - $ref: "#/components/schemas/CustomerCreateResponse" - x-api-wrapper: true - x-api-wrapper-datatype: CustomerCreateResponse - CustomerListResponse: - type: object - properties: - customers: + content: type: array items: $ref: "#/components/schemas/CustomerDto" - ServiceResponseCustomerListResponse: - allOf: - - $ref: "#/components/schemas/ServiceResponse" - - type: object - properties: - data: - $ref: "#/components/schemas/CustomerListResponse" - x-api-wrapper: true - x-api-wrapper-datatype: CustomerListResponse - ServiceResponseCustomerDto: + 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 + ServiceResponsePageCustomerDto: allOf: - $ref: "#/components/schemas/ServiceResponse" - type: object properties: data: - $ref: "#/components/schemas/CustomerDto" + $ref: "#/components/schemas/PageCustomerDto" x-api-wrapper: true - x-api-wrapper-datatype: CustomerDto + x-api-wrapper-datatype: PageCustomerDto + x-data-container: Page + x-data-item: CustomerDto CustomerDeleteResponse: type: object properties: customerId: type: integer format: int32 - deletedAt: - type: string - format: date-time ServiceResponseCustomerDeleteResponse: allOf: - $ref: "#/components/schemas/ServiceResponse" @@ -224,39 +389,75 @@ components: $ref: "#/components/schemas/CustomerDeleteResponse" x-api-wrapper: true x-api-wrapper-datatype: CustomerDeleteResponse - ServiceResponse: + ErrorItem: type: object + additionalProperties: false + description: Standard error item structure. properties: - status: - type: integer - format: int32 + code: + type: string + description: Short application-specific error code. message: type: string - errors: - type: array - items: - type: object - properties: - errorCode: - type: string - message: - type: string - ServiceResponseVoid: + description: Human-readable error message. + field: + type: string + description: Field name when error is field-specific. + resource: + type: string + description: Domain resource name if applicable. + id: + type: string + description: Resource identifier if applicable. + required: + - code + - message + ProblemDetail: type: object + additionalProperties: true properties: + type: + type: string + format: uri + description: Problem type as a URI. + title: + type: string + description: "Short, human-readable summary of the problem type." status: type: integer format: int32 - message: + description: HTTP status code for this problem. + detail: + type: string + description: Human-readable explanation specific to this occurrence. + instance: + type: string + format: uri + description: URI that identifies this specific occurrence. + errorCode: type: string + description: Application-specific error code. + extensions: + type: object + additionalProperties: false + description: Additional problem metadata. + properties: + errors: + type: array + description: List of error items (field-level or domain-specific). + items: + $ref: "#/components/schemas/ErrorItem" + ServiceResponse: + type: object + properties: data: type: object - errors: - type: array - items: - type: object - properties: - errorCode: - type: string - message: - type: string + meta: + $ref: "#/components/schemas/Meta" + ServiceResponseVoid: + type: object + properties: + data: + type: object + meta: + $ref: "#/components/schemas/Meta" diff --git a/customer-service-client/src/main/resources/openapi-templates/api_wrapper.mustache b/customer-service-client/src/main/resources/openapi-templates/api_wrapper.mustache index 8edc6a0..21c813a 100644 --- a/customer-service-client/src/main/resources/openapi-templates/api_wrapper.mustache +++ b/customer-service-client/src/main/resources/openapi-templates/api_wrapper.mustache @@ -1,8 +1,10 @@ import {{commonPackage}}.ServiceClientResponse; +{{#vendorExtensions.x-data-container}} +import {{commonPackage}}.{{vendorExtensions.x-data-container}}; +{{/vendorExtensions.x-data-container}} {{#vendorExtensions.x-class-extra-annotation}} {{{vendorExtensions.x-class-extra-annotation}}} {{/vendorExtensions.x-class-extra-annotation}} -public class {{classname}} - extends ServiceClientResponse<{{vendorExtensions.x-api-wrapper-datatype}}> { +public class {{classname}} extends ServiceClientResponse<{{#vendorExtensions.x-data-container}}{{vendorExtensions.x-data-container}}<{{vendorExtensions.x-data-item}}>{{/vendorExtensions.x-data-container}}{{^vendorExtensions.x-data-container}}{{vendorExtensions.x-api-wrapper-datatype}}{{/vendorExtensions.x-data-container}}> { } \ No newline at end of file diff --git a/customer-service-client/src/test/java/io/github/bsayli/openapi/client/adapter/CustomerClientErrorIT.java b/customer-service-client/src/test/java/io/github/bsayli/openapi/client/adapter/CustomerClientErrorIT.java new file mode 100644 index 0000000..a4d85f9 --- /dev/null +++ b/customer-service-client/src/test/java/io/github/bsayli/openapi/client/adapter/CustomerClientErrorIT.java @@ -0,0 +1,128 @@ +package io.github.bsayli.openapi.client.adapter; + +import static org.junit.jupiter.api.Assertions.*; + +import com.fasterxml.jackson.databind.ObjectMapper; +import io.github.bsayli.openapi.client.adapter.config.CustomerApiClientConfig; +import io.github.bsayli.openapi.client.common.error.ClientProblemException; +import io.github.bsayli.openapi.client.generated.api.CustomerControllerApi; +import io.github.bsayli.openapi.client.generated.dto.CustomerCreateRequest; +import okhttp3.mockwebserver.MockResponse; +import okhttp3.mockwebserver.MockWebServer; +import org.junit.jupiter.api.*; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.context.annotation.*; +import org.springframework.http.MediaType; +import org.springframework.test.context.junit.jupiter.SpringJUnitConfig; +import org.springframework.web.client.RestClient; + +@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 /v1/customers/{id} -> 404 Problem => throws ClientProblemException 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", + "errorCode":"CUS_404" + } + """; + + server.enqueue( + new MockResponse() + .setResponseCode(404) + .addHeader("Content-Type", "application/problem+json") + .setBody(problem)); + + var ex = assertThrows(ClientProblemException.class, () -> api.getCustomer(999)); + + assertEquals(404, ex.getStatus()); + assertNotNull(ex.getProblem()); + assertEquals("Not Found", ex.getProblem().getTitle()); + assertEquals("Customer 999 not found", ex.getProblem().getDetail()); + assertEquals("CUS_404", ex.getProblem().getErrorCode()); + } + + @Test + @DisplayName("POST /v1/customers -> 400 Problem (validation) => throws ClientProblemException") + 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(ClientProblemException.class, () -> api.createCustomer(req)); + + assertEquals(400, ex.getStatus()); + assertNotNull(ex.getProblem()); + assertEquals("Bad Request", ex.getProblem().getTitle()); + assertEquals("VAL_001", ex.getProblem().getErrorCode()); + } + + @Test + @DisplayName( + "DELETE /v1/customers/{id} -> 500 (no body) => throws ClientProblemException with null problem") + void deleteCustomer_500_no_body() { + server.enqueue( + new MockResponse() + .setResponseCode(500) + .addHeader("Content-Type", MediaType.APPLICATION_JSON_VALUE)); // empty body + + var ex = assertThrows(ClientProblemException.class, () -> api.deleteCustomer(1)); + + assertEquals(500, ex.getStatus()); + assertNull(ex.getProblem()); + } + + @Configuration + static class TestBeans { + @Bean + RestClient.Builder restClientBuilder() { + return RestClient.builder(); + } + + @Bean + ObjectMapper objectMapper() { + return new ObjectMapper(); + } + } +} diff --git a/customer-service-client/src/test/java/io/github/bsayli/openapi/client/adapter/CustomerClientIT.java b/customer-service-client/src/test/java/io/github/bsayli/openapi/client/adapter/CustomerClientIT.java index 47c4f60..8fa11e6 100644 --- a/customer-service-client/src/test/java/io/github/bsayli/openapi/client/adapter/CustomerClientIT.java +++ b/customer-service-client/src/test/java/io/github/bsayli/openapi/client/adapter/CustomerClientIT.java @@ -2,6 +2,7 @@ import static org.junit.jupiter.api.Assertions.*; +import com.fasterxml.jackson.databind.ObjectMapper; import io.github.bsayli.openapi.client.adapter.config.CustomerApiClientConfig; import io.github.bsayli.openapi.client.generated.api.CustomerControllerApi; import io.github.bsayli.openapi.client.generated.dto.CustomerCreateRequest; @@ -39,18 +40,13 @@ static void stopServer() throws Exception { } @Test - @DisplayName("POST /v1/customers -> 201 CREATED + CustomerCreateResponse") - void createCustomer_shouldReturn201_andMappedBody() { + @DisplayName("POST /v1/customers -> 201 Created + maps {data, meta}") + void createCustomer_shouldReturn201_andMapBody() { var body = """ { - "status": 201, - "message": "CREATED", - "data": { - "customer": { "customerId": 1, "name": "Jane Doe", "email": "jane@example.com" }, - "createdAt": "2025-01-01T12:34:56Z" - }, - "errors": [] + "data": { "customerId": 1, "name": "Jane Doe", "email": "jane@example.com" }, + "meta": { "serverTime": "2025-01-01T12:34:56Z", "sort": [] } } """; @@ -64,22 +60,23 @@ void createCustomer_shouldReturn201_andMappedBody() { var resp = api.createCustomer(req); assertNotNull(resp); - assertEquals(201, resp.getStatus()); - assertEquals("CREATED", resp.getMessage()); - assertNotNull(resp.getData().getCustomer()); - assertEquals("Jane Doe", resp.getData().getCustomer().getName()); + assertNotNull(resp.getData()); + assertEquals(1, resp.getData().getCustomerId()); + assertEquals("Jane Doe", resp.getData().getName()); + assertEquals("jane@example.com", resp.getData().getEmail()); + + assertNotNull(resp.getMeta()); + assertNotNull(resp.getMeta().serverTime()); } @Test - @DisplayName("GET /v1/customers/{id} -> 200 OK + CustomerDto") - void getCustomer_shouldReturn200_andMappedBody() { + @DisplayName("GET /v1/customers/{id} -> 200 OK + maps {data, meta}") + void getCustomer_shouldReturn200_andMapBody() { var body = """ { - "status": 200, - "message": "OK", "data": { "customerId": 1, "name": "Jane Doe", "email": "jane@example.com" }, - "errors": [] + "meta": { "requestId": "req-2", "serverTime": "2025-01-02T09:00:00Z", "sort": [] } } """; @@ -92,27 +89,33 @@ void getCustomer_shouldReturn200_andMappedBody() { var resp = api.getCustomer(1); assertNotNull(resp); - assertEquals(200, resp.getStatus()); - assertEquals("OK", resp.getMessage()); assertNotNull(resp.getData()); assertEquals(1, resp.getData().getCustomerId()); + assertEquals("Jane Doe", resp.getData().getName()); + + assertNotNull(resp.getMeta()); + assertNotNull(resp.getMeta().serverTime()); } @Test - @DisplayName("GET /v1/customers -> 200 OK + CustomerListResponse") - void getCustomers_shouldReturn200_andMappedBody() { + @DisplayName("GET /v1/customers -> 200 OK + maps Page in data and meta") + void getCustomers_shouldReturn200_andMapPage() { var body = """ { - "status": 200, - "message": "LISTED", "data": { - "customers": [ + "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 }, - "errors": [] + "meta": { "serverTime": "2025-01-03T10:00:00Z", "sort": [] } } """; @@ -122,29 +125,35 @@ void getCustomers_shouldReturn200_andMappedBody() { .addHeader("Content-Type", "application/json") .setBody(body)); - var resp = api.getCustomers(); + // generated signature accepts query params (sortBy/direction are strings at wire-level) + var resp = api.getCustomers(null, null, 0, 5, "customerId", "asc"); assertNotNull(resp); - assertEquals(200, resp.getStatus()); - assertEquals("LISTED", resp.getMessage()); assertNotNull(resp.getData()); - assertNotNull(resp.getData().getCustomers()); - assertEquals(2, resp.getData().getCustomers().size()); + + var page = resp.getData(); // this is io.github.bsayli.openapi.client.common.Page + 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().getCustomerId()); + + assertNotNull(resp.getMeta()); + assertNotNull(resp.getMeta().serverTime()); } @Test - @DisplayName("PUT /v1/customers/{id} -> 200 OK + CustomerUpdateResponse") - void updateCustomer_shouldReturn200_andMappedBody() { + @DisplayName("PUT /v1/customers/{id} -> 200 OK + maps {data, meta}") + void updateCustomer_shouldReturn200_andMapBody() { var body = """ { - "status": 200, - "message": "UPDATED", - "data": { - "customer": { "customerId": 1, "name": "Jane Updated", "email": "jane.updated@example.com" }, - "updatedAt": "2025-01-02T12:00:00Z" - }, - "errors": [] + "data": { "customerId": 1, "name": "Jane Updated", "email": "jane.updated@example.com" }, + "meta": { "serverTime": "2025-01-04T12:00:00Z", "sort": [] } } """; @@ -158,25 +167,23 @@ void updateCustomer_shouldReturn200_andMappedBody() { var resp = api.updateCustomer(1, req); assertNotNull(resp); - assertEquals(200, resp.getStatus()); - assertEquals("UPDATED", resp.getMessage()); - assertNotNull(resp.getData().getCustomer()); - assertEquals("Jane Updated", resp.getData().getCustomer().getName()); + assertNotNull(resp.getData()); + assertEquals(1, resp.getData().getCustomerId()); + assertEquals("Jane Updated", resp.getData().getName()); + assertEquals("jane.updated@example.com", resp.getData().getEmail()); + + assertNotNull(resp.getMeta()); + assertNotNull(resp.getMeta().serverTime()); } @Test - @DisplayName("DELETE /v1/customers/{id} -> 200 OK + CustomerDeleteResponse") - void deleteCustomer_shouldReturn200_andMappedBody() { + @DisplayName("DELETE /v1/customers/{id} -> 200 OK + maps {data, meta}") + void deleteCustomer_shouldReturn200_andMapBody() { var body = """ { - "status": 200, - "message": "DELETED", - "data": { - "customerId": 1, - "deletedAt": "2025-01-02T12:00:00Z" - }, - "errors": [] + "data": { "customerId": 1 }, + "meta": { "serverTime": "2025-01-05T08:00:00Z", "sort": [] } } """; @@ -189,17 +196,24 @@ void deleteCustomer_shouldReturn200_andMappedBody() { var resp = api.deleteCustomer(1); assertNotNull(resp); - assertEquals(200, resp.getStatus()); - assertEquals("DELETED", resp.getMessage()); assertNotNull(resp.getData()); assertEquals(1, resp.getData().getCustomerId()); + + assertNotNull(resp.getMeta()); + assertNotNull(resp.getMeta().serverTime()); } @Configuration static class TestBeans { + @Bean RestClient.Builder restClientBuilder() { return RestClient.builder(); } + + @Bean + ObjectMapper objectMapper() { + return new ObjectMapper(); + } } } diff --git a/customer-service-client/src/test/java/io/github/bsayli/openapi/client/adapter/config/CustomerApiClientConfigStatusHandlerTest.java b/customer-service-client/src/test/java/io/github/bsayli/openapi/client/adapter/config/CustomerApiClientConfigStatusHandlerTest.java new file mode 100644 index 0000000..94b0539 --- /dev/null +++ b/customer-service-client/src/test/java/io/github/bsayli/openapi/client/adapter/config/CustomerApiClientConfigStatusHandlerTest.java @@ -0,0 +1,104 @@ +package io.github.bsayli.openapi.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 com.fasterxml.jackson.databind.ObjectMapper; +import io.github.bsayli.openapi.client.common.error.ClientProblemException; +import io.github.bsayli.openapi.client.generated.dto.ProblemDetail; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.boot.web.client.RestClientCustomizer; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.test.web.client.MockRestServiceServer; +import org.springframework.web.client.RestClient; + +@DisplayName("Unit: CustomerApiClientConfig.problemDetailStatusHandler") +class CustomerApiClientConfigStatusHandlerTest { + + @Test + @DisplayName( + "400 with application/problem+json -> throws ClientProblemException with parsed ProblemDetail") + void handler_parses_problem_detail_on_4xx() { + // Arrange + var om = new ObjectMapper(); + RestClient.Builder builder = RestClient.builder().baseUrl("http://localhost"); + + // apply the status handler customizer + RestClientCustomizer customizer = new CustomerApiClientConfig().problemDetailStatusHandler(om); + customizer.customize(builder); + + // bind mock server to this builder + MockRestServiceServer server = MockRestServiceServer.bindTo(builder).build(); + + String body = + """ + { + "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" } ] } + } + """; + + server + .expect(once(), requestTo("http://localhost/err400")) + .andRespond( + withStatus(HttpStatus.BAD_REQUEST) + .contentType(MediaType.valueOf("application/problem+json")) + .body(body)); + + RestClient client = builder.build(); + + // Act + Assert + ClientProblemException ex = + assertThrows( + ClientProblemException.class, + () -> client.get().uri("/err400").retrieve().body(String.class)); + + assertEquals(400, ex.getStatus()); + ProblemDetail pd = ex.getProblem(); + assertNotNull(pd); + assertEquals("Bad Request", pd.getTitle()); + assertEquals("Validation failed", pd.getDetail()); + assertEquals("VAL_001", pd.getErrorCode()); + + server.verify(); + } + + @Test + @DisplayName("500 with empty body -> throws ClientProblemException with null ProblemDetail") + void handler_handles_empty_body_on_5xx() { + // Arrange + var om = new ObjectMapper(); + RestClient.Builder builder = RestClient.builder().baseUrl("http://localhost"); + + RestClientCustomizer customizer = new CustomerApiClientConfig().problemDetailStatusHandler(om); + customizer.customize(builder); + + MockRestServiceServer server = MockRestServiceServer.bindTo(builder).build(); + + server + .expect(once(), requestTo("http://localhost/err500")) + .andRespond(withStatus(HttpStatus.INTERNAL_SERVER_ERROR)); // no body, no content-type + + RestClient client = builder.build(); + + // Act + Assert + ClientProblemException ex = + assertThrows( + ClientProblemException.class, + () -> client.get().uri("/err500").retrieve().body(String.class)); + + assertEquals(500, ex.getStatus()); + assertNull(ex.getProblem()); // no problem body parsed + + server.verify(); + } +} diff --git a/customer-service-client/src/test/java/io/github/bsayli/openapi/client/adapter/impl/CustomerClientAdapterImplTest.java b/customer-service-client/src/test/java/io/github/bsayli/openapi/client/adapter/impl/CustomerClientAdapterImplTest.java index 48ca561..ee98830 100644 --- a/customer-service-client/src/test/java/io/github/bsayli/openapi/client/adapter/impl/CustomerClientAdapterImplTest.java +++ b/customer-service-client/src/test/java/io/github/bsayli/openapi/client/adapter/impl/CustomerClientAdapterImplTest.java @@ -5,20 +5,19 @@ import static org.mockito.Mockito.when; import io.github.bsayli.openapi.client.adapter.CustomerClientAdapter; +import io.github.bsayli.openapi.client.common.ClientMeta; +import io.github.bsayli.openapi.client.common.Page; import io.github.bsayli.openapi.client.common.ServiceClientResponse; +import io.github.bsayli.openapi.client.common.sort.SortDirection; +import io.github.bsayli.openapi.client.common.sort.SortField; import io.github.bsayli.openapi.client.generated.api.CustomerControllerApi; import io.github.bsayli.openapi.client.generated.dto.CustomerCreateRequest; -import io.github.bsayli.openapi.client.generated.dto.CustomerCreateResponse; import io.github.bsayli.openapi.client.generated.dto.CustomerDeleteResponse; import io.github.bsayli.openapi.client.generated.dto.CustomerDto; -import io.github.bsayli.openapi.client.generated.dto.CustomerListResponse; import io.github.bsayli.openapi.client.generated.dto.CustomerUpdateRequest; -import io.github.bsayli.openapi.client.generated.dto.CustomerUpdateResponse; -import io.github.bsayli.openapi.client.generated.dto.ServiceResponseCustomerCreateResponse; import io.github.bsayli.openapi.client.generated.dto.ServiceResponseCustomerDeleteResponse; import io.github.bsayli.openapi.client.generated.dto.ServiceResponseCustomerDto; -import io.github.bsayli.openapi.client.generated.dto.ServiceResponseCustomerListResponse; -import io.github.bsayli.openapi.client.generated.dto.ServiceResponseCustomerUpdateResponse; +import io.github.bsayli.openapi.client.generated.dto.ServiceResponsePageCustomerDto; import java.time.OffsetDateTime; import java.util.List; import org.junit.jupiter.api.DisplayName; @@ -31,7 +30,7 @@ @Tag("unit") @ExtendWith(MockitoExtension.class) -@DisplayName("Unit Test: CustomerClientAdapterImpl") +@DisplayName("Unit Test: CustomerClientAdapterImpl (data + meta mapping)") class CustomerClientAdapterImplTest { @Mock CustomerControllerApi api; @@ -39,144 +38,139 @@ class CustomerClientAdapterImplTest { @InjectMocks CustomerClientAdapterImpl adapter; @Test - @DisplayName("createCustomer -> delegates to API and returns 201 + payload passthrough") - void createCustomer_delegates_and_passthrough() { + @DisplayName( + "createCustomer -> delegates to API and returns ServiceClientResponse (including meta)") + 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 payload = - new CustomerCreateResponse() - .customer(dto) - .createdAt(OffsetDateTime.parse("2025-01-01T12:34:56Z")); + var serverOdt = OffsetDateTime.parse("2025-01-01T12:34:56Z"); + var meta = new ClientMeta(serverOdt.toInstant(), List.of()); - var wrapper = new ServiceResponseCustomerCreateResponse(); - wrapper.setStatus(201); - wrapper.setMessage("CREATED"); - wrapper.setErrors(List.of()); - wrapper.setData(payload); + var wrapper = new ServiceResponseCustomerDto(); + wrapper.setData(dto); + wrapper.setMeta(meta); when(api.createCustomer(any(CustomerCreateRequest.class))).thenReturn(wrapper); - ServiceClientResponse res = adapter.createCustomer(req); + ServiceClientResponse res = adapter.createCustomer(req); assertNotNull(res); - assertEquals(201, res.getStatus()); - assertEquals("CREATED", res.getMessage()); assertNotNull(res.getData()); - assertNotNull(res.getData().getCustomer()); - assertEquals(1, res.getData().getCustomer().getCustomerId()); - assertEquals(OffsetDateTime.parse("2025-01-01T12:34:56Z"), res.getData().getCreatedAt()); + assertEquals(1, res.getData().getCustomerId()); + assertEquals("Jane Doe", res.getData().getName()); + assertEquals("jane@example.com", res.getData().getEmail()); + + assertNotNull(res.getMeta()); + assertEquals(serverOdt.toInstant(), res.getMeta().serverTime()); } @Test - @DisplayName("getCustomer -> delegates to API and returns typed DTO") + @DisplayName("getCustomer -> returns a single CustomerDto (data + meta)") void getCustomer_delegates_and_returnsDto() { var dto = new CustomerDto().customerId(42).name("John Smith").email("john.smith@example.com"); + var serverOdt = OffsetDateTime.parse("2025-02-01T10:00:00Z"); var wrapper = new ServiceResponseCustomerDto(); - wrapper.setStatus(200); - wrapper.setMessage("OK"); - wrapper.setErrors(List.of()); wrapper.setData(dto); + wrapper.setMeta(new ClientMeta(serverOdt.toInstant(), List.of())); - when(api.getCustomer(42)).thenReturn(wrapper); + when(api.getCustomer(any())).thenReturn(wrapper); ServiceClientResponse res = adapter.getCustomer(42); assertNotNull(res); - assertEquals(200, res.getStatus()); - assertEquals("OK", res.getMessage()); assertNotNull(res.getData()); assertEquals(42, res.getData().getCustomerId()); assertEquals("John Smith", res.getData().getName()); + assertEquals("john.smith@example.com", res.getData().getEmail()); + + assertNotNull(res.getMeta()); + assertEquals(serverOdt.toInstant(), res.getMeta().serverTime()); } @Test - @DisplayName("getCustomers -> delegates to API and returns list") - void getCustomers_delegates_and_returnsList() { + @DisplayName("getCustomers -> returns Page (data + meta)") + 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 listPayload = new CustomerListResponse().customers(List.of(d1, d2)); - var wrapper = new ServiceResponseCustomerListResponse(); - wrapper.setStatus(200); - wrapper.setMessage("LISTED"); - wrapper.setErrors(List.of()); - wrapper.setData(listPayload); + 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 ClientMeta(serverOdt.toInstant(), List.of())); - when(api.getCustomers()).thenReturn(wrapper); + when(api.getCustomers(any(), any(), any(), any(), any(), any())).thenReturn(wrapper); - ServiceClientResponse res = adapter.getCustomers(); + ServiceClientResponse> res = + adapter.getCustomers(null, null, 0, 5, SortField.CUSTOMER_ID, SortDirection.ASC); assertNotNull(res); - assertEquals(200, res.getStatus()); - assertEquals("LISTED", res.getMessage()); assertNotNull(res.getData()); - assertNotNull(res.getData().getCustomers()); - assertEquals(2, res.getData().getCustomers().size()); - assertEquals(1, res.getData().getCustomers().getFirst().getCustomerId()); + 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()); + + assertNotNull(res.getMeta()); + assertEquals(serverOdt.toInstant(), res.getMeta().serverTime()); } @Test - @DisplayName("updateCustomer -> delegates to API and returns UPDATED payload") + @DisplayName("updateCustomer -> returns updated CustomerDto (data + meta)") 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 payload = - new CustomerUpdateResponse() - .customer(dto) - .updatedAt(OffsetDateTime.parse("2025-01-02T12:00:00Z")); - - var wrapper = new ServiceResponseCustomerUpdateResponse(); - wrapper.setStatus(200); - wrapper.setMessage("UPDATED"); - wrapper.setErrors(List.of()); - wrapper.setData(payload); + var serverOdt = OffsetDateTime.parse("2025-04-02T12:00:00Z"); + var wrapper = new ServiceResponseCustomerDto(); + wrapper.setData(dto); + wrapper.setMeta(new ClientMeta(serverOdt.toInstant(), List.of())); - when(api.updateCustomer(1, req)).thenReturn(wrapper); + when(api.updateCustomer(any(), any(CustomerUpdateRequest.class))).thenReturn(wrapper); - ServiceClientResponse res = adapter.updateCustomer(1, req); + ServiceClientResponse res = adapter.updateCustomer(1, req); assertNotNull(res); - assertEquals(200, res.getStatus()); - assertEquals("UPDATED", res.getMessage()); assertNotNull(res.getData()); - assertEquals("Jane Updated", res.getData().getCustomer().getName()); - assertEquals(OffsetDateTime.parse("2025-01-02T12:00:00Z"), res.getData().getUpdatedAt()); + assertEquals("Jane Updated", res.getData().getName()); + assertEquals("jane.updated@example.com", res.getData().getEmail()); + + assertNotNull(res.getMeta()); + assertEquals(serverOdt.toInstant(), res.getMeta().serverTime()); } @Test - @DisplayName("deleteCustomer -> delegates to API and returns DELETED payload") - void deleteCustomer_delegates_and_passthrough() { - var payload = - new CustomerDeleteResponse() - .customerId(7) - .deletedAt(OffsetDateTime.parse("2025-01-03T08:00:00Z")); + @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.setStatus(200); - wrapper.setMessage("DELETED"); - wrapper.setErrors(List.of()); wrapper.setData(payload); + wrapper.setMeta(new ClientMeta(serverOdt.toInstant(), List.of())); - when(api.deleteCustomer(7)).thenReturn(wrapper); + when(api.deleteCustomer(any())).thenReturn(wrapper); ServiceClientResponse res = adapter.deleteCustomer(7); assertNotNull(res); - assertEquals(200, res.getStatus()); - assertEquals("DELETED", res.getMessage()); assertNotNull(res.getData()); assertEquals(7, res.getData().getCustomerId()); - assertEquals(OffsetDateTime.parse("2025-01-03T08:00:00Z"), res.getData().getDeletedAt()); + + assertNotNull(res.getMeta()); + assertEquals(serverOdt.toInstant(), res.getMeta().serverTime()); } @Test - @DisplayName("Adapter is a CustomerClientAdapter and uses the generated API underneath") + @DisplayName("Adapter interface type check") void adapter_type_sanity() { CustomerClientAdapter asInterface = adapter; assertNotNull(asInterface); diff --git a/customer-service/README.md b/customer-service/README.md index af61433..5af7d22 100644 --- a/customer-service/README.md +++ b/customer-service/README.md @@ -9,42 +9,53 @@ ## 🎯 Purpose -`customer-service` provides a **minimal but complete backend** that exposes CRUD endpoints for customers. Its primary -role in this repository is: +`customer-service` provides a **minimal yet production-grade backend** exposing CRUD endpoints for customers. Its +primary role is to act as the **OpenAPI producer** that defines the canonical contract consumed by the generated client +module. -* To **serve as the API producer** that publishes an OpenAPI spec (`/v3/api-docs.yaml`). -* To **feed the `customer-service-client` module**, where the spec is consumed and turned into a type-safe client with - generics-aware wrappers. -* To demonstrate how **Swagger customizers** can teach OpenAPI about generic wrappers so that the generated client stays - clean and DRY. +**Key responsibilities:** -Think of this module as the **server-side anchor**: without it, the client module would have nothing to generate -against. +* Publishes OpenAPI spec (`/v3/api-docs.yaml`) enriched with **vendor extensions** for generics and nested wrappers. +* Feeds the [`customer-service-client`](../customer-service-client/README.md) module for type-safe, boilerplate-free + client generation. +* Demonstrates **automatic schema registration** and **generic wrapper introspection** via custom Springdoc extensions. + +This module serves as the **server-side anchor**: the reference point for generics-aware OpenAPI code generation. --- ## 📊 Architecture at a Glance ``` -[customer-service] ── publishes ──> /v3/api-docs.yaml (OpenAPI contract with x-api-wrapper extensions) +[customer-service] ── publishes ──> /v3/api-docs.yaml (OpenAPI 3.1 contract with x-api-wrapper & x-data-container) │ - └─ consumed by OpenAPI Generator (+ generics-aware templates) + └─ consumed by OpenAPI Generator (+ custom Mustache overlays) │ └─> [customer-service-client] (type-safe wrappers) │ - └─ used by consumer apps (your services) + └─ used by consumer microservices ``` ### Explanation -* **customer-service** exposes an **enhanced OpenAPI contract** at `/v3/api-docs.yaml` (and Swagger UI). - It auto-registers generic wrappers (`ServiceResponse`) using `OpenApiCustomizer` and `ResponseTypeIntrospector`, - enriching the spec with vendor extensions: - - `x-api-wrapper: true` - - `x-api-wrapper-datatype: ` +* **customer-service** auto-registers `ServiceResponse` and `ServiceResponse>` schemas through the + `AutoWrapperSchemaCustomizer` and `ResponseTypeIntrospector`. + +* The OpenAPI document is enriched with vendor extensions: + + * `x-api-wrapper: true` + * `x-api-wrapper-datatype: ` + * `x-data-container: ` (e.g., `Page`) + * `x-data-item: ` (e.g., `CustomerDto`) + +* These hints allow the OpenAPI Generator to produce nested generic clients such as: -* **customer-service-client** runs the OpenAPI Generator against this enhanced contract, applying generics-aware - Mustache templates to generate **thin wrapper classes** instead of duplicating full models. + ```java + class CustomerListResponse extends ServiceClientResponse> {} + ``` + +* **customer-service-client** uses custom templates to emit **thin wrappers** extending the base + `ServiceClientResponse` without repeating model definitions. --- @@ -52,14 +63,17 @@ against. * **Java 21** * **Spring Boot 3.4.10** - - spring-boot-starter-web - - spring-boot-starter-validation - - spring-boot-starter-test (test scope) + + * spring-boot-starter-web + * spring-boot-starter-validation + * spring-boot-starter-test (test scope) * **OpenAPI / Swagger** - - springdoc-openapi-starter-webmvc-ui (2.8.13) + + * springdoc-openapi-starter-webmvc-ui (2.8.13) * **Build & Tools** - - Maven 3.9+ - - JaCoCo, Surefire, Failsafe for test & coverage + + * Maven 3.9+ + * JaCoCo, Surefire, Failsafe for test & coverage --- @@ -81,78 +95,81 @@ curl -X POST "http://localhost:8084/customer-service/v1/customers" \ -d '{"name":"Jane Doe","email":"jane@example.com"}' ``` -**Expected response (wrapped in `ServiceResponse`):** +**Expected response (wrapped in `ServiceResponse`):** ```json { - "status": 201, - "message": "CREATED", "data": { - "customer": { - "customerId": 1, - "name": "Jane Doe", - "email": "jane@example.com" - }, - "createdAt": "2025-01-01T12:34:56Z" + "customerId": 1, + "name": "Jane Doe", + "email": "jane@example.com" }, - "errors": [] + "meta": { + "serverTime": "2025-01-01T12:34:56Z", + "sort": [] + } } ``` --- -## 📚 CRUD Endpoints +## 📙 CRUD Endpoints | Method | Path | Description | Returns | |--------|------------------------------|---------------------|--------------------------| -| POST | `/v1/customers` | Create new customer | `CustomerCreateResponse` | +| POST | `/v1/customers` | Create new customer | `CustomerDto` | | GET | `/v1/customers/{customerId}` | Get single customer | `CustomerDto` | -| GET | `/v1/customers` | List all customers | `CustomerListResponse` | -| PUT | `/v1/customers/{customerId}` | Update customer | `CustomerUpdateResponse` | +| GET | `/v1/customers` | List all customers | `Page` | +| PUT | `/v1/customers/{customerId}` | Update customer | `CustomerDto` | | DELETE | `/v1/customers/{customerId}` | Delete customer | `CustomerDeleteResponse` | -**Base URL Note:** All endpoints are prefixed with `/customer-service` as defined in `application.yml`. - -Example full URL for listing customers: - -``` -http://localhost:8084/customer-service/v1/customers -``` +**Base URL:** `/customer-service` (defined in `application.yml`) ### Example Response: Get Customer ```json { - "status": 200, - "message": "OK", "data": { "customerId": 1, "name": "Jane Doe", "email": "jane@example.com" }, - "errors": [] + "meta": { + "serverTime": "2025-01-01T12:34:56Z" + } } ``` -### Example Response: List Customers +### Example Response: List Customers (Page-aware) ```json { - "status": 200, - "message": "OK", - "data": [ - { - "customerId": 1, - "name": "Jane Doe", - "email": "jane@example.com" - }, - { - "customerId": 2, - "name": "John Smith", - "email": "john@example.com" - } - ], - "errors": [] + "data": { + "content": [ + { + "customerId": 1, + "name": "Jane Doe", + "email": "jane@example.com" + }, + { + "customerId": 2, + "name": "John Smith", + "email": "john@example.com" + } + ], + "page": 0, + "size": 5, + "totalElements": 2 + }, + "meta": { + "serverTime": "2025-01-01T12:35:00Z", + "sort": [ + { + "field": "customerId", + "direction": "asc" + } + ] + } } ``` @@ -164,33 +181,37 @@ http://localhost:8084/customer-service/v1/customers * OpenAPI JSON → `http://localhost:8084/customer-service/v3/api-docs` * OpenAPI YAML → `http://localhost:8084/customer-service/v3/api-docs.yaml` -➡️ The YAML/JSON spec above is the **contract** that the client module (`customer-service-client`) consumes when generating code. +🤙 The YAML/JSON spec above is the **canonical contract** consumed by `customer-service-client`. -➡️ For clarity, in this repository it is saved under the client module as: `src/main/resources/customer-api-docs.yaml` +🔙 In this repository, the spec is also stored under the client module: `src/main/resources/customer-api-docs.yaml` --- ### Example Wrapper Snippet The generated OpenAPI YAML (`/v3/api-docs.yaml`) includes wrapper schemas -with vendor extensions that mark generic response envelopes: +with vendor extensions marking nested generic response envelopes: ```yaml -ServiceResponseCustomerDto: +ServiceResponsePageCustomerDto: allOf: - $ref: "#/components/schemas/ServiceResponse" - type: object properties: data: - $ref: "#/components/schemas/CustomerDto" + $ref: "#/components/schemas/PageCustomerDto" x-api-wrapper: true - x-api-wrapper-datatype: CustomerDto + x-api-wrapper-datatype: PageCustomerDto + x-data-container: Page + x-data-item: CustomerDto ``` -➡️ These `x-api-wrapper` fields are added automatically by the -`OpenApiCustomizer` and `ResponseTypeIntrospector` so that the client -generator knows which classes should become **thin wrappers** extending the -generic base. +These fields are added automatically by the `AutoWrapperSchemaCustomizer` and `ResponseTypeIntrospector`, allowing the +client generator to produce nested generics like: + +```java +ServiceClientResponse> +``` --- @@ -206,15 +227,23 @@ curl -X GET "http://localhost:8084/customer-service/v1/customers/999" ```json { + "type": "https://example.com/problems/not-found", + "title": "Resource not found", "status": 404, - "message": "NOT_FOUND", - "data": { - "code": "NOT_FOUND", - "message": "Customer not found: 999", - "timestamp": "2025-01-01T12:45:00Z", - "violations": [] - }, - "errors": [] + "detail": "Requested resource was not found.", + "instance": "/customer-service/v1/customers/999", + "errorCode": "NOT_FOUND", + "extensions": { + "errors": [ + { + "code": "NOT_FOUND", + "message": "Customer not found: 999", + "field": null, + "resource": "Customer", + "id": null + } + ] + } } ``` @@ -250,7 +279,7 @@ docker compose down --- -## 🧪 Testing +## 🥺 Testing Run unit and integration tests: @@ -261,21 +290,15 @@ mvn test --- -## 📖 Notes +## 🖖 Notes -* Demonstrates **generic `ServiceResponse`** pattern. +* Demonstrates **generic `ServiceResponse`** and nested `ServiceResponse>` patterns. * Acts as the **API producer** for the generated client. -* Uses **Swagger customizers** to mark wrappers for OpenAPI. -* Auto-registers **wrapper schemas** in OpenAPI using `OpenApiCustomizer` and `ResponseTypeIntrospector` (adds - `x-api-wrapper` vendor extensions). -* OpenAPI spec (`/v3/api-docs.yaml`) is the input for client generation. -* Includes **exception handling via `CustomerControllerAdvice`**. -* Provides **unit tests** for both controller and service layers. -* Focused on clarity and minimal setup. -* Optional: You can attach extra annotations (e.g., Jackson) to generated wrapper classes by setting - `app.openapi.wrapper.class-extra-annotation` in `application.yml`. - See [customer-service-client README](../customer-service-client/README.md#optional-extra-class-annotations) for - details. +* Uses **Swagger customizers** (`AutoWrapperSchemaCustomizer`, `GlobalErrorResponsesCustomizer`). +* Auto-registers wrapper schemas and adds container/item hints via vendor extensions. +* Implements **RFC 7807-compliant ProblemDetail** responses. +* Provides **unit and integration tests** for controller and error handler layers. +* Supports **annotation injection** for generated wrappers via `app.openapi.wrapper.class-extra-annotation`. --- @@ -284,14 +307,21 @@ mvn test This service is the API producer for the generated client: * [customer-service-client](../customer-service-client/README.md) — Java client generated from this service's OpenAPI - spec. + spec, supporting nested generic wrappers and problem decoding. + +--- + +## 💬 Feedback + +If you spot an error or have suggestions, open an issue or join the discussion — contributions are welcome. +💭 [Start a discussion →](https://github.com/bsayli/spring-boot-openapi-generics-clients/discussions) --- ## 🤝 Contributing -Contributions, issues, and feature requests are welcome! -Feel free to [open an issue](../../issues) or submit a PR. +Contributions, issues, and feature requests are welcome! +Feel free to [open an issue](https://github.com/bsayli/spring-boot-openapi-generics-clients/issues) or submit a PR. --- diff --git a/customer-service/pom.xml b/customer-service/pom.xml index e1f936f..0a23d11 100644 --- a/customer-service/pom.xml +++ b/customer-service/pom.xml @@ -13,7 +13,7 @@ io.github.bsayli customer-service - 0.6.8 + 0.7.0 customer-service Spring Boot 3.4 + Springdoc (OpenAPI) for generics-aware client generation diff --git a/customer-service/src/main/java/io/github/bsayli/customerservice/api/controller/CustomerController.java b/customer-service/src/main/java/io/github/bsayli/customerservice/api/controller/CustomerController.java index d12be4e..143d430 100644 --- a/customer-service/src/main/java/io/github/bsayli/customerservice/api/controller/CustomerController.java +++ b/customer-service/src/main/java/io/github/bsayli/customerservice/api/controller/CustomerController.java @@ -1,19 +1,18 @@ package io.github.bsayli.customerservice.api.controller; -import static io.github.bsayli.customerservice.common.api.ApiConstants.Response.CREATED; -import static io.github.bsayli.customerservice.common.api.ApiConstants.Response.DELETED; -import static io.github.bsayli.customerservice.common.api.ApiConstants.Response.LISTED; -import static io.github.bsayli.customerservice.common.api.ApiConstants.Response.UPDATED; - import io.github.bsayli.customerservice.api.dto.*; +import io.github.bsayli.customerservice.common.api.response.Meta; +import io.github.bsayli.customerservice.common.api.response.Page; import io.github.bsayli.customerservice.common.api.response.ServiceResponse; +import io.github.bsayli.customerservice.common.api.sort.Sort; +import io.github.bsayli.customerservice.common.api.sort.SortDirection; +import io.github.bsayli.customerservice.common.api.sort.SortField; import io.github.bsayli.customerservice.service.CustomerService; import jakarta.validation.Valid; +import jakarta.validation.constraints.Max; import jakarta.validation.constraints.Min; import java.net.URI; -import java.time.Instant; import java.util.List; -import org.springframework.http.HttpStatus; import org.springframework.http.MediaType; import org.springframework.http.ResponseEntity; import org.springframework.validation.annotation.Validated; @@ -32,12 +31,10 @@ public CustomerController(CustomerService customerService) { } @PostMapping(consumes = MediaType.APPLICATION_JSON_VALUE) - @ResponseStatus(HttpStatus.CREATED) - public ResponseEntity> createCustomer( + public ResponseEntity> createCustomer( @Valid @RequestBody CustomerCreateRequest request) { CustomerDto created = customerService.createCustomer(request); - CustomerCreateResponse body = new CustomerCreateResponse(created, Instant.now()); URI location = ServletUriComponentsBuilder.fromCurrentRequest() @@ -45,8 +42,7 @@ public ResponseEntity> createCustomer( .buildAndExpand(created.customerId()) .toUri(); - return ResponseEntity.created(location) - .body(ServiceResponse.of(HttpStatus.CREATED, CREATED, body)); + return ResponseEntity.created(location).body(ServiceResponse.ok(created)); } @GetMapping("/{customerId}") @@ -57,19 +53,23 @@ public ResponseEntity> getCustomer( } @GetMapping - public ResponseEntity> getCustomers() { - List all = customerService.getCustomers(); - CustomerListResponse body = new CustomerListResponse(all); - return ResponseEntity.ok(ServiceResponse.of(HttpStatus.OK, LISTED, body)); + 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(List.of(new Sort(sortBy, direction))); + return ResponseEntity.ok(ServiceResponse.ok(paged, meta)); } @PutMapping(path = "/{customerId}", consumes = MediaType.APPLICATION_JSON_VALUE) - public ResponseEntity> updateCustomer( + public ResponseEntity> updateCustomer( @PathVariable @Min(1) Integer customerId, @Valid @RequestBody CustomerUpdateRequest request) { CustomerDto updated = customerService.updateCustomer(customerId, request); - CustomerUpdateResponse body = new CustomerUpdateResponse(updated, Instant.now()); - return ResponseEntity.ok(ServiceResponse.of(HttpStatus.OK, UPDATED, body)); + return ResponseEntity.ok(ServiceResponse.ok(updated)); } @DeleteMapping("/{customerId}") @@ -77,7 +77,7 @@ public ResponseEntity> deleteCustomer( @PathVariable @Min(1) Integer customerId) { customerService.deleteCustomer(customerId); - CustomerDeleteResponse body = new CustomerDeleteResponse(customerId, Instant.now()); - return ResponseEntity.ok(ServiceResponse.of(HttpStatus.OK, DELETED, body)); + var body = new CustomerDeleteResponse(customerId); + return ResponseEntity.ok(ServiceResponse.ok(body)); } } diff --git a/customer-service/src/main/java/io/github/bsayli/customerservice/api/dto/CustomerCreateResponse.java b/customer-service/src/main/java/io/github/bsayli/customerservice/api/dto/CustomerCreateResponse.java deleted file mode 100644 index a8b46e7..0000000 --- a/customer-service/src/main/java/io/github/bsayli/customerservice/api/dto/CustomerCreateResponse.java +++ /dev/null @@ -1,5 +0,0 @@ -package io.github.bsayli.customerservice.api.dto; - -import java.time.Instant; - -public record CustomerCreateResponse(CustomerDto customer, Instant createdAt) {} diff --git a/customer-service/src/main/java/io/github/bsayli/customerservice/api/dto/CustomerDeleteResponse.java b/customer-service/src/main/java/io/github/bsayli/customerservice/api/dto/CustomerDeleteResponse.java index 4a4bc36..7d3619f 100644 --- a/customer-service/src/main/java/io/github/bsayli/customerservice/api/dto/CustomerDeleteResponse.java +++ b/customer-service/src/main/java/io/github/bsayli/customerservice/api/dto/CustomerDeleteResponse.java @@ -1,5 +1,3 @@ package io.github.bsayli.customerservice.api.dto; -import java.time.Instant; - -public record CustomerDeleteResponse(Integer customerId, Instant deletedAt) {} +public record CustomerDeleteResponse(Integer customerId) {} diff --git a/customer-service/src/main/java/io/github/bsayli/customerservice/api/dto/CustomerSearchCriteria.java b/customer-service/src/main/java/io/github/bsayli/customerservice/api/dto/CustomerSearchCriteria.java new file mode 100644 index 0000000..0b6556c --- /dev/null +++ b/customer-service/src/main/java/io/github/bsayli/customerservice/api/dto/CustomerSearchCriteria.java @@ -0,0 +1,6 @@ +package io.github.bsayli.customerservice.api.dto; + +import org.springdoc.core.annotations.ParameterObject; + +@ParameterObject +public record CustomerSearchCriteria(String name, String email) {} diff --git a/customer-service/src/main/java/io/github/bsayli/customerservice/api/dto/CustomerUpdateResponse.java b/customer-service/src/main/java/io/github/bsayli/customerservice/api/dto/CustomerUpdateResponse.java deleted file mode 100644 index 6e75f16..0000000 --- a/customer-service/src/main/java/io/github/bsayli/customerservice/api/dto/CustomerUpdateResponse.java +++ /dev/null @@ -1,5 +0,0 @@ -package io.github.bsayli.customerservice.api.dto; - -import java.time.Instant; - -public record CustomerUpdateResponse(CustomerDto customer, Instant updatedAt) {} diff --git a/customer-service/src/main/java/io/github/bsayli/customerservice/api/error/ApplicationExceptionHandler.java b/customer-service/src/main/java/io/github/bsayli/customerservice/api/error/ApplicationExceptionHandler.java new file mode 100644 index 0000000..4caef45 --- /dev/null +++ b/customer-service/src/main/java/io/github/bsayli/customerservice/api/error/ApplicationExceptionHandler.java @@ -0,0 +1,48 @@ +package io.github.bsayli.customerservice.api.error; + +import static io.github.bsayli.customerservice.api.error.ProblemSupport.*; +import static io.github.bsayli.customerservice.common.api.ApiConstants.ErrorCode.*; + +import jakarta.servlet.http.HttpServletRequest; +import java.util.List; +import java.util.NoSuchElementException; +import java.util.Optional; +import org.slf4j.*; +import org.springframework.core.annotation.Order; +import org.springframework.http.*; +import org.springframework.web.bind.annotation.*; + +@RestControllerAdvice(basePackages = "io.github.bsayli.customerservice.api.controller") +@Order(4) +public class ApplicationExceptionHandler { + + private static final Logger log = LoggerFactory.getLogger(ApplicationExceptionHandler.class); + + @ExceptionHandler(NoSuchElementException.class) + public ProblemDetail handleNotFound(NoSuchElementException ex, HttpServletRequest req) { + ProblemDetail pd = + baseProblem( + type(TYPE_NOT_FOUND), HttpStatus.NOT_FOUND, TITLE_NOT_FOUND, DETAIL_NOT_FOUND, req); + + String msg = Optional.ofNullable(ex.getMessage()).orElse("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); + + ProblemDetail pd = + baseProblem( + type(TYPE_INTERNAL_ERROR), + HttpStatus.INTERNAL_SERVER_ERROR, + TITLE_INTERNAL_ERROR, + DETAIL_GENERIC_ERROR, + req); + + attachErrors( + pd, INTERNAL_ERROR, List.of(error(INTERNAL_ERROR, "Internal error.", null, null, null))); + return pd; + } +} diff --git a/customer-service/src/main/java/io/github/bsayli/customerservice/api/error/CustomerControllerAdvice.java b/customer-service/src/main/java/io/github/bsayli/customerservice/api/error/CustomerControllerAdvice.java deleted file mode 100644 index 8146035..0000000 --- a/customer-service/src/main/java/io/github/bsayli/customerservice/api/error/CustomerControllerAdvice.java +++ /dev/null @@ -1,105 +0,0 @@ -package io.github.bsayli.customerservice.api.error; - -import static io.github.bsayli.customerservice.common.api.ApiConstants.ErrorCode.*; - -import io.github.bsayli.customerservice.common.api.response.ServiceResponse; -import jakarta.validation.ConstraintViolation; -import jakarta.validation.ConstraintViolationException; -import java.time.Instant; -import java.util.List; -import java.util.NoSuchElementException; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; -import org.springframework.http.HttpStatus; -import org.springframework.http.ResponseEntity; -import org.springframework.http.converter.HttpMessageNotReadableException; -import org.springframework.validation.FieldError; -import org.springframework.web.bind.MethodArgumentNotValidException; -import org.springframework.web.bind.annotation.ExceptionHandler; -import org.springframework.web.bind.annotation.RestControllerAdvice; -import org.springframework.web.method.annotation.MethodArgumentTypeMismatchException; - -@RestControllerAdvice(basePackages = "io.github.bsayli.customerservice.api.controller") -public class CustomerControllerAdvice { - - private static final Logger log = LoggerFactory.getLogger(CustomerControllerAdvice.class); - - @ExceptionHandler(NoSuchElementException.class) - public ResponseEntity> handleNotFound(NoSuchElementException ex) { - var payload = new ErrorPayload(NOT_FOUND, ex.getMessage(), Instant.now(), List.of()); - return ResponseEntity.status(HttpStatus.NOT_FOUND) - .body(ServiceResponse.of(HttpStatus.NOT_FOUND, NOT_FOUND, payload)); - } - - @ExceptionHandler(MethodArgumentNotValidException.class) - public ResponseEntity> handleMethodArgInvalid( - MethodArgumentNotValidException ex) { - List violations = - ex.getBindingResult().getFieldErrors().stream().map(this::toViolation).toList(); - var payload = new ErrorPayload(VALIDATION_FAILED, BAD_REQUEST, Instant.now(), violations); - return ResponseEntity.status(HttpStatus.BAD_REQUEST) - .body(ServiceResponse.of(HttpStatus.BAD_REQUEST, BAD_REQUEST, payload)); - } - - @ExceptionHandler(ConstraintViolationException.class) - public ResponseEntity> handleConstraintViolation( - ConstraintViolationException ex) { - List violations = - ex.getConstraintViolations().stream().map(this::toViolation).toList(); - var payload = new ErrorPayload(VALIDATION_FAILED, BAD_REQUEST, Instant.now(), violations); - return ResponseEntity.status(HttpStatus.BAD_REQUEST) - .body(ServiceResponse.of(HttpStatus.BAD_REQUEST, BAD_REQUEST, payload)); - } - - @ExceptionHandler(HttpMessageNotReadableException.class) - public ResponseEntity> handleNotReadable( - HttpMessageNotReadableException ex) { - Throwable cause = ex.getCause(); - String causeMsg = - (cause != null && cause.getMessage() != null) ? cause.getMessage() : ex.getMessage(); - log.warn("Bad request (not readable): {}", causeMsg); - var payload = new ErrorPayload(VALIDATION_FAILED, BAD_REQUEST, Instant.now(), List.of()); - return ResponseEntity.status(HttpStatus.BAD_REQUEST) - .body(ServiceResponse.of(HttpStatus.BAD_REQUEST, BAD_REQUEST, payload)); - } - - @ExceptionHandler(MethodArgumentTypeMismatchException.class) - public ResponseEntity> handleTypeMismatch( - MethodArgumentTypeMismatchException ex) { - String requiredType = - java.util.Optional.ofNullable(ex.getRequiredType()) - .map(Class::getSimpleName) - .orElse("unknown"); - log.warn( - "Bad request (type mismatch): param={}, value={}, requiredType={}", - ex.getName(), - ex.getValue(), - requiredType); - var v = new Violation(ex.getName(), "Type mismatch (expected " + requiredType + ")"); - var payload = new ErrorPayload(VALIDATION_FAILED, BAD_REQUEST, Instant.now(), List.of(v)); - return ResponseEntity.status(HttpStatus.BAD_REQUEST) - .body(ServiceResponse.of(HttpStatus.BAD_REQUEST, BAD_REQUEST, payload)); - } - - @ExceptionHandler(Exception.class) - public ResponseEntity> handleGeneric(Exception ex) { - log.error("Unhandled exception", ex); - var payload = new ErrorPayload(INTERNAL_ERROR, ex.getMessage(), Instant.now(), List.of()); - return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR) - .body(ServiceResponse.of(HttpStatus.INTERNAL_SERVER_ERROR, INTERNAL_ERROR, payload)); - } - - private Violation toViolation(FieldError fe) { - return new Violation(fe.getField(), fe.getDefaultMessage()); - } - - private Violation toViolation(ConstraintViolation v) { - String path = v.getPropertyPath() == null ? null : v.getPropertyPath().toString(); - return new Violation(path, v.getMessage()); - } - - public record Violation(String field, String message) {} - - public record ErrorPayload( - String code, String message, Instant timestamp, List violations) {} -} diff --git a/customer-service/src/main/java/io/github/bsayli/customerservice/api/error/JsonExceptionHandler.java b/customer-service/src/main/java/io/github/bsayli/customerservice/api/error/JsonExceptionHandler.java new file mode 100644 index 0000000..59b3521 --- /dev/null +++ b/customer-service/src/main/java/io/github/bsayli/customerservice/api/error/JsonExceptionHandler.java @@ -0,0 +1,99 @@ +package io.github.bsayli.customerservice.api.error; + +import static io.github.bsayli.customerservice.api.error.ProblemSupport.*; +import static io.github.bsayli.customerservice.common.api.ApiConstants.ErrorCode.*; + +import com.fasterxml.jackson.databind.exc.InvalidFormatException; +import com.fasterxml.jackson.databind.exc.UnrecognizedPropertyException; +import io.github.bsayli.customerservice.common.api.response.error.ErrorItem; +import jakarta.servlet.http.HttpServletRequest; +import java.util.List; +import java.util.Optional; +import org.slf4j.*; +import org.springframework.core.annotation.Order; +import org.springframework.http.*; +import org.springframework.http.converter.HttpMessageNotReadableException; +import org.springframework.web.bind.annotation.*; + +@RestControllerAdvice(basePackages = "io.github.bsayli.customerservice.api.controller") +@Order(2) +public class JsonExceptionHandler { + + private static final Logger log = LoggerFactory.getLogger(JsonExceptionHandler.class); + + @ExceptionHandler(InvalidFormatException.class) + public ProblemDetail handleInvalidFormat(InvalidFormatException ex, HttpServletRequest req) { + List errors = + ex.getPath().stream() + .map( + ref -> + error( + BAD_REQUEST, + "Invalid format: " + safe(ex.getValue()), + ref.getFieldName(), + null, + null)) + .toList(); + + ProblemDetail pd = + baseProblem( + type(TYPE_BAD_REQUEST), + HttpStatus.BAD_REQUEST, + TITLE_BAD_REQUEST, + DETAIL_NOT_READABLE, + req); + + attachErrors( + pd, + BAD_REQUEST, + errors.isEmpty() + ? List.of(error(BAD_REQUEST, "Invalid JSON payload.", null, null, null)) + : errors); + return pd; + } + + @ExceptionHandler(UnrecognizedPropertyException.class) + public ProblemDetail handleUnrecognized( + UnrecognizedPropertyException ex, HttpServletRequest req) { + String field = ex.getPropertyName(); + log.warn("Unrecognized field: '{}' (known: {})", field, ex.getKnownPropertyIds()); + + ProblemDetail pd = + baseProblem( + type(TYPE_BAD_REQUEST), + HttpStatus.BAD_REQUEST, + TITLE_BAD_REQUEST, + DETAIL_NOT_READABLE, + req); + + attachErrors( + pd, + BAD_REQUEST, + List.of(error(BAD_REQUEST, "Unrecognized field: '" + field + "'", field, null, null))); + return pd; + } + + @ExceptionHandler(HttpMessageNotReadableException.class) + public ProblemDetail handleNotReadable( + HttpMessageNotReadableException ex, HttpServletRequest req) { + String raw = + Optional.ofNullable(ex.getCause()).map(Throwable::getMessage).orElseGet(ex::getMessage); + log.warn("Bad request (not readable): {}", raw); + + ProblemDetail pd = + baseProblem( + type(TYPE_BAD_REQUEST), + HttpStatus.BAD_REQUEST, + TITLE_BAD_REQUEST, + DETAIL_NOT_READABLE, + req); + + attachErrors( + pd, BAD_REQUEST, List.of(error(BAD_REQUEST, "Invalid JSON payload.", null, null, null))); + return pd; + } + + private String safe(Object v) { + return v != null ? v.toString() : "null"; + } +} diff --git a/customer-service/src/main/java/io/github/bsayli/customerservice/api/error/ProblemSupport.java b/customer-service/src/main/java/io/github/bsayli/customerservice/api/error/ProblemSupport.java new file mode 100644 index 0000000..5436061 --- /dev/null +++ b/customer-service/src/main/java/io/github/bsayli/customerservice/api/error/ProblemSupport.java @@ -0,0 +1,62 @@ +package io.github.bsayli.customerservice.api.error; + +import io.github.bsayli.customerservice.common.api.response.error.ErrorItem; +import io.github.bsayli.customerservice.common.api.response.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"; + + static final String TITLE_BAD_REQUEST = "Bad request"; + static final String TITLE_VALIDATION_FAILED = "Validation failed"; + static final String TITLE_NOT_FOUND = "Resource not found"; + static final String TITLE_INTERNAL_ERROR = "Internal server error"; + static final String TITLE_METHOD_NOT_ALLOWED = "Method not allowed"; + + static final String DETAIL_NOT_FOUND = "Requested resource was not found."; + static final String DETAIL_VALIDATION_FAILED = "One or more fields are invalid."; + static final String DETAIL_NOT_READABLE = "Malformed request body."; + static final String DETAIL_PARAM_INVALID = "One or more parameters are invalid."; + static final String DETAIL_GENERIC_ERROR = "Unexpected error occurred."; + + private static final String PROBLEM_BASE = "https://example.com/problems/"; + + 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/customer-service/src/main/java/io/github/bsayli/customerservice/api/error/SpringHttpExceptionHandler.java b/customer-service/src/main/java/io/github/bsayli/customerservice/api/error/SpringHttpExceptionHandler.java new file mode 100644 index 0000000..ef96ef0 --- /dev/null +++ b/customer-service/src/main/java/io/github/bsayli/customerservice/api/error/SpringHttpExceptionHandler.java @@ -0,0 +1,103 @@ +package io.github.bsayli.customerservice.api.error; + +import static io.github.bsayli.customerservice.api.error.ProblemSupport.*; +import static io.github.bsayli.customerservice.common.api.ApiConstants.ErrorCode.*; + +import jakarta.servlet.http.HttpServletRequest; +import java.util.List; +import java.util.Optional; +import org.slf4j.*; +import org.springframework.core.annotation.Order; +import org.springframework.http.*; +import org.springframework.web.*; +import org.springframework.web.bind.MissingServletRequestParameterException; +import org.springframework.web.bind.annotation.*; +import org.springframework.web.method.annotation.MethodArgumentTypeMismatchException; +import org.springframework.web.servlet.resource.NoResourceFoundException; + +@RestControllerAdvice(basePackages = "io.github.bsayli.customerservice.api.controller") +@Order(3) +public class SpringHttpExceptionHandler { + + private static final Logger log = LoggerFactory.getLogger(SpringHttpExceptionHandler.class); + + @ExceptionHandler(NoResourceFoundException.class) + public ProblemDetail handleNoResourceFound(NoResourceFoundException ex, HttpServletRequest req) { + log.warn("Endpoint not found: {}", ex.getResourcePath()); + + ProblemDetail pd = + baseProblem( + type(TYPE_NOT_FOUND), HttpStatus.NOT_FOUND, TITLE_NOT_FOUND, DETAIL_NOT_FOUND, req); + + attachErrors(pd, NOT_FOUND, List.of(error(NOT_FOUND, "Endpoint not found.", null, null, null))); + return pd; + } + + @ExceptionHandler(HttpRequestMethodNotSupportedException.class) + public ProblemDetail handleMethodNotSupported( + HttpRequestMethodNotSupportedException ex, HttpServletRequest req) { + String method = ex.getMethod(); + + ProblemDetail pd = + baseProblem( + type(TYPE_METHOD_NOT_ALLOWED), + HttpStatus.METHOD_NOT_ALLOWED, + TITLE_METHOD_NOT_ALLOWED, + "The request method is not supported for this resource.", + req); + + attachErrors( + pd, + "METHOD_NOT_ALLOWED", + List.of( + error("METHOD_NOT_ALLOWED", "HTTP method not supported: " + method, null, null, null))); + return pd; + } + + @ExceptionHandler(MissingServletRequestParameterException.class) + public ProblemDetail handleMissingParam( + MissingServletRequestParameterException ex, HttpServletRequest req) { + String param = ex.getParameterName(); + + ProblemDetail pd = + baseProblem( + type(TYPE_BAD_REQUEST), + HttpStatus.BAD_REQUEST, + TITLE_BAD_REQUEST, + DETAIL_PARAM_INVALID, + req); + + attachErrors( + pd, + BAD_REQUEST, + List.of(error(BAD_REQUEST, "Missing required parameter: " + param, param, null, null))); + return pd; + } + + @ExceptionHandler(MethodArgumentTypeMismatchException.class) + public ProblemDetail handleTypeMismatch( + MethodArgumentTypeMismatchException ex, HttpServletRequest req) { + String expected = + Optional.ofNullable(ex.getRequiredType()).map(Class::getSimpleName).orElse("unknown"); + + ProblemDetail pd = + baseProblem( + type(TYPE_BAD_REQUEST), + HttpStatus.BAD_REQUEST, + TITLE_BAD_REQUEST, + DETAIL_PARAM_INVALID, + req); + + attachErrors( + pd, + BAD_REQUEST, + List.of( + error( + BAD_REQUEST, + "Invalid value (expected " + expected + ").", + ex.getName(), + null, + null))); + return pd; + } +} diff --git a/customer-service/src/main/java/io/github/bsayli/customerservice/api/error/ValidationExceptionHandler.java b/customer-service/src/main/java/io/github/bsayli/customerservice/api/error/ValidationExceptionHandler.java new file mode 100644 index 0000000..712dfb8 --- /dev/null +++ b/customer-service/src/main/java/io/github/bsayli/customerservice/api/error/ValidationExceptionHandler.java @@ -0,0 +1,86 @@ +package io.github.bsayli.customerservice.api.error; + +import static io.github.bsayli.customerservice.api.error.ProblemSupport.*; +import static io.github.bsayli.customerservice.common.api.ApiConstants.ErrorCode.*; + +import io.github.bsayli.customerservice.common.api.response.error.ErrorItem; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.validation.ConstraintViolation; +import jakarta.validation.ConstraintViolationException; +import java.util.List; +import java.util.Optional; +import org.springframework.core.annotation.Order; +import org.springframework.http.HttpStatus; +import org.springframework.http.ProblemDetail; +import org.springframework.validation.BindException; +import org.springframework.validation.FieldError; +import org.springframework.web.bind.MethodArgumentNotValidException; +import org.springframework.web.bind.annotation.*; + +@RestControllerAdvice(basePackages = "io.github.bsayli.customerservice.api.controller") +@Order(1) +public class ValidationExceptionHandler { + + @ExceptionHandler(MethodArgumentNotValidException.class) + public ProblemDetail handleMethodArgInvalid( + MethodArgumentNotValidException ex, HttpServletRequest req) { + List errors = + ex.getBindingResult().getFieldErrors().stream().map(this::toErrorItem).toList(); + + ProblemDetail pd = + baseProblem( + type(TYPE_VALIDATION_FAILED), + HttpStatus.BAD_REQUEST, + TITLE_VALIDATION_FAILED, + DETAIL_VALIDATION_FAILED, + req); + + attachErrors(pd, VALIDATION_FAILED, errors); + return pd; + } + + @ExceptionHandler(ConstraintViolationException.class) + public ProblemDetail handleConstraintViolation( + ConstraintViolationException ex, HttpServletRequest req) { + List errors = ex.getConstraintViolations().stream().map(this::toErrorItem).toList(); + + ProblemDetail pd = + baseProblem( + type(TYPE_VALIDATION_FAILED), + HttpStatus.BAD_REQUEST, + TITLE_VALIDATION_FAILED, + DETAIL_VALIDATION_FAILED, + 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 = + baseProblem( + type(TYPE_VALIDATION_FAILED), + HttpStatus.BAD_REQUEST, + TITLE_VALIDATION_FAILED, + DETAIL_VALIDATION_FAILED, + req); + + attachErrors(pd, VALIDATION_FAILED, errors); + return pd; + } + + private ErrorItem toErrorItem(FieldError fe) { + String field = Optional.of(fe.getField()).orElse(""); + String message = Optional.ofNullable(fe.getDefaultMessage()).orElse("invalid"); + return error(VALIDATION_FAILED, message, field, null, null); + } + + private ErrorItem toErrorItem(ConstraintViolation v) { + String field = v.getPropertyPath() == null ? "" : v.getPropertyPath().toString(); + String message = Optional.ofNullable(v.getMessage()).orElse("invalid"); + return error(VALIDATION_FAILED, message, field, null, null); + } +} diff --git a/customer-service/src/main/java/io/github/bsayli/customerservice/common/api/ApiConstants.java b/customer-service/src/main/java/io/github/bsayli/customerservice/common/api/ApiConstants.java index 1582a3d..ab18155 100644 --- a/customer-service/src/main/java/io/github/bsayli/customerservice/common/api/ApiConstants.java +++ b/customer-service/src/main/java/io/github/bsayli/customerservice/common/api/ApiConstants.java @@ -3,15 +3,6 @@ public final class ApiConstants { private ApiConstants() {} - public static final class Response { - public static final String CREATED = "CREATED"; - public static final String UPDATED = "UPDATED"; - public static final String DELETED = "DELETED"; - public static final String LISTED = "LISTED"; - - private Response() {} - } - public static final class ErrorCode { public static final String NOT_FOUND = "NOT_FOUND"; public static final String BAD_REQUEST = "BAD_REQUEST"; diff --git a/customer-service/src/main/java/io/github/bsayli/customerservice/common/api/response/ErrorDetail.java b/customer-service/src/main/java/io/github/bsayli/customerservice/common/api/response/ErrorDetail.java deleted file mode 100644 index 8e5c264..0000000 --- a/customer-service/src/main/java/io/github/bsayli/customerservice/common/api/response/ErrorDetail.java +++ /dev/null @@ -1,3 +0,0 @@ -package io.github.bsayli.customerservice.common.api.response; - -public record ErrorDetail(String errorCode, String message) {} diff --git a/customer-service/src/main/java/io/github/bsayli/customerservice/common/api/response/Meta.java b/customer-service/src/main/java/io/github/bsayli/customerservice/common/api/response/Meta.java new file mode 100644 index 0000000..227882a --- /dev/null +++ b/customer-service/src/main/java/io/github/bsayli/customerservice/common/api/response/Meta.java @@ -0,0 +1,21 @@ +package io.github.bsayli.customerservice.common.api.response; + +import io.github.bsayli.customerservice.common.api.sort.Sort; +import java.time.Instant; +import java.util.List; + +/** + * Common metadata going with every successful API response. Typically, it includes request-level + * context such as identifiers, timestamps, and sorting details. Future-proof can be extended with + * fields like traceId, locale, or tenantId if needed. + */ +public record Meta(Instant serverTime, List sort) { + + public static Meta now() { + return new Meta(Instant.now(), List.of()); + } + + public static Meta now(List sort) { + return new Meta(Instant.now(), sort == null ? List.of() : List.copyOf(sort)); + } +} diff --git a/customer-service/src/main/java/io/github/bsayli/customerservice/common/api/response/Page.java b/customer-service/src/main/java/io/github/bsayli/customerservice/common/api/response/Page.java new file mode 100644 index 0000000..237965f --- /dev/null +++ b/customer-service/src/main/java/io/github/bsayli/customerservice/common/api/response/Page.java @@ -0,0 +1,27 @@ +package io.github.bsayli.customerservice.common.api.response; + +import java.util.List; + +/** + * Generic pagination container used in API responses. Designed to be language-agnostic for OpenAPI + * client generation. + * + * @param the element type + */ +public record Page( + List content, + int page, + int size, + long totalElements, + int totalPages, + boolean hasNext, + boolean hasPrev) { + + public static Page of(List content, int page, int size, long totalElements) { + List safeContent = content == null ? List.of() : List.copyOf(content); + int totalPages = (int) Math.ceil((double) totalElements / size); + boolean hasNext = page + 1 < totalPages; + boolean hasPrev = page > 0; + return new Page<>(safeContent, page, size, totalElements, totalPages, hasNext, hasPrev); + } +} diff --git a/customer-service/src/main/java/io/github/bsayli/customerservice/common/api/response/ServiceResponse.java b/customer-service/src/main/java/io/github/bsayli/customerservice/common/api/response/ServiceResponse.java index a8ae662..c7d130c 100644 --- a/customer-service/src/main/java/io/github/bsayli/customerservice/common/api/response/ServiceResponse.java +++ b/customer-service/src/main/java/io/github/bsayli/customerservice/common/api/response/ServiceResponse.java @@ -1,25 +1,12 @@ package io.github.bsayli.customerservice.common.api.response; -import java.util.Collections; -import java.util.List; -import org.springframework.http.HttpStatus; +public record ServiceResponse(T data, Meta meta) { -public record ServiceResponse(int status, String message, T data, List errors) { public static ServiceResponse ok(T data) { - return new ServiceResponse<>(HttpStatus.OK.value(), "OK", data, Collections.emptyList()); + return new ServiceResponse<>(data, Meta.now()); } - public static ServiceResponse of(HttpStatus status, String message, T data) { - return new ServiceResponse<>(status.value(), message, data, Collections.emptyList()); - } - - public static ServiceResponse error(HttpStatus status, String message) { - return new ServiceResponse<>(status.value(), message, null, Collections.emptyList()); - } - - public static ServiceResponse error( - HttpStatus status, String message, List errors) { - return new ServiceResponse<>( - status.value(), message, null, errors != null ? errors : Collections.emptyList()); + public static ServiceResponse ok(T data, Meta meta) { + return new ServiceResponse<>(data, meta != null ? meta : Meta.now()); } } diff --git a/customer-service/src/main/java/io/github/bsayli/customerservice/common/api/response/error/ErrorItem.java b/customer-service/src/main/java/io/github/bsayli/customerservice/common/api/response/error/ErrorItem.java new file mode 100644 index 0000000..8ff1d2c --- /dev/null +++ b/customer-service/src/main/java/io/github/bsayli/customerservice/common/api/response/error/ErrorItem.java @@ -0,0 +1,6 @@ +package io.github.bsayli.customerservice.common.api.response.error; + +import com.fasterxml.jackson.annotation.JsonInclude; + +@JsonInclude(JsonInclude.Include.NON_NULL) +public record ErrorItem(String code, String message, String field, String resource, String id) {} diff --git a/customer-service/src/main/java/io/github/bsayli/customerservice/common/api/response/error/ProblemExtensions.java b/customer-service/src/main/java/io/github/bsayli/customerservice/common/api/response/error/ProblemExtensions.java new file mode 100644 index 0000000..208f404 --- /dev/null +++ b/customer-service/src/main/java/io/github/bsayli/customerservice/common/api/response/error/ProblemExtensions.java @@ -0,0 +1,11 @@ +package io.github.bsayli.customerservice.common.api.response.error; + +import com.fasterxml.jackson.annotation.JsonInclude; +import java.util.List; + +@JsonInclude(JsonInclude.Include.NON_NULL) +public record ProblemExtensions(List errors) { + public static ProblemExtensions ofErrors(List errors) { + return new ProblemExtensions(errors); + } +} diff --git a/customer-service/src/main/java/io/github/bsayli/customerservice/common/api/sort/Sort.java b/customer-service/src/main/java/io/github/bsayli/customerservice/common/api/sort/Sort.java new file mode 100644 index 0000000..6ef4324 --- /dev/null +++ b/customer-service/src/main/java/io/github/bsayli/customerservice/common/api/sort/Sort.java @@ -0,0 +1,3 @@ +package io.github.bsayli.customerservice.common.api.sort; + +public record Sort(SortField field, SortDirection direction) {} diff --git a/customer-service/src/main/java/io/github/bsayli/customerservice/common/api/sort/SortDirection.java b/customer-service/src/main/java/io/github/bsayli/customerservice/common/api/sort/SortDirection.java new file mode 100644 index 0000000..8bf5239 --- /dev/null +++ b/customer-service/src/main/java/io/github/bsayli/customerservice/common/api/sort/SortDirection.java @@ -0,0 +1,24 @@ +package io.github.bsayli.customerservice.common.api.sort; + +import com.fasterxml.jackson.annotation.JsonValue; + +public enum SortDirection { + ASC("asc"), + DESC("desc"); + + private final String value; + + SortDirection(String value) { + this.value = value; + } + + public static SortDirection from(String s) { + if (s == null) return ASC; + return "desc".equalsIgnoreCase(s) ? DESC : ASC; + } + + @JsonValue + public String value() { + return value; + } +} diff --git a/customer-service/src/main/java/io/github/bsayli/customerservice/common/api/sort/SortField.java b/customer-service/src/main/java/io/github/bsayli/customerservice/common/api/sort/SortField.java new file mode 100644 index 0000000..ca94b9a --- /dev/null +++ b/customer-service/src/main/java/io/github/bsayli/customerservice/common/api/sort/SortField.java @@ -0,0 +1,28 @@ +package io.github.bsayli.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/customer-service/src/main/java/io/github/bsayli/customerservice/common/openapi/ApiResponseSchemaFactory.java b/customer-service/src/main/java/io/github/bsayli/customerservice/common/openapi/ApiResponseSchemaFactory.java index d22078a..72b18ea 100644 --- a/customer-service/src/main/java/io/github/bsayli/customerservice/common/openapi/ApiResponseSchemaFactory.java +++ b/customer-service/src/main/java/io/github/bsayli/customerservice/common/openapi/ApiResponseSchemaFactory.java @@ -6,8 +6,13 @@ import io.swagger.v3.oas.models.media.ObjectSchema; import io.swagger.v3.oas.models.media.Schema; import java.util.List; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; public final class ApiResponseSchemaFactory { + + private static final Logger log = LoggerFactory.getLogger(ApiResponseSchemaFactory.class); + private ApiResponseSchemaFactory() {} public static Schema createComposedWrapper(String dataRefName) { @@ -15,6 +20,13 @@ public static Schema createComposedWrapper(String dataRefName) { } public static Schema createComposedWrapper(String dataRefName, String classExtraAnnotation) { + if (log.isDebugEnabled()) { + log.debug( + "Creating composed wrapper for dataRef='{}', extraAnnotation='{}'", + dataRefName, + classExtraAnnotation); + } + var schema = new ComposedSchema(); schema.setAllOf( List.of( @@ -28,6 +40,14 @@ public static Schema createComposedWrapper(String dataRefName, String classEx if (classExtraAnnotation != null && !classExtraAnnotation.isBlank()) { schema.addExtension(EXT_CLASS_EXTRA_ANNOTATION, classExtraAnnotation); + if (log.isDebugEnabled()) { + log.debug("Added extension {}='{}'", EXT_CLASS_EXTRA_ANNOTATION, classExtraAnnotation); + } + } + + if (log.isDebugEnabled()) { + log.debug( + "Composed schema created for '{}': extensions={}", dataRefName, schema.getExtensions()); } return schema; diff --git a/customer-service/src/main/java/io/github/bsayli/customerservice/common/openapi/GlobalErrorResponsesCustomizer.java b/customer-service/src/main/java/io/github/bsayli/customerservice/common/openapi/GlobalErrorResponsesCustomizer.java new file mode 100644 index 0000000..3104386 --- /dev/null +++ b/customer-service/src/main/java/io/github/bsayli/customerservice/common/openapi/GlobalErrorResponsesCustomizer.java @@ -0,0 +1,167 @@ +package io.github.bsayli.customerservice.common.openapi; + +import static io.github.bsayli.customerservice.common.openapi.OpenApiSchemas.SCHEMA_PROBLEM_DETAIL; + +import io.swagger.v3.oas.models.media.ArraySchema; +import io.swagger.v3.oas.models.media.Content; +import io.swagger.v3.oas.models.media.IntegerSchema; +import io.swagger.v3.oas.models.media.MediaType; +import io.swagger.v3.oas.models.media.ObjectSchema; +import io.swagger.v3.oas.models.media.Schema; +import io.swagger.v3.oas.models.media.StringSchema; +import io.swagger.v3.oas.models.responses.ApiResponse; +import java.util.Map; +import org.springdoc.core.customizers.OpenApiCustomizer; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +@Configuration +public class GlobalErrorResponsesCustomizer { + + private static final String MEDIA_TYPE_PROBLEM_JSON = "application/problem+json"; + private static final String REF_PROBLEM_DETAIL = "#/components/schemas/" + SCHEMA_PROBLEM_DETAIL; + private static final String SCHEMA_ERROR_ITEM = "ErrorItem"; + + private static final String STATUS_400 = "400"; + private static final String STATUS_404 = "404"; + private static final String STATUS_405 = "405"; + private static final String STATUS_500 = "500"; + + private static final String DESC_BAD_REQUEST = "Bad Request"; + private static final String DESC_NOT_FOUND = "Not Found"; + private static final String DESC_METHOD_NOT_ALLOWED = "Method Not Allowed"; + private static final String DESC_INTERNAL_ERROR = "Internal Server Error"; + + @Bean + OpenApiCustomizer addDefaultProblemResponses() { + return openApi -> { + var components = openApi.getComponents(); + if (components == null) return; + + ensureErrorItemSchema(components.getSchemas()); + ensureProblemDetailSchema(components.getSchemas()); + + openApi + .getPaths() + .forEach( + (path, item) -> + item.readOperations() + .forEach( + op -> { + var responses = op.getResponses(); + var problemContent = + new Content() + .addMediaType( + MEDIA_TYPE_PROBLEM_JSON, + new MediaType() + .schema(new Schema<>().$ref(REF_PROBLEM_DETAIL))); + + responses.addApiResponse( + STATUS_400, + new ApiResponse() + .description(DESC_BAD_REQUEST) + .content(problemContent)); + responses.addApiResponse( + STATUS_404, + new ApiResponse() + .description(DESC_NOT_FOUND) + .content(problemContent)); + responses.addApiResponse( + STATUS_405, + new ApiResponse() + .description(DESC_METHOD_NOT_ALLOWED) + .content(problemContent)); + responses.addApiResponse( + STATUS_500, + new ApiResponse() + .description(DESC_INTERNAL_ERROR) + .content(problemContent)); + })); + }; + } + + @SuppressWarnings("rawtypes") + private void ensureProblemDetailSchema(Map schemas) { + if (schemas == null) return; + if (schemas.containsKey(SCHEMA_PROBLEM_DETAIL)) return; + + ObjectSchema pd = new ObjectSchema(); + + StringSchema type = new StringSchema(); + type.setFormat("uri"); + type.setDescription("Problem type as a URI."); + pd.addProperty("type", type); + + StringSchema title = new StringSchema(); + title.setDescription("Short, human-readable summary of the problem type."); + pd.addProperty("title", title); + + IntegerSchema status = new IntegerSchema(); + status.setFormat("int32"); + status.setDescription("HTTP status code for this problem."); + pd.addProperty("status", status); + + StringSchema detail = new StringSchema(); + detail.setDescription("Human-readable explanation specific to this occurrence."); + pd.addProperty("detail", detail); + + StringSchema instance = new StringSchema(); + instance.setFormat("uri"); + instance.setDescription("URI that identifies this specific occurrence."); + pd.addProperty("instance", instance); + + StringSchema errorCode = new StringSchema(); + errorCode.setDescription("Application-specific error code."); + pd.addProperty("errorCode", errorCode); + + // extensions.errors[] + ArraySchema errorsArray = new ArraySchema(); + errorsArray.setItems(new Schema<>().$ref("#/components/schemas/" + SCHEMA_ERROR_ITEM)); + errorsArray.setDescription("List of error items (field-level or domain-specific)."); + + ObjectSchema extensions = new ObjectSchema(); + extensions.addProperty("errors", errorsArray); + extensions.setDescription("Additional problem metadata."); + extensions.setAdditionalProperties(Boolean.FALSE); + + pd.addProperty("extensions", extensions); + + pd.setAdditionalProperties(Boolean.TRUE); + + schemas.put(SCHEMA_PROBLEM_DETAIL, pd); + } + + @SuppressWarnings("rawtypes") + private void ensureErrorItemSchema(Map schemas) { + if (schemas == null) return; + if (schemas.containsKey(SCHEMA_ERROR_ITEM)) return; + + ObjectSchema errorItem = new ObjectSchema(); + + StringSchema code = new StringSchema(); + code.setDescription("Short application-specific error code."); + errorItem.addProperty("code", code); + + StringSchema message = new StringSchema(); + message.setDescription("Human-readable error message."); + errorItem.addProperty("message", message); + + StringSchema field = new StringSchema(); + field.setDescription("Field name when error is field-specific."); + errorItem.addProperty("field", field); + + StringSchema resource = new StringSchema(); + resource.setDescription("Domain resource name if applicable."); + errorItem.addProperty("resource", resource); + + StringSchema id = new StringSchema(); + id.setDescription("Resource identifier if applicable."); + errorItem.addProperty("id", id); + + errorItem.setDescription("Standard error item structure."); + errorItem.setRequired(java.util.List.of("code", "message")); + errorItem.setAdditionalProperties(Boolean.FALSE); + + schemas.put(SCHEMA_ERROR_ITEM, errorItem); + } +} diff --git a/customer-service/src/main/java/io/github/bsayli/customerservice/common/openapi/OpenApiSchemas.java b/customer-service/src/main/java/io/github/bsayli/customerservice/common/openapi/OpenApiSchemas.java index 76541a1..ea9705c 100644 --- a/customer-service/src/main/java/io/github/bsayli/customerservice/common/openapi/OpenApiSchemas.java +++ b/customer-service/src/main/java/io/github/bsayli/customerservice/common/openapi/OpenApiSchemas.java @@ -3,21 +3,28 @@ import io.github.bsayli.customerservice.common.api.response.ServiceResponse; public final class OpenApiSchemas { - // Common property keys - public static final String PROP_STATUS = "status"; - public static final String PROP_MESSAGE = "message"; - public static final String PROP_ERRORS = "errors"; - public static final String PROP_ERROR_CODE = "errorCode"; + + // ---- Common property keys public static final String PROP_DATA = "data"; + public static final String PROP_META = "meta"; - // Base envelopes + // ---- Base envelopes public static final String SCHEMA_SERVICE_RESPONSE = ServiceResponse.class.getSimpleName(); public static final String SCHEMA_SERVICE_RESPONSE_VOID = SCHEMA_SERVICE_RESPONSE + "Void"; - // Vendor extensions + // ---- Other shared schemas + public static final String SCHEMA_META = "Meta"; + public static final String SCHEMA_SORT = "Sort"; + public static final String SCHEMA_PROBLEM_DETAIL = "ProblemDetail"; + + // ---- Vendor extensions public static final String EXT_API_WRAPPER = "x-api-wrapper"; public static final String EXT_API_WRAPPER_DATATYPE = "x-api-wrapper-datatype"; public static final String EXT_CLASS_EXTRA_ANNOTATION = "x-class-extra-annotation"; + // ---- Vendor extensions (nested/container awareness) + public static final String EXT_DATA_CONTAINER = "x-data-container"; // e.g. "Page" + public static final String EXT_DATA_ITEM = "x-data-item"; // e.g. "CustomerDto" + private OpenApiSchemas() {} } diff --git a/customer-service/src/main/java/io/github/bsayli/customerservice/common/openapi/SwaggerResponseCustomizer.java b/customer-service/src/main/java/io/github/bsayli/customerservice/common/openapi/SwaggerResponseCustomizer.java index 476dff9..f14436c 100644 --- a/customer-service/src/main/java/io/github/bsayli/customerservice/common/openapi/SwaggerResponseCustomizer.java +++ b/customer-service/src/main/java/io/github/bsayli/customerservice/common/openapi/SwaggerResponseCustomizer.java @@ -2,10 +2,7 @@ import static io.github.bsayli.customerservice.common.openapi.OpenApiSchemas.*; -import io.swagger.v3.oas.models.media.ArraySchema; -import io.swagger.v3.oas.models.media.IntegerSchema; -import io.swagger.v3.oas.models.media.ObjectSchema; -import io.swagger.v3.oas.models.media.StringSchema; +import io.swagger.v3.oas.models.media.*; import org.springdoc.core.customizers.OpenApiCustomizer; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; @@ -13,42 +10,51 @@ @Configuration public class SwaggerResponseCustomizer { + private static final String COMPONENTS_SCHEMAS = "#/components/schemas/"; + @Bean public OpenApiCustomizer responseEnvelopeSchemas() { return openApi -> { - if (!openApi.getComponents().getSchemas().containsKey(SCHEMA_SERVICE_RESPONSE)) { - openApi - .getComponents() - .addSchemas( - SCHEMA_SERVICE_RESPONSE, - new ObjectSchema() - .addProperty(PROP_STATUS, new IntegerSchema().format("int32")) - .addProperty(PROP_MESSAGE, new StringSchema()) - .addProperty( - PROP_ERRORS, - new ArraySchema() - .items( - new ObjectSchema() - .addProperty(PROP_ERROR_CODE, new StringSchema()) - .addProperty(PROP_MESSAGE, new StringSchema())))); + var schemas = openApi.getComponents().getSchemas(); + if (schemas == null) { + openApi.getComponents().setSchemas(new java.util.LinkedHashMap<>()); + schemas = openApi.getComponents().getSchemas(); + } + + if (!schemas.containsKey(SCHEMA_SORT)) { + schemas.put( + SCHEMA_SORT, + new ObjectSchema() + .addProperty("field", new StringSchema()) + .addProperty( + "direction", new StringSchema()._enum(java.util.List.of("asc", "desc")))); + } + + if (!schemas.containsKey(SCHEMA_META)) { + schemas.put( + SCHEMA_META, + new ObjectSchema() + .addProperty("serverTime", new StringSchema().format("date-time")) + .addProperty( + "sort", + new ArraySchema() + .items(new Schema<>().$ref(COMPONENTS_SCHEMAS + SCHEMA_SORT)))); + } + + if (!schemas.containsKey(SCHEMA_SERVICE_RESPONSE)) { + schemas.put( + SCHEMA_SERVICE_RESPONSE, + new ObjectSchema() + .addProperty(PROP_DATA, new ObjectSchema()) + .addProperty(PROP_META, new Schema<>().$ref(COMPONENTS_SCHEMAS + SCHEMA_META))); } - if (!openApi.getComponents().getSchemas().containsKey(SCHEMA_SERVICE_RESPONSE_VOID)) { - openApi - .getComponents() - .addSchemas( - SCHEMA_SERVICE_RESPONSE_VOID, - new ObjectSchema() - .addProperty(PROP_STATUS, new IntegerSchema().format("int32")) - .addProperty(PROP_MESSAGE, new StringSchema()) - .addProperty(PROP_DATA, new ObjectSchema()) - .addProperty( - PROP_ERRORS, - new ArraySchema() - .items( - new ObjectSchema() - .addProperty(PROP_ERROR_CODE, new StringSchema()) - .addProperty(PROP_MESSAGE, new StringSchema())))); + if (!schemas.containsKey(SCHEMA_SERVICE_RESPONSE_VOID)) { + schemas.put( + SCHEMA_SERVICE_RESPONSE_VOID, + new ObjectSchema() + .addProperty(PROP_DATA, new ObjectSchema()) + .addProperty(PROP_META, new Schema<>().$ref(COMPONENTS_SCHEMAS + SCHEMA_META))); } }; } diff --git a/customer-service/src/main/java/io/github/bsayli/customerservice/common/openapi/autoreg/AutoWrapperSchemaCustomizer.java b/customer-service/src/main/java/io/github/bsayli/customerservice/common/openapi/autoreg/AutoWrapperSchemaCustomizer.java index 2dd0585..b9e2da4 100644 --- a/customer-service/src/main/java/io/github/bsayli/customerservice/common/openapi/autoreg/AutoWrapperSchemaCustomizer.java +++ b/customer-service/src/main/java/io/github/bsayli/customerservice/common/openapi/autoreg/AutoWrapperSchemaCustomizer.java @@ -3,9 +3,14 @@ import io.github.bsayli.customerservice.common.openapi.ApiResponseSchemaFactory; import io.github.bsayli.customerservice.common.openapi.OpenApiSchemas; import io.github.bsayli.customerservice.common.openapi.introspector.ResponseTypeIntrospector; -import java.util.Collections; -import java.util.LinkedHashSet; -import java.util.Set; +import io.swagger.v3.oas.models.OpenAPI; +import io.swagger.v3.oas.models.media.ArraySchema; +import io.swagger.v3.oas.models.media.ComposedSchema; +import io.swagger.v3.oas.models.media.JsonSchema; +import io.swagger.v3.oas.models.media.ObjectSchema; +import io.swagger.v3.oas.models.media.Schema; +import java.util.*; +import java.util.stream.Collectors; import org.springdoc.core.customizers.OpenApiCustomizer; import org.springframework.beans.factory.ListableBeanFactory; import org.springframework.beans.factory.annotation.Value; @@ -17,29 +22,37 @@ @Configuration public class AutoWrapperSchemaCustomizer { + private static final String SCHEMA_REF_PREFIX = "#/components/schemas/"; + private static final String CONTENT = "content"; + private final Set dataRefs; private final String classExtraAnnotation; + private final Set genericContainers; public AutoWrapperSchemaCustomizer( ListableBeanFactory beanFactory, ResponseTypeIntrospector introspector, - @Value("${app.openapi.wrapper.class-extra-annotation:}") String classExtraAnnotation) { - - Set refs = new LinkedHashSet<>(); - beanFactory - .getBeansOfType(RequestMappingHandlerMapping.class) - .values() - .forEach( - rmh -> - rmh.getHandlerMethods().values().stream() - .map(HandlerMethod::getMethod) - .forEach(m -> introspector.extractDataRefName(m).ifPresent(refs::add))); - - this.dataRefs = Collections.unmodifiableSet(refs); + @Value("${app.openapi.wrapper.class-extra-annotation:}") String classExtraAnnotation, + @Value("${app.openapi.wrapper.generic-containers:Page}") String genericContainersProp) { + + this.dataRefs = + beanFactory.getBeansOfType(RequestMappingHandlerMapping.class).values().stream() + .flatMap(rmh -> rmh.getHandlerMethods().values().stream()) + .map(HandlerMethod::getMethod) + .map(introspector::extractDataRefName) + .flatMap(Optional::stream) + .collect(Collectors.toCollection(LinkedHashSet::new)); + this.classExtraAnnotation = (classExtraAnnotation == null || classExtraAnnotation.isBlank()) ? null : classExtraAnnotation; + + this.genericContainers = + Arrays.stream(genericContainersProp.split(",")) + .map(String::trim) + .filter(s -> !s.isEmpty()) + .collect(Collectors.toUnmodifiableSet()); } @Bean @@ -47,12 +60,98 @@ public OpenApiCustomizer autoResponseWrappers() { return openApi -> dataRefs.forEach( ref -> { - String name = OpenApiSchemas.SCHEMA_SERVICE_RESPONSE + ref; + String wrapperName = OpenApiSchemas.SCHEMA_SERVICE_RESPONSE + ref; openApi .getComponents() .addSchemas( - name, + wrapperName, ApiResponseSchemaFactory.createComposedWrapper(ref, classExtraAnnotation)); + enrichWrapperExtensions(openApi, wrapperName, ref); }); } + + private void enrichWrapperExtensions(OpenAPI openApi, String wrapperName, String dataRefName) { + String container = matchContainer(dataRefName); + if (container == null) return; + + Map schemas = + (openApi.getComponents() != null) ? openApi.getComponents().getSchemas() : null; + if (schemas == null) return; + + Schema raw = schemas.get(dataRefName); + Schema containerSchema = resolveObjectLikeSchema(schemas, raw, new LinkedHashSet<>()); + if (containerSchema == null) return; + + String itemName = extractItemNameFromSchema(containerSchema); + if (itemName == null) return; + + Schema wrapper = schemas.get(wrapperName); + if (wrapper == null) return; + + wrapper.addExtension(OpenApiSchemas.EXT_DATA_CONTAINER, container); + wrapper.addExtension(OpenApiSchemas.EXT_DATA_ITEM, itemName); + } + + private Schema resolveObjectLikeSchema( + Map schemas, Schema schema, Set visited) { + if (schema == null) return null; + + Schema cur = derefIfNeeded(schemas, schema, visited); + if (cur == null) return null; + + if (isObjectLike(cur)) return cur; + + if (cur instanceof ComposedSchema cs && cs.getAllOf() != null) { + for (Schema s : cs.getAllOf()) { + Schema resolved = resolveObjectLikeSchema(schemas, s, visited); + if (resolved != null) return resolved; + } + } + return null; + } + + private boolean isObjectLike(Schema s) { + return (s instanceof ObjectSchema) + || "object".equals(s.getType()) + || (s.getProperties() != null && !s.getProperties().isEmpty()); + } + + private Schema derefIfNeeded(Map schemas, Schema s, Set visited) { + if (s == null) return null; + String ref = s.get$ref(); + if (ref == null || !ref.startsWith(SCHEMA_REF_PREFIX)) return s; + + String name = ref.substring(SCHEMA_REF_PREFIX.length()); + if (!visited.add(name)) return null; // cycle guard + return schemas.get(name); + } + + private String extractItemNameFromSchema(Schema containerSchema) { + Map props = containerSchema.getProperties(); + if (props == null) return null; + + Schema content = props.get(CONTENT); + if (content == null) return null; + + Schema items = null; + if (content instanceof ArraySchema arr) { + items = arr.getItems(); + } else if ("array".equals(content.getType())) { + items = content.getItems(); + } else if (content instanceof JsonSchema js + && js.getTypes() != null + && js.getTypes().contains("array")) { + items = js.getItems(); + } + if (items == null) return null; + + String itemRef = items.get$ref(); + if (itemRef == null || !itemRef.startsWith(SCHEMA_REF_PREFIX)) return null; + + return itemRef.substring(SCHEMA_REF_PREFIX.length()); + } + + private String matchContainer(String dataRefName) { + return genericContainers.stream().filter(dataRefName::startsWith).findFirst().orElse(null); + } } diff --git a/customer-service/src/main/java/io/github/bsayli/customerservice/common/openapi/introspector/ResponseTypeIntrospector.java b/customer-service/src/main/java/io/github/bsayli/customerservice/common/openapi/introspector/ResponseTypeIntrospector.java index 1cae9d6..56dca0b 100644 --- a/customer-service/src/main/java/io/github/bsayli/customerservice/common/openapi/introspector/ResponseTypeIntrospector.java +++ b/customer-service/src/main/java/io/github/bsayli/customerservice/common/openapi/introspector/ResponseTypeIntrospector.java @@ -18,62 +18,60 @@ public final class ResponseTypeIntrospector { private static final Logger log = LoggerFactory.getLogger(ResponseTypeIntrospector.class); - private static final int MAX_UNWRAP_DEPTH = 8; - private static final Set REACTOR_WRAPPERS = Set.of("reactor.core.publisher.Mono", "reactor.core.publisher.Flux"); public Optional extractDataRefName(Method method) { if (method == null) return Optional.empty(); - ResolvableType type = ResolvableType.forMethodReturnType(method); - type = unwrapToServiceResponse(type); + ResolvableType t = ResolvableType.forMethodReturnType(method); + t = unwrapToServiceResponse(t); - Class raw = type.resolve(); + Class raw = t.resolve(); if (raw == null || !ServiceResponse.class.isAssignableFrom(raw)) return Optional.empty(); - if (!type.hasGenerics()) return Optional.empty(); + if (!t.hasGenerics()) return Optional.empty(); - Class dataClass = type.getGeneric(0).resolve(); - Optional ref = Optional.ofNullable(dataClass).map(Class::getSimpleName); + ResolvableType dataType = t.getGeneric(0); + String ref = buildRefName(dataType); if (log.isDebugEnabled()) { - log.debug( - "Introspected method [{}]: wrapper [{}], data [{}]", - method.toGenericString(), - raw.getSimpleName(), - ref.orElse("")); + log.debug("Introspected method [{}]: dataRef={}", method.toGenericString(), ref); } - - return ref; + return Optional.of(ref); } private ResolvableType unwrapToServiceResponse(ResolvableType type) { - for (int guard = 0; guard < MAX_UNWRAP_DEPTH; guard++) { + for (int i = 0; i < MAX_UNWRAP_DEPTH; i++) { Class raw = type.resolve(); - if (raw == null || ServiceResponse.class.isAssignableFrom(raw)) { - return type; - } + if (raw == null || ServiceResponse.class.isAssignableFrom(raw)) return type; ResolvableType next = nextLayer(type, raw); - if (next == null) { - return type; - } + if (next == null) return type; type = next; } return type; } private ResolvableType nextLayer(ResolvableType current, Class raw) { - return switch (raw) { - case Class c when ResponseEntity.class.isAssignableFrom(c) -> current.getGeneric(0); - case Class c - when CompletionStage.class.isAssignableFrom(c) || Future.class.isAssignableFrom(c) -> - current.getGeneric(0); - case Class c - when DeferredResult.class.isAssignableFrom(c) || WebAsyncTask.class.isAssignableFrom(c) -> - current.getGeneric(0); - case Class c when REACTOR_WRAPPERS.contains(c.getName()) -> current.getGeneric(0); - default -> null; - }; + if (ResponseEntity.class.isAssignableFrom(raw)) return current.getGeneric(0); + if (CompletionStage.class.isAssignableFrom(raw) || Future.class.isAssignableFrom(raw)) + return current.getGeneric(0); + if (DeferredResult.class.isAssignableFrom(raw) || WebAsyncTask.class.isAssignableFrom(raw)) + return current.getGeneric(0); + if (REACTOR_WRAPPERS.contains(raw.getName())) return current.getGeneric(0); + return null; + } + + private String buildRefName(ResolvableType type) { + Class raw = type.resolve(); + if (raw == null) return "Object"; + String base = raw.getSimpleName(); + if (!type.hasGenerics()) return base; + + StringBuilder sb = new StringBuilder(base); + for (ResolvableType g : type.getGenerics()) { + sb.append(buildRefName(g)); + } + return sb.toString(); } } diff --git a/customer-service/src/main/java/io/github/bsayli/customerservice/config/WebConfig.java b/customer-service/src/main/java/io/github/bsayli/customerservice/config/WebConfig.java new file mode 100644 index 0000000..d4fbb76 --- /dev/null +++ b/customer-service/src/main/java/io/github/bsayli/customerservice/config/WebConfig.java @@ -0,0 +1,16 @@ +package io.github.bsayli.customerservice.config; + +import io.github.bsayli.customerservice.common.api.sort.SortDirection; +import io.github.bsayli.customerservice.common.api.sort.SortField; +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, SortField.class, SortField::from); + registry.addConverter(String.class, SortDirection.class, SortDirection::from); + } +} diff --git a/customer-service/src/main/java/io/github/bsayli/customerservice/service/CustomerService.java b/customer-service/src/main/java/io/github/bsayli/customerservice/service/CustomerService.java index 76dd36b..e967426 100644 --- a/customer-service/src/main/java/io/github/bsayli/customerservice/service/CustomerService.java +++ b/customer-service/src/main/java/io/github/bsayli/customerservice/service/CustomerService.java @@ -2,15 +2,23 @@ import io.github.bsayli.customerservice.api.dto.CustomerCreateRequest; import io.github.bsayli.customerservice.api.dto.CustomerDto; +import io.github.bsayli.customerservice.api.dto.CustomerSearchCriteria; import io.github.bsayli.customerservice.api.dto.CustomerUpdateRequest; -import java.util.List; +import io.github.bsayli.customerservice.common.api.response.Page; +import io.github.bsayli.customerservice.common.api.sort.SortDirection; +import io.github.bsayli.customerservice.common.api.sort.SortField; public interface CustomerService { CustomerDto createCustomer(CustomerCreateRequest request); CustomerDto getCustomer(Integer customerId); - List getCustomers(); + Page getCustomers( + CustomerSearchCriteria criteria, + int page, + int size, + SortField sortBy, + SortDirection direction); CustomerDto updateCustomer(Integer customerId, CustomerUpdateRequest request); diff --git a/customer-service/src/main/java/io/github/bsayli/customerservice/service/impl/CustomerServiceImpl.java b/customer-service/src/main/java/io/github/bsayli/customerservice/service/impl/CustomerServiceImpl.java index 3f35693..777c128 100644 --- a/customer-service/src/main/java/io/github/bsayli/customerservice/service/impl/CustomerServiceImpl.java +++ b/customer-service/src/main/java/io/github/bsayli/customerservice/service/impl/CustomerServiceImpl.java @@ -2,13 +2,19 @@ import io.github.bsayli.customerservice.api.dto.CustomerCreateRequest; import io.github.bsayli.customerservice.api.dto.CustomerDto; +import io.github.bsayli.customerservice.api.dto.CustomerSearchCriteria; import io.github.bsayli.customerservice.api.dto.CustomerUpdateRequest; +import io.github.bsayli.customerservice.common.api.response.Page; +import io.github.bsayli.customerservice.common.api.sort.SortDirection; +import io.github.bsayli.customerservice.common.api.sort.SortField; import io.github.bsayli.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 @@ -22,6 +28,19 @@ public CustomerServiceImpl() { 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 @@ -40,8 +59,15 @@ public CustomerDto getCustomer(Integer customerId) { } @Override - public List getCustomers() { - return List.copyOf(store.values()); + 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 @@ -57,4 +83,42 @@ public CustomerDto updateCustomer(Integer customerId, CustomerUpdateRequest requ 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) { + long total = items.size(); + int from = Math.min(page * size, items.size()); + int to = Math.min(from + size, items.size()); + var slice = items.subList(from, to); + return Page.of(slice, page, size, total); + } } diff --git a/customer-service/src/main/resources/application.yml b/customer-service/src/main/resources/application.yml index 2dc2237..15894a3 100644 --- a/customer-service/src/main/resources/application.yml +++ b/customer-service/src/main/resources/application.yml @@ -9,6 +9,9 @@ server: include-exception: false spring: + mvc: + problemdetails: + enabled: true application: name: customer-service profiles: @@ -18,14 +21,20 @@ logging: level: root: INFO org.springframework.web: INFO - com.example.demo: DEBUG + io.github.bsayli: DEBUG app: openapi: version: @project.version@ base-url: "http://localhost:${server.port}${server.servlet.context-path:}" - #wrapper: - #class-extra-annotation: "@com.fasterxml.jackson.annotation.JsonIgnoreProperties(ignoreUnknown = true)" + wrapper: + # Optional: extra annotation for generated client models + # class-extra-annotation: "@com.fasterxml.jackson.annotation.JsonIgnoreProperties(ignoreUnknown = true)" + # Generic container types to auto-enrich (supports multiple) + generic-containers: + - Page + # - Slice + # - PagedResult springdoc: default-consumes-media-type: application/json diff --git a/customer-service/src/test/java/io/github/bsayli/customerservice/api/controller/CustomerControllerIT.java b/customer-service/src/test/java/io/github/bsayli/customerservice/api/controller/CustomerControllerIT.java index ada1a19..1529aa7 100644 --- a/customer-service/src/test/java/io/github/bsayli/customerservice/api/controller/CustomerControllerIT.java +++ b/customer-service/src/test/java/io/github/bsayli/customerservice/api/controller/CustomerControllerIT.java @@ -1,17 +1,18 @@ package io.github.bsayli.customerservice.api.controller; import static org.hamcrest.Matchers.endsWith; -import static org.mockito.ArgumentMatchers.any; +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.*; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; import com.fasterxml.jackson.databind.ObjectMapper; -import io.github.bsayli.customerservice.api.dto.CustomerCreateRequest; -import io.github.bsayli.customerservice.api.dto.CustomerDto; -import io.github.bsayli.customerservice.api.dto.CustomerUpdateRequest; -import io.github.bsayli.customerservice.api.error.CustomerControllerAdvice; +import io.github.bsayli.customerservice.api.dto.*; +import io.github.bsayli.customerservice.api.error.*; +import io.github.bsayli.customerservice.common.api.response.Page; +import io.github.bsayli.customerservice.common.api.sort.SortDirection; +import io.github.bsayli.customerservice.common.api.sort.SortField; import io.github.bsayli.customerservice.service.CustomerService; import io.github.bsayli.customerservice.testconfig.TestControllerMocksConfig; import java.util.List; @@ -28,17 +29,22 @@ import org.springframework.test.web.servlet.MockMvc; @WebMvcTest(controllers = CustomerController.class) -@Import({CustomerControllerAdvice.class, TestControllerMocksConfig.class}) +@Import({ + ValidationExceptionHandler.class, + JsonExceptionHandler.class, + SpringHttpExceptionHandler.class, + ApplicationExceptionHandler.class, + TestControllerMocksConfig.class +}) @Tag("integration") class CustomerControllerIT { @Autowired private MockMvc mvc; @Autowired private ObjectMapper om; - @Autowired private CustomerService customerService; @Test - @DisplayName("POST /v1/customers -> 201 Created, Location header ve ServiceResponse(CREATED)") + @DisplayName("POST /v1/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()); @@ -51,32 +57,39 @@ void createCustomer_created201_withLocation() throws Exception { .andExpect(status().isCreated()) .andExpect(header().string("Location", endsWith("/v1/customers/1"))) .andExpect(content().contentType(MediaType.APPLICATION_JSON)) - .andExpect(jsonPath("$.status").value(201)) - .andExpect(jsonPath("$.message").value("CREATED")) - .andExpect(jsonPath("$.data.customer.customerId").value(1)) - .andExpect(jsonPath("$.data.customer.name").value("John Smith")) - .andExpect(jsonPath("$.data.customer.email").value("john.smith@example.com")) - .andExpect(jsonPath("$.data.createdAt").exists()); + .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()); } @Test - @DisplayName("POST /v1/customers -> 400 Bad Request (validation hatası)") + @DisplayName("POST /v1/customers -> 400 Bad Request (validation failed)") void createCustomer_validation400() throws Exception { var badJson = """ {"name":"","email":"not-an-email"} """; + mvc.perform(post("/v1/customers").contentType(MediaType.APPLICATION_JSON).content(badJson)) .andExpect(status().isBadRequest()) - .andExpect(content().contentType(MediaType.APPLICATION_JSON)) - .andExpect(jsonPath("$.message").value("BAD_REQUEST")) - .andExpect(jsonPath("$.data.code").value("VALIDATION_FAILED")) - .andExpect(jsonPath("$.data.timestamp").exists()) - .andExpect(jsonPath("$.data.violations").isArray()); + .andExpect(content().contentType(MediaType.APPLICATION_PROBLEM_JSON)) + .andExpect(jsonPath("$.status").value(400)); + } + + @Test + @DisplayName("POST /v1/customers -> 400 Bad Request (malformed JSON)") + void createCustomer_badJson_notReadable() throws Exception { + var malformed = "{ \"name\": \"John\", \"email\": }"; // invalid JSON + + mvc.perform(post("/v1/customers").contentType(MediaType.APPLICATION_JSON).content(malformed)) + .andExpect(status().isBadRequest()) + .andExpect(content().contentType(MediaType.APPLICATION_PROBLEM_JSON)) + .andExpect(jsonPath("$.status").value(400)); } @Test - @DisplayName("GET /v1/customers/{id} -> 200 OK ve tek müşteri") + @DisplayName("GET /v1/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); @@ -84,10 +97,9 @@ void getCustomer_ok200() throws Exception { mvc.perform(get("/v1/customers/{id}", 1)) .andExpect(status().isOk()) .andExpect(content().contentType(MediaType.APPLICATION_JSON)) - .andExpect(jsonPath("$.status").value(200)) - .andExpect(jsonPath("$.message").value("OK")) .andExpect(jsonPath("$.data.customerId").value(1)) - .andExpect(jsonPath("$.data.name").value("John Smith")); + .andExpect(jsonPath("$.data.name").value("John Smith")) + .andExpect(jsonPath("$.meta.serverTime").exists()); } @Test @@ -98,10 +110,8 @@ void getCustomer_notFound404() throws Exception { mvc.perform(get("/v1/customers/{id}", 99)) .andExpect(status().isNotFound()) - .andExpect(content().contentType(MediaType.APPLICATION_JSON)) - .andExpect(jsonPath("$.message").value("NOT_FOUND")) - .andExpect(jsonPath("$.data.code").value("NOT_FOUND")) - .andExpect(jsonPath("$.data.message").value("Customer not found: 99")); + .andExpect(content().contentType(MediaType.APPLICATION_PROBLEM_JSON)) + .andExpect(jsonPath("$.status").value(404)); } @Test @@ -109,32 +119,61 @@ void getCustomer_notFound404() throws Exception { void getCustomer_typeMismatch400() throws Exception { mvc.perform(get("/v1/customers/{id}", "abc")) .andExpect(status().isBadRequest()) - .andExpect(content().contentType(MediaType.APPLICATION_JSON)) - .andExpect(jsonPath("$.message").value("BAD_REQUEST")) - .andExpect(jsonPath("$.data.code").value("VALIDATION_FAILED")) - .andExpect(jsonPath("$.data.violations[0].field").value("customerId")) - .andExpect(jsonPath("$.data.violations[0].message").exists()); + .andExpect(content().contentType(MediaType.APPLICATION_PROBLEM_JSON)) + .andExpect(jsonPath("$.status").value(400)); + } + + @Test + @DisplayName("GET /v1/customers/{id} -> 400 Bad Request (@Min violation)") + void getCustomer_constraintViolation_min() throws Exception { + mvc.perform(get("/v1/customers/{id}", 0)) + .andExpect(status().isBadRequest()) + .andExpect(content().contentType(MediaType.APPLICATION_PROBLEM_JSON)) + .andExpect(jsonPath("$.status").value(400)); + } + + @Test + @DisplayName("GET /v1/customers/{id} -> 500 Internal Server Error (generic)") + void getCustomer_internalServerError_generic() throws Exception { + when(customerService.getCustomer(1)).thenThrow(new RuntimeException("Boom")); + + mvc.perform(get("/v1/customers/{id}", 1)) + .andExpect(status().isInternalServerError()) + .andExpect(content().contentType(MediaType.APPLICATION_PROBLEM_JSON)) + .andExpect(jsonPath("$.status").value(500)); } @Test - @DisplayName("GET /v1/customers -> 200 OK ve LISTED mesajı") - void getCustomers_list200() throws Exception { + @DisplayName("GET /v1/customers -> 200 OK, Page + meta.sort") + void getCustomers_list200_paged() throws Exception { var d1 = new CustomerDto(1, "John Smith", "john.smith@example.com"); var d2 = new CustomerDto(2, "Ahmet Yilmaz", "ahmet.yilmaz@example.com"); - when(customerService.getCustomers()).thenReturn(List.of(d1, d2)); + 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("/v1/customers")) .andExpect(status().isOk()) .andExpect(content().contentType(MediaType.APPLICATION_JSON)) - .andExpect(jsonPath("$.status").value(200)) - .andExpect(jsonPath("$.message").value("LISTED")) - .andExpect(jsonPath("$.data.customers.length()").value(2)) - .andExpect(jsonPath("$.data.customers[0].customerId").value(1)) - .andExpect(jsonPath("$.data.customers[1].customerId").value(2)); + .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("PUT /v1/customers/{id} -> 200 OK ve UPDATED") + @DisplayName("PUT /v1/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()); @@ -146,60 +185,22 @@ void updateCustomer_ok200() throws Exception { .content(om.writeValueAsBytes(req))) .andExpect(status().isOk()) .andExpect(content().contentType(MediaType.APPLICATION_JSON)) - .andExpect(jsonPath("$.message").value("UPDATED")) - .andExpect(jsonPath("$.data.customer.customerId").value(1)) - .andExpect(jsonPath("$.data.updatedAt").exists()); + .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()); } @Test - @DisplayName("DELETE /v1/customers/{id} -> 200 OK ve DELETED") + @DisplayName("DELETE /v1/customers/{id} -> 200 OK (delete)") void deleteCustomer_ok200() throws Exception { doNothing().when(customerService).deleteCustomer(1); mvc.perform(delete("/v1/customers/{id}", 1)) .andExpect(status().isOk()) .andExpect(content().contentType(MediaType.APPLICATION_JSON)) - .andExpect(jsonPath("$.message").value("DELETED")) .andExpect(jsonPath("$.data.customerId").value(1)) - .andExpect(jsonPath("$.data.deletedAt").exists()); - } - - @Test - @DisplayName("POST /v1/customers -> 400 Bad Request when JSON is malformed") - void createCustomer_badJson_notReadable() throws Exception { - var malformed = "{ \"name\": \"John\", \"email\": }"; // invalid JSON - - mvc.perform(post("/v1/customers").contentType(MediaType.APPLICATION_JSON).content(malformed)) - .andExpect(status().isBadRequest()) - .andExpect(content().contentType(MediaType.APPLICATION_JSON)) - .andExpect(jsonPath("$.message").value("BAD_REQUEST")) - .andExpect(jsonPath("$.data.code").value("VALIDATION_FAILED")) - .andExpect(jsonPath("$.data.violations").isArray()); - } - - @Test - @DisplayName("GET /v1/customers/{id} -> 400 Bad Request on @Min violation (id=0)") - void getCustomer_constraintViolation_min() throws Exception { - mvc.perform(get("/v1/customers/{id}", 0)) // @Min(1) violated - .andExpect(status().isBadRequest()) - .andExpect(content().contentType(MediaType.APPLICATION_JSON)) - .andExpect(jsonPath("$.message").value("BAD_REQUEST")) - .andExpect(jsonPath("$.data.code").value("VALIDATION_FAILED")) - .andExpect(jsonPath("$.data.violations").isArray()) - .andExpect(jsonPath("$.data.violations[0].message").exists()); - } - - @Test - @DisplayName("GET /v1/customers/{id} -> 500 Internal Server Error handled by advice") - void getCustomer_internalServerError_generic() throws Exception { - when(customerService.getCustomer(1)).thenThrow(new RuntimeException("Boom")); - - mvc.perform(get("/v1/customers/{id}", 1)) - .andExpect(status().isInternalServerError()) - .andExpect(content().contentType(MediaType.APPLICATION_JSON)) - .andExpect(jsonPath("$.message").value("INTERNAL_ERROR")) - .andExpect(jsonPath("$.data.code").value("INTERNAL_ERROR")) - .andExpect(jsonPath("$.data.message").value("Boom")); + .andExpect(jsonPath("$.meta.serverTime").exists()); } @AfterEach diff --git a/customer-service/src/test/java/io/github/bsayli/customerservice/api/controller/CustomerControllerTest.java b/customer-service/src/test/java/io/github/bsayli/customerservice/api/controller/CustomerControllerTest.java index eab5c92..6e74709 100644 --- a/customer-service/src/test/java/io/github/bsayli/customerservice/api/controller/CustomerControllerTest.java +++ b/customer-service/src/test/java/io/github/bsayli/customerservice/api/controller/CustomerControllerTest.java @@ -1,13 +1,16 @@ package io.github.bsayli.customerservice.api.controller; -import static io.github.bsayli.customerservice.common.api.ApiConstants.Response.*; import static org.junit.jupiter.api.Assertions.*; import static org.mockito.Mockito.*; import io.github.bsayli.customerservice.api.dto.*; +import io.github.bsayli.customerservice.common.api.response.Meta; +import io.github.bsayli.customerservice.common.api.response.Page; import io.github.bsayli.customerservice.common.api.response.ServiceResponse; +import io.github.bsayli.customerservice.common.api.sort.Sort; +import io.github.bsayli.customerservice.common.api.sort.SortDirection; +import io.github.bsayli.customerservice.common.api.sort.SortField; import io.github.bsayli.customerservice.service.CustomerService; -import java.time.Instant; import java.util.List; import org.junit.jupiter.api.*; import org.junit.jupiter.api.extension.ExtendWith; @@ -49,87 +52,107 @@ void tearDown() { } @Test - @DisplayName("POST /v1/customers -> 201 CREATED + ApiResponse(CREATED)") + @DisplayName("POST /v1/customers -> 201 Created + ServiceResponse(data, meta)") void createCustomer_shouldReturnCreated() { var request = new CustomerCreateRequest("John Smith", "john.smith@example.com"); when(customerService.createCustomer(request)).thenReturn(dto1); - ResponseEntity> resp = - controller.createCustomer(request); + ResponseEntity> resp = controller.createCustomer(request); assertEquals(HttpStatus.CREATED, resp.getStatusCode()); assertNotNull(resp.getHeaders().getLocation(), "Location header should be set"); - assertNotNull(resp.getBody()); - assertEquals(CREATED, resp.getBody().message()); - assertNotNull(resp.getBody().data()); - assertEquals(dto1, resp.getBody().data().customer()); - assertNotNull(resp.getBody().data().createdAt()); + + var body = resp.getBody(); + assertNotNull(body); + assertEquals(dto1, body.data()); + assertNotNull(body.meta()); + assertNotNull(body.meta().serverTime()); + verify(customerService).createCustomer(request); } @Test - @DisplayName("GET /v1/customers/{id} -> 200 OK + ApiResponse.ok(dto)") + @DisplayName("GET /v1/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()); - assertNotNull(resp.getBody()); - assertNotNull(resp.getBody().data()); - assertEquals(dto1, resp.getBody().data()); + var body = resp.getBody(); + assertNotNull(body); + assertEquals(dto1, body.data()); + assertNotNull(body.meta()); verify(customerService).getCustomer(1); } @Test - @DisplayName("GET /v1/customers -> 200 OK + ApiResponse(LISTED)") - void getCustomers_shouldReturnListed() { - when(customerService.getCustomers()).thenReturn(List.of(dto1, dto2)); + @DisplayName("GET /v1/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(); + ResponseEntity>> resp = + controller.getCustomers(criteria, 0, 5, sortBy, direction); assertEquals(HttpStatus.OK, resp.getStatusCode()); - assertNotNull(resp.getBody()); - assertEquals(LISTED, resp.getBody().message()); - assertNotNull(resp.getBody().data()); - assertEquals(2, resp.getBody().data().customers().size()); - assertEquals(dto1, resp.getBody().data().customers().getFirst()); - verify(customerService).getCustomers(); + var body = resp.getBody(); + assertNotNull(body); + + // Page assertions + assertNotNull(body.data()); + assertEquals(2, body.data().content().size()); + assertEquals(0, body.data().page()); + assertEquals(5, body.data().size()); + assertEquals(2, body.data().totalElements()); + assertEquals(1, body.data().totalPages()); + + Meta meta = body.meta(); + assertNotNull(meta); + assertNotNull(meta.serverTime()); + assertNotNull(meta.sort()); + assertFalse(meta.sort().isEmpty()); + Sort s = meta.sort().getFirst(); + assertEquals(sortBy, s.field()); + assertEquals(direction, s.direction()); + + verify(customerService).getCustomers(criteria, 0, 5, sortBy, direction); } @Test - @DisplayName("PUT /v1/customers/{id} -> 200 OK + ApiResponse(UPDATED)") - void updateCustomer_shouldReturnUpdated() { + @DisplayName("PUT /v1/customers/{id} -> 200 OK + ServiceResponse(data)") + 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); + ResponseEntity> resp = controller.updateCustomer(1, req); assertEquals(HttpStatus.OK, resp.getStatusCode()); - assertNotNull(resp.getBody()); - assertEquals(UPDATED, resp.getBody().message()); - assertNotNull(resp.getBody().data()); - assertEquals(updated, resp.getBody().data().customer()); - assertNotNull(resp.getBody().data().updatedAt()); + var body = resp.getBody(); + assertNotNull(body); + assertEquals(updated, body.data()); + assertNotNull(body.meta()); verify(customerService).updateCustomer(1, req); } @Test - @DisplayName("DELETE /v1/customers/{id} -> 200 OK + ApiResponse(DELETED)") - void deleteCustomer_shouldReturnDeleted() { + @DisplayName("DELETE /v1/customers/{id} -> 200 OK + ServiceResponse(CustomerDeleteResponse)") + void deleteCustomer_shouldReturnOk() { doNothing().when(customerService).deleteCustomer(1); ResponseEntity> resp = controller.deleteCustomer(1); assertEquals(HttpStatus.OK, resp.getStatusCode()); - assertNotNull(resp.getBody()); - assertEquals(DELETED, resp.getBody().message()); - assertNotNull(resp.getBody().data()); - assertEquals(1, resp.getBody().data().customerId()); - assertNotNull(resp.getBody().data().deletedAt()); - assertFalse(resp.getBody().data().deletedAt().isAfter(Instant.now().plusSeconds(1))); + var body = resp.getBody(); + assertNotNull(body); + assertNotNull(body.data()); + assertEquals(1, body.data().customerId()); + assertNotNull(body.meta()); verify(customerService).deleteCustomer(1); } diff --git a/customer-service/src/test/java/io/github/bsayli/customerservice/api/dto/CustomerCreateResponseJsonTest.java b/customer-service/src/test/java/io/github/bsayli/customerservice/api/dto/CustomerCreateResponseJsonTest.java deleted file mode 100644 index 3a878fd..0000000 --- a/customer-service/src/test/java/io/github/bsayli/customerservice/api/dto/CustomerCreateResponseJsonTest.java +++ /dev/null @@ -1,33 +0,0 @@ -package io.github.bsayli.customerservice.api.dto; - -import static org.assertj.core.api.Assertions.assertThat; - -import com.fasterxml.jackson.databind.ObjectMapper; -import com.fasterxml.jackson.databind.SerializationFeature; -import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; -import java.time.Instant; -import org.junit.jupiter.api.*; - -@Tag("unit") -@DisplayName("DTO JSON: CustomerCreateResponse") -class CustomerCreateResponseJsonTest { - - private ObjectMapper om; - - @BeforeEach - void setUp() { - om = new ObjectMapper(); - om.registerModule(new JavaTimeModule()); - om.disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS); - } - - @Test - @DisplayName("createdAt should serialize as ISO-8601 string") - void createdAt_shouldBeIso8601() throws Exception { - var customer = new CustomerDto(10, "Jane", "jane@example.com"); - var resp = new CustomerCreateResponse(customer, Instant.parse("2025-01-01T12:34:56Z")); - - String json = om.writeValueAsString(resp); - assertThat(json).contains("\"createdAt\":\"2025-01-01T12:34:56Z\""); - } -} diff --git a/customer-service/src/test/java/io/github/bsayli/customerservice/common/api/response/PageTest.java b/customer-service/src/test/java/io/github/bsayli/customerservice/common/api/response/PageTest.java new file mode 100644 index 0000000..22c76c6 --- /dev/null +++ b/customer-service/src/test/java/io/github/bsayli/customerservice/common/api/response/PageTest.java @@ -0,0 +1,66 @@ +package io.github.bsayli.customerservice.common.api.response; + +import static org.junit.jupiter.api.Assertions.*; + +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/customer-service/src/test/java/io/github/bsayli/customerservice/common/openapi/GlobalErrorResponsesCustomizerTest.java b/customer-service/src/test/java/io/github/bsayli/customerservice/common/openapi/GlobalErrorResponsesCustomizerTest.java new file mode 100644 index 0000000..aaf6de6 --- /dev/null +++ b/customer-service/src/test/java/io/github/bsayli/customerservice/common/openapi/GlobalErrorResponsesCustomizerTest.java @@ -0,0 +1,124 @@ +package io.github.bsayli.customerservice.common.openapi; + +import static org.junit.jupiter.api.Assertions.*; + +import io.swagger.v3.oas.models.Components; +import io.swagger.v3.oas.models.OpenAPI; +import io.swagger.v3.oas.models.Operation; +import io.swagger.v3.oas.models.PathItem; +import io.swagger.v3.oas.models.Paths; +import io.swagger.v3.oas.models.media.Content; +import io.swagger.v3.oas.models.media.MediaType; +import io.swagger.v3.oas.models.media.Schema; +import io.swagger.v3.oas.models.responses.ApiResponses; +import java.util.LinkedHashMap; +import java.util.Map; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; +import org.springdoc.core.customizers.OpenApiCustomizer; + +@Tag("unit") +@DisplayName("Unit Test: GlobalErrorResponsesCustomizer") +class GlobalErrorResponsesCustomizerTest { + + private static final String MT_PROBLEM_JSON = "application/problem+json"; + private static final String REF_PREFIX = "#/components/schemas/"; + + @Test + @DisplayName("Adds ProblemDetail and ErrorItem schemas when missing") + void addsSchemas_whenMissing() { + OpenApiCustomizer customizer = + new GlobalErrorResponsesCustomizer().addDefaultProblemResponses(); + + var openAPI = + new OpenAPI() + .components(new Components().schemas(new LinkedHashMap<>())) + .paths(minimalPathWithOperation()); + + customizer.customise(openAPI); + + Map schemas = openAPI.getComponents().getSchemas(); + assertNotNull(schemas, "schemas map should exist"); + assertTrue(schemas.containsKey(OpenApiSchemas.SCHEMA_PROBLEM_DETAIL), "ProblemDetail schema"); + assertTrue(schemas.containsKey("ErrorItem"), "ErrorItem schema"); + } + + @Test + @DisplayName( + "Registers default problem responses (400, 404, 405, 500) with application/problem+json") + void registersDefaultProblemResponses() { + OpenApiCustomizer customizer = + new GlobalErrorResponsesCustomizer().addDefaultProblemResponses(); + + var openAPI = + new OpenAPI() + .components(new Components().schemas(new LinkedHashMap<>())) + .paths(minimalPathWithOperation()); + + customizer.customise(openAPI); + + var op = + openAPI.getPaths().values().iterator().next().readOperations().stream() + .findFirst() + .orElseThrow(); + var responses = op.getResponses(); + + for (String code : new String[] {"400", "404", "405", "500"}) { + assertTrue(responses.containsKey(code), "response " + code + " should be present"); + Content content = responses.get(code).getContent(); + assertNotNull(content, "content must exist for " + code); + MediaType mt = content.get(MT_PROBLEM_JSON); + assertNotNull(mt, "media type should be " + MT_PROBLEM_JSON + " for " + code); + Schema schema = mt.getSchema(); + assertNotNull(schema, "schema must exist for " + code); + assertEquals( + REF_PREFIX + OpenApiSchemas.SCHEMA_PROBLEM_DETAIL, + schema.get$ref(), + "schema ref must target ProblemDetail for " + code); + } + } + + @Test + @DisplayName("No-op when components is null (does not throw)") + void noOp_whenComponentsNull() { + OpenApiCustomizer customizer = + new GlobalErrorResponsesCustomizer().addDefaultProblemResponses(); + + var openAPI = new OpenAPI(); // components == null + assertDoesNotThrow(() -> customizer.customise(openAPI)); + } + + @Test + @DisplayName("Idempotent customization (second run does not break or duplicate)") + void idempotentCustomization() { + OpenApiCustomizer customizer = + new GlobalErrorResponsesCustomizer().addDefaultProblemResponses(); + + var openAPI = + new OpenAPI() + .components(new Components().schemas(new LinkedHashMap<>())) + .paths(minimalPathWithOperation()); + + customizer.customise(openAPI); + customizer.customise(openAPI); // run twice + + var schemas = openAPI.getComponents().getSchemas(); + assertTrue(schemas.containsKey(OpenApiSchemas.SCHEMA_PROBLEM_DETAIL)); + assertTrue(schemas.containsKey("ErrorItem")); + + var op = + openAPI.getPaths().values().iterator().next().readOperations().stream() + .findFirst() + .orElseThrow(); + var responses = op.getResponses(); + + assertEquals(4, responses.size(), "should still have exactly 4 default responses"); + } + + private Paths minimalPathWithOperation() { + var op = new Operation().responses(new ApiResponses()); + var pi = new PathItem().get(op); + return new Paths().addPathItem("/test", pi); + } +} diff --git a/customer-service/src/test/java/io/github/bsayli/customerservice/common/openapi/SwaggerResponseCustomizerTest.java b/customer-service/src/test/java/io/github/bsayli/customerservice/common/openapi/SwaggerResponseCustomizerTest.java index 2b202c8..a245f9e 100644 --- a/customer-service/src/test/java/io/github/bsayli/customerservice/common/openapi/SwaggerResponseCustomizerTest.java +++ b/customer-service/src/test/java/io/github/bsayli/customerservice/common/openapi/SwaggerResponseCustomizerTest.java @@ -4,8 +4,9 @@ import io.swagger.v3.oas.models.Components; import io.swagger.v3.oas.models.OpenAPI; +import io.swagger.v3.oas.models.media.ArraySchema; import io.swagger.v3.oas.models.media.Schema; -import java.util.HashMap; +import java.util.LinkedHashMap; import java.util.Map; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Tag; @@ -16,34 +17,115 @@ @DisplayName("Unit Test: SwaggerResponseCustomizer") class SwaggerResponseCustomizerTest { - private final SwaggerResponseCustomizer config = new SwaggerResponseCustomizer(); - private final OpenApiCustomizer customizer = config.responseEnvelopeSchemas(); + private static final String REF_PREFIX = "#/components/schemas/"; @Test - @DisplayName("Should add missing schemas when absent") - void shouldAddSchemasWhenAbsent() { - OpenAPI openAPI = new OpenAPI().components(new Components().schemas(new HashMap<>())); + @DisplayName("Creates Sort, Meta, ServiceResponse and ServiceResponseVoid schemas when missing") + void createsEnvelopeSchemas_whenMissing() { + OpenApiCustomizer customizer = new SwaggerResponseCustomizer().responseEnvelopeSchemas(); + var openAPI = new OpenAPI().components(new Components()); customizer.customise(openAPI); - @SuppressWarnings("rawtypes") Map schemas = openAPI.getComponents().getSchemas(); - assertNotNull(schemas.get(OpenApiSchemas.SCHEMA_SERVICE_RESPONSE)); - assertNotNull(schemas.get(OpenApiSchemas.SCHEMA_SERVICE_RESPONSE_VOID)); + assertNotNull(schemas); + assertTrue(schemas.containsKey(OpenApiSchemas.SCHEMA_SORT)); + assertTrue(schemas.containsKey(OpenApiSchemas.SCHEMA_META)); + assertTrue(schemas.containsKey(OpenApiSchemas.SCHEMA_SERVICE_RESPONSE)); + assertTrue(schemas.containsKey(OpenApiSchemas.SCHEMA_SERVICE_RESPONSE_VOID)); } @Test - @DisplayName("Should not override existing schemas") - void shouldNotOverrideExistingSchemas() { - Schema existing = new Schema<>().description("pre-existing"); - Components components = new Components().schemas(new HashMap<>()); - components.addSchemas(OpenApiSchemas.SCHEMA_SERVICE_RESPONSE, existing); - OpenAPI openAPI = new OpenAPI().components(components); + @DisplayName("Meta schema has serverTime, and sort[] referencing Sort") + void metaSchema_structure_isCorrect() { + OpenApiCustomizer customizer = new SwaggerResponseCustomizer().responseEnvelopeSchemas(); + var openAPI = new OpenAPI().components(new Components()); customizer.customise(openAPI); - Schema result = - openAPI.getComponents().getSchemas().get(OpenApiSchemas.SCHEMA_SERVICE_RESPONSE); - assertSame(existing, result, "Existing schema should remain unchanged"); + var meta = openAPI.getComponents().getSchemas().get(OpenApiSchemas.SCHEMA_META); + assertNotNull(meta); + assertNotNull(meta.getProperties()); + assertTrue(meta.getProperties().containsKey("serverTime")); + assertTrue(meta.getProperties().containsKey("sort")); + + var sortProp = meta.getProperties().get("sort"); + assertInstanceOf(ArraySchema.class, sortProp); + var array = (ArraySchema) sortProp; + assertNotNull(array.getItems()); + assertEquals(REF_PREFIX + OpenApiSchemas.SCHEMA_SORT, array.getItems().get$ref()); + } + + @Test + @DisplayName("ServiceResponse schema has 'data' (object) and 'meta' ($ref Meta)") + void serviceResponseSchema_structure_isCorrect() { + OpenApiCustomizer customizer = new SwaggerResponseCustomizer().responseEnvelopeSchemas(); + + var openAPI = new OpenAPI().components(new Components()); + customizer.customise(openAPI); + + var sr = openAPI.getComponents().getSchemas().get(OpenApiSchemas.SCHEMA_SERVICE_RESPONSE); + assertNotNull(sr); + assertNotNull(sr.getProperties()); + assertTrue(sr.getProperties().containsKey(OpenApiSchemas.PROP_DATA)); + assertTrue(sr.getProperties().containsKey(OpenApiSchemas.PROP_META)); + + var metaProp = (Schema) sr.getProperties().get(OpenApiSchemas.PROP_META); + assertEquals(REF_PREFIX + OpenApiSchemas.SCHEMA_META, metaProp.get$ref()); + } + + @Test + @DisplayName("ServiceResponseVoid schema mirrors ServiceResponse structure") + void serviceResponseVoidSchema_structure_isCorrect() { + OpenApiCustomizer customizer = new SwaggerResponseCustomizer().responseEnvelopeSchemas(); + + var openAPI = new OpenAPI().components(new Components()); + customizer.customise(openAPI); + + var srv = openAPI.getComponents().getSchemas().get(OpenApiSchemas.SCHEMA_SERVICE_RESPONSE_VOID); + assertNotNull(srv); + assertNotNull(srv.getProperties()); + assertTrue(srv.getProperties().containsKey(OpenApiSchemas.PROP_DATA)); + assertTrue(srv.getProperties().containsKey(OpenApiSchemas.PROP_META)); + + var metaProp = (Schema) srv.getProperties().get(OpenApiSchemas.PROP_META); + assertEquals(REF_PREFIX + OpenApiSchemas.SCHEMA_META, metaProp.get$ref()); + } + + @Test + @DisplayName("Idempotent: running the customizer twice doesn't duplicate or error") + void idempotentCustomization() { + OpenApiCustomizer customizer = new SwaggerResponseCustomizer().responseEnvelopeSchemas(); + + var openAPI = new OpenAPI().components(new Components()); + customizer.customise(openAPI); + customizer.customise(openAPI); + + var schemas = openAPI.getComponents().getSchemas(); + assertNotNull(schemas); + assertTrue(schemas.containsKey(OpenApiSchemas.SCHEMA_SORT)); + assertTrue(schemas.containsKey(OpenApiSchemas.SCHEMA_META)); + assertTrue(schemas.containsKey(OpenApiSchemas.SCHEMA_SERVICE_RESPONSE)); + assertTrue(schemas.containsKey(OpenApiSchemas.SCHEMA_SERVICE_RESPONSE_VOID)); + } + + @Test + @DisplayName("Uses existing schemas map if present (does not overwrite)") + void respectsExistingSchemasMap() { + OpenApiCustomizer customizer = new SwaggerResponseCustomizer().responseEnvelopeSchemas(); + + var preExisting = new LinkedHashMap(); + preExisting.put("PreExisting", new Schema<>()); + var openAPI = new OpenAPI().components(new Components().schemas(preExisting)); + + customizer.customise(openAPI); + + var schemas = openAPI.getComponents().getSchemas(); + assertSame(preExisting, schemas); + assertTrue(schemas.containsKey("PreExisting")); + assertTrue(schemas.containsKey(OpenApiSchemas.SCHEMA_SORT)); + assertTrue(schemas.containsKey(OpenApiSchemas.SCHEMA_META)); + assertTrue(schemas.containsKey(OpenApiSchemas.SCHEMA_SERVICE_RESPONSE)); + assertTrue(schemas.containsKey(OpenApiSchemas.SCHEMA_SERVICE_RESPONSE_VOID)); } } diff --git a/customer-service/src/test/java/io/github/bsayli/customerservice/common/openapi/autoreg/AutoWrapperSchemaCustomizerTest.java b/customer-service/src/test/java/io/github/bsayli/customerservice/common/openapi/autoreg/AutoWrapperSchemaCustomizerTest.java index 34ee477..56b040c 100644 --- a/customer-service/src/test/java/io/github/bsayli/customerservice/common/openapi/autoreg/AutoWrapperSchemaCustomizerTest.java +++ b/customer-service/src/test/java/io/github/bsayli/customerservice/common/openapi/autoreg/AutoWrapperSchemaCustomizerTest.java @@ -54,7 +54,7 @@ void registersSchemas_forDiscoveredRefs() throws Exception { }; }); - var customizerCfg = new AutoWrapperSchemaCustomizer(beanFactory, introspector, null); + var customizerCfg = new AutoWrapperSchemaCustomizer(beanFactory, introspector, null, "Page"); OpenApiCustomizer customizer = customizerCfg.autoResponseWrappers(); var openAPI = new OpenAPI().components(new Components()); @@ -79,7 +79,7 @@ void noRefs_noSchemasAdded() { when(beanFactory.getBeansOfType(RequestMappingHandlerMapping.class)).thenReturn(Map.of()); - var customizerCfg = new AutoWrapperSchemaCustomizer(beanFactory, introspector, null); + var customizerCfg = new AutoWrapperSchemaCustomizer(beanFactory, introspector, null, "Page"); OpenApiCustomizer customizer = customizerCfg.autoResponseWrappers(); var openAPI = new OpenAPI().components(new Components()); @@ -113,7 +113,8 @@ void addsClassExtraAnnotation_whenConfigured() throws Exception { String classExtraAnn = "@com.fasterxml.jackson.annotation.JsonIgnoreProperties(ignoreUnknown = true)"; - var customizerCfg = new AutoWrapperSchemaCustomizer(beanFactory, introspector, classExtraAnn); + var customizerCfg = + new AutoWrapperSchemaCustomizer(beanFactory, introspector, classExtraAnn, "Page"); OpenApiCustomizer customizer = customizerCfg.autoResponseWrappers(); var openAPI = new OpenAPI().components(new Components()); @@ -147,7 +148,7 @@ void doesNotAddClassExtraAnnotation_whenBlank() throws Exception { .thenReturn(Map.of("rmh", handlerMapping)); when(introspector.extractDataRefName(any(Method.class))).thenReturn(Optional.of("FooRef")); - var customizerCfg = new AutoWrapperSchemaCustomizer(beanFactory, introspector, " "); + var customizerCfg = new AutoWrapperSchemaCustomizer(beanFactory, introspector, " ", "Page"); OpenApiCustomizer customizer = customizerCfg.autoResponseWrappers(); var openAPI = new OpenAPI().components(new Components()); diff --git a/customer-service/src/test/java/io/github/bsayli/customerservice/common/openapi/introspector/ResponseTypeIntrospectorTest.java b/customer-service/src/test/java/io/github/bsayli/customerservice/common/openapi/introspector/ResponseTypeIntrospectorTest.java index 44abbe2..4d109d1 100644 --- a/customer-service/src/test/java/io/github/bsayli/customerservice/common/openapi/introspector/ResponseTypeIntrospectorTest.java +++ b/customer-service/src/test/java/io/github/bsayli/customerservice/common/openapi/introspector/ResponseTypeIntrospectorTest.java @@ -70,12 +70,6 @@ void deepNesting_unwraps() throws Exception { assertEquals(Optional.of("Bar"), introspector.extractDataRefName(method("deepNesting"))); } - @Test - @DisplayName("Returns empty for raw ServiceResponse (no generic parameter)") - void rawServiceResponse_empty() throws Exception { - assertTrue(introspector.extractDataRefName(method("rawServiceResponse")).isEmpty()); - } - @Test @DisplayName("Returns empty when return type is not a wrapper") void notAWrapper_empty() throws Exception { diff --git a/customer-service/src/test/java/io/github/bsayli/customerservice/service/impl/CustomerServiceImplTest.java b/customer-service/src/test/java/io/github/bsayli/customerservice/service/impl/CustomerServiceImplTest.java index f1ad4ae..80fc6db 100644 --- a/customer-service/src/test/java/io/github/bsayli/customerservice/service/impl/CustomerServiceImplTest.java +++ b/customer-service/src/test/java/io/github/bsayli/customerservice/service/impl/CustomerServiceImplTest.java @@ -4,9 +4,12 @@ import io.github.bsayli.customerservice.api.dto.CustomerCreateRequest; import io.github.bsayli.customerservice.api.dto.CustomerDto; +import io.github.bsayli.customerservice.api.dto.CustomerSearchCriteria; import io.github.bsayli.customerservice.api.dto.CustomerUpdateRequest; +import io.github.bsayli.customerservice.common.api.response.Page; +import io.github.bsayli.customerservice.common.api.sort.SortDirection; +import io.github.bsayli.customerservice.common.api.sort.SortField; import io.github.bsayli.customerservice.service.CustomerService; -import java.util.List; import java.util.NoSuchElementException; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; @@ -25,11 +28,19 @@ void setUp() { } @Test - @DisplayName("Initial seed should contain 4 customers") - void initialSeed_shouldContainFour() { - List all = service.getCustomers(); - assertEquals(4, all.size()); - assertTrue(all.stream().allMatch(c -> c.customerId() != null && c.customerId() > 0)); + @DisplayName("Initial seed should contain 17 customers") + void initialSeed_shouldContainSeventeen() { + Page page = + service.getCustomers( + new CustomerSearchCriteria(null, null), + 0, + 100, + SortField.CUSTOMER_ID, + SortDirection.ASC); + + assertEquals(17, page.totalElements()); + assertEquals(17, page.content().size()); + assertTrue(page.content().stream().allMatch(c -> c.customerId() != null && c.customerId() > 0)); } @Test @@ -43,15 +54,26 @@ void createCustomer_shouldAssignIdAndStore() { assertEquals("Jane Doe", created.name()); assertEquals("jane.doe@example.com", created.email()); - List all = service.getCustomers(); - assertEquals(5, all.size()); - assertTrue(all.stream().anyMatch(c -> c.customerId().equals(created.customerId()))); + Page page = + service.getCustomers( + new CustomerSearchCriteria(null, null), + 0, + 200, + SortField.CUSTOMER_ID, + SortDirection.ASC); + + assertEquals(18, page.totalElements()); + assertTrue(page.content().stream().anyMatch(c -> c.customerId().equals(created.customerId()))); } @Test @DisplayName("getCustomer should return existing customer") void getCustomer_shouldReturn() { - CustomerDto any = service.getCustomers().getFirst(); + Page page = + service.getCustomers( + new CustomerSearchCriteria(null, null), 0, 1, SortField.CUSTOMER_ID, SortDirection.ASC); + + CustomerDto any = page.content().getFirst(); CustomerDto found = service.getCustomer(any.customerId()); assertEquals(any, found); } @@ -88,12 +110,27 @@ void updateCustomer_shouldThrowWhenMissing() { void deleteCustomer_shouldRemove() { CustomerDto base = service.createCustomer(new CustomerCreateRequest("Mark Lee", "mark.lee@example.com")); - int sizeBefore = service.getCustomers().size(); + + Page before = + service.getCustomers( + new CustomerSearchCriteria(null, null), + 0, + 200, + SortField.CUSTOMER_ID, + SortDirection.ASC); + long sizeBefore = before.totalElements(); service.deleteCustomer(base.customerId()); - List after = service.getCustomers(); - assertEquals(sizeBefore - 1, after.size()); - assertTrue(after.stream().noneMatch(c -> c.customerId().equals(base.customerId()))); + Page after = + service.getCustomers( + new CustomerSearchCriteria(null, null), + 0, + 200, + SortField.CUSTOMER_ID, + SortDirection.ASC); + + assertEquals(sizeBefore - 1, after.totalElements()); + assertTrue(after.content().stream().noneMatch(c -> c.customerId().equals(base.customerId()))); } } diff --git a/docs/adoption/client-side-adoption-pom.md b/docs/adoption/client-side-adoption-pom.md index f5ded51..c6abaae 100644 --- a/docs/adoption/client-side-adoption-pom.md +++ b/docs/adoption/client-side-adoption-pom.md @@ -1,19 +1,19 @@ --- + layout: default -title: Client-Side Build Setup +title: Client-Side Build Setup (Maven Plugins & Dependencies) parent: Client-Side Adoption nav_order: 1 ---- +------------ -# Client-Side Build Setup (Maven Plugins & Dependencies) +# Client-Side Build Setup — Maven Plugins & Dependencies -When adopting the **generics-aware OpenAPI client**, make sure to configure your `pom.xml` with the required plugins and -dependencies. These ensure that template overlays are applied correctly and generated sources are compiled into your -project. +This guide ensures your client module is correctly configured to generate **type‑safe, generics‑aware OpenAPI clients** +using custom Mustache templates. It aligns your build pipeline with modern OpenAPI Generator practices. --- -## 1) Core Dependencies +## ⚙️ 1. Core Dependencies Add these dependencies to your client module: @@ -26,11 +26,13 @@ Add these dependencies to your client module: spring-boot-starter-web provided + jakarta.validation jakarta.validation-api provided + jakarta.annotation jakarta.annotation-api @@ -61,10 +63,9 @@ Add these dependencies to your client module: --- -## 2) Maven Properties +## 🧩 2. Maven Properties -Add these properties at the top level of your `pom.xml` (right under ``), so that plugin versions and template -paths are resolved correctly: +Define reusable properties to simplify plugin management and template resolution. ```xml @@ -77,16 +78,15 @@ paths are resolved correctly: --- -## 3) Maven Plugins +## 🏗️ 3. Maven Plugins — Full Build Pipeline -These plugins **work together** to unpack upstream templates, overlay your custom Mustache files, and generate type-safe -client code. +These plugins work in sequence to **unpack, overlay, and compile** OpenAPI templates. ```xml - + org.apache.maven.plugins maven-dependency-plugin @@ -113,7 +113,7 @@ client code. - + org.apache.maven.plugins maven-resources-plugin @@ -158,7 +158,7 @@ client code. - + org.openapitools openapi-generator-maven-plugin @@ -196,14 +196,14 @@ client code. - commonPackage=your.base.openapi.client.common + commonPackage=your.base.openapi.client.common - + org.codehaus.mojo build-helper-maven-plugin @@ -228,12 +228,22 @@ client code. --- -## 4) Why These Plugins Matter +## 🧠 4. Why These Plugins Matter + +| Plugin | Purpose | +|------------------------------------|------------------------------------------------------------------| +| **maven-dependency-plugin** | Unpacks built-in OpenAPI templates from the generator JAR. | +| **maven-resources-plugin** | Overlays your local Mustache templates on top of upstream ones. | +| **openapi-generator-maven-plugin** | Generates type-safe client code using the effective templates. | +| **build-helper-maven-plugin** | Ensures generated sources are included in the compilation phase. | + +Together, these guarantee your **generics‑aware response wrappers** (e.g., `ServiceClientResponse`) are generated +cleanly and consistently across builds. + +--- -* **maven-dependency-plugin** → unpacks stock OpenAPI templates. -* **maven-resources-plugin** → overlays your custom Mustache files. -* **openapi-generator-maven-plugin** → generates the client code. -* **build-helper-maven-plugin** → makes sure generated code is compiled. +✅ With this setup, your client build will always: -Together, these steps ensure **your generics-aware wrappers** are generated correctly and seamlessly integrated into the -build. +* Resolve templates dynamically from the current OpenAPI Generator version. +* Apply your overlay Mustache templates automatically. +* Generate **RFC 7807‑aware**, `data + meta` aligned clients ready for production use. diff --git a/docs/adoption/client-side-adoption.md b/docs/adoption/client-side-adoption.md index 9e9d9a0..e5aeb7a 100644 --- a/docs/adoption/client-side-adoption.md +++ b/docs/adoption/client-side-adoption.md @@ -1,129 +1,218 @@ --- + layout: default -title: Client-Side Adoption +title: Client-Side Adoption (Updated for data+meta & ProblemDetail) parent: Adoption Guides nav_order: 2 ---- +------------ + +# Client‑Side Integration Guide (Updated) -# Client-Side Integration Guide +This guide describes how to integrate the **generics‑aware OpenAPI client** into your own project, aligned with the +new `{ data, meta }` response structure and RFC 7807 `ProblemDetail` error model introduced in the updated +`customer-service` / `customer-service-client` architecture. -This document describes how to integrate the **generics-aware OpenAPI client** into your own microservice project, based -on the patterns demonstrated in the `customer-service-client` module. +--- + +## 🎯 Goals -The purpose is to generate **type-safe clients** that extend a reusable generic base (`ServiceClientResponse`) -instead of duplicating response envelopes. +* Generate thin, **type‑safe wrappers** extending `ServiceClientResponse` instead of duplicating envelopes. +* Support **nested generics** such as `ServiceClientResponse>`. +* Decode non‑2xx responses into **RFC 7807 ProblemDetail** and raise `ClientProblemException`. +* Allow seamless injection into Spring Boot apps using a pooled `RestClient`. --- -## 1) What You Get +## ⚙️ What You Get -* Thin wrappers per endpoint (`ServiceResponseYourDto`), each extending `ServiceClientResponse`. -* Strong typing for `getData()` without boilerplate or casting. -* A reusable adapter interface to keep generated code isolated. -* Optional Spring Boot configuration to auto-wire the client beans. +* **Generated wrappers** per endpoint (e.g., `ServiceResponseCustomerDto`) extending `ServiceClientResponse`. +* **Strong typing** for `.getData()` and `.getMeta()`. +* **Problem‑aware exception** decoding (`ClientProblemException`). +* **Spring configuration** using `RestClientCustomizer` + Apache HttpClient5. --- -## 2) Prerequisites +## 🧱 Prerequisites * Java 21+ -* Maven 3.9+ (or Gradle 8+ if adapted) -* Running service exposing `/v3/api-docs.yaml` +* Maven 3.9+ +* A running OpenAPI provider exposing `/v3/api-docs.yaml` (from your server‑side service) --- -## 3) Steps to Generate +## 🚀 Generate the Client -1. **Pull the OpenAPI spec** from your service: +1. **Download the OpenAPI spec:** ```bash - curl -s http://localhost:8084/your-service/v3/api-docs.yaml \ - -o src/main/resources/your-api-docs.yaml + curl -s http://localhost:8084/customer-service/v3/api-docs.yaml \ + -o src/main/resources/customer-api-docs.yaml ``` -2. **Run Maven build** in the client module: +2. **Build the client:** ```bash mvn clean install ``` -3. **Inspect generated code**: +3. **Inspect generated output:** * `target/generated-sources/openapi/src/gen/java` - * Look for classes like `ServiceResponseYourDto` extending `ServiceClientResponse` + * Look for classes like `ServiceResponseCustomerDto` extending `ServiceClientResponse` --- -## 4) Core Classes to Copy +## 🧩 Core Classes (Shared Base) -Ensure you copy the following **shared classes** into your client project: +Copy these into your client module under `openapi/client/common`: -**`common/ServiceClientResponse.java`** +### `ServiceClientResponse.java` ```java package .openapi.client.common; -import java.util.List; import java.util.Objects; public class ServiceClientResponse { - private Integer status; - private String message; - private List errors; private T data; - // getters, setters, equals, hashCode, toString + private ClientMeta meta; + + public T getData() { + return data; + } + + public void setData(T data) { + this.data = data; + } + + public ClientMeta getMeta() { + return meta; + } + + public void setMeta(ClientMeta meta) { + this.meta = meta; + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (!(o instanceof ServiceClientResponse that)) return false; + return Objects.equals(data, that.data) && Objects.equals(meta, that.meta); + } + + @Override + public int hashCode() { + return Objects.hash(data, meta); + } + + @Override + public String toString() { + return "ServiceClientResponse{" + "data=" + data + ", meta=" + meta + '}'; + } } ``` -**`common/ClientErrorDetail.java`** +### `ClientMeta.java` ```java -package .openapi.client.common; +package + +.openapi.client.common; -public record ClientErrorDetail(String errorCode, String message) { +import java.time.Instant; +import java.util.List; + +import .openapi.client.common.sort.ClientSort; + +public record ClientMeta(Instant serverTime, List sort) { } ``` -These are referenced by the Mustache templates and must exist in your client project. +### `Page.java` ---- +```java +package -## 5) Mustache Templates +.openapi.client.common; -Place the following templates under: +import java.util.List; +public record Page(List content, int page, int size, long totalElements, + int totalPages, boolean hasNext, boolean hasPrev) { +} ``` -src/main/resources/openapi-templates/ + +### `ClientProblemException.java` + +```java +package + +.openapi.client.common.error; + +import io.github.bsayli.openapi.client.generated.dto.ProblemDetail; + +public class ClientProblemException extends RuntimeException { + private final transient ProblemDetail problem; + private final int status; + + public ClientProblemException(ProblemDetail problem, int status) { + super(problem != null ? problem.getTitle() + ": " + problem.getDetail() : "HTTP " + status); + this.problem = problem; + this.status = status; + } + + public ProblemDetail getProblem() { + return problem; + } + + public int getStatus() { + return status; + } +} ``` -**`api_wrapper.mustache`** +--- + +## 🧰 Mustache Template Overlay + +Place templates under `src/main/resources/openapi-templates/`. + +### `api_wrapper.mustache` {% raw %} ```mustache import {{commonPackage}}.ServiceClientResponse; +{{#vendorExtensions.x-data-container}} +import {{commonPackage}}.{{vendorExtensions.x-data-container}}; +{{/vendorExtensions.x-data-container}} {{#vendorExtensions.x-class-extra-annotation}} {{{vendorExtensions.x-class-extra-annotation}}} {{/vendorExtensions.x-class-extra-annotation}} public class {{classname}} - extends ServiceClientResponse<{{vendorExtensions.x-api-wrapper-datatype}}> { + extends ServiceClientResponse< + {{#vendorExtensions.x-data-container}} + {{vendorExtensions.x-data-container}}<{{vendorExtensions.x-data-item}}> + {{/vendorExtensions.x-data-container}} + {{^vendorExtensions.x-data-container}} + {{vendorExtensions.x-api-wrapper-datatype}} + {{/vendorExtensions.x-data-container}} + > { } ``` {% endraw %} -**`model.mustache`** (partial overlay to delegate wrapper classes to `api_wrapper.mustache`). - -These ensure generated wrappers extend the generic base instead of duplicating fields. +This ensures wrappers extend the generic base, including nested containers. --- -## 6) Adapter Pattern +## 🧩 Adapter Pattern (Recommended) -Encapsulate generated APIs in an adapter interface: +Encapsulate generated APIs behind your own adapter interface. ```java package @@ -131,125 +220,183 @@ package .openapi.client.adapter; import .openapi.client.common.ServiceClientResponse; -import .openapi.client.generated.dto.*; +import .openapi.client.common.Page; +import .openapi.client.generated.api.YourControllerApi; +import .openapi.client.generated.dto .*; public interface YourClientAdapter { ServiceClientResponse getYourEntity(Integer id); - ServiceClientResponse createYourEntity(YourCreateRequest request); + ServiceClientResponse> listEntities(); + + ServiceClientResponse createEntity(YourCreateRequest req); } ``` -This shields your business code from generated artifacts and provides a stable contract. +Then implement it using the generated API: + +```java + +@Service +public class YourClientAdapterImpl implements YourClientAdapter { + private final YourControllerApi api; + + public YourClientAdapterImpl(YourControllerApi api) { + this.api = api; + } + + @Override + public ServiceClientResponse getYourEntity(Integer id) { + return api.getYourEntity(id); + } + + @Override + public ServiceClientResponse> listEntities() { + return api.getEntities(); + } + + @Override + public ServiceClientResponse createEntity(YourCreateRequest req) { + return api.createEntity(req); + } +} +``` --- -## 7) Spring Boot Configuration — Quick Start (for demos/dev) +## 🧠 Spring Boot Configuration -For quick experiments or local development, you can wire the generated client with a simple RestClient: +Production‑ready configuration using pooled Apache HttpClient5 and `RestClientCustomizer`. ```java + @Configuration public class YourApiClientConfig { @Bean - RestClient yourRestClient(RestClient.Builder builder) { + RestClientCustomizer problemDetailStatusHandler(ObjectMapper om) { + return builder -> builder.defaultStatusHandler( + HttpStatusCode::isError, + (req, res) -> { + ProblemDetail pd = null; + try (var is = res.getBody()) { + if (is != null) pd = om.readValue(is, ProblemDetail.class); + } catch (Exception ignore) { + } + throw new ClientProblemException(pd, res.getStatusCode().value()); + }); + } + + @Bean(destroyMethod = "close") + CloseableHttpClient httpClient(@Value("${your.api.max-connections-total:64}") int total, + @Value("${your.api.max-connections-per-route:16}") int perRoute) { + var cm = PoolingHttpClientConnectionManagerBuilder.create() + .setMaxConnTotal(total).setMaxConnPerRoute(perRoute).build(); + return HttpClients.custom() + .setConnectionManager(cm) + .evictExpiredConnections() + .evictIdleConnections(org.apache.hc.core5.util.TimeValue.ofSeconds(30)) + .disableAutomaticRetries() + .setUserAgent("your-service-client") + .build(); + } + + @Bean + HttpComponentsClientHttpRequestFactory requestFactory(CloseableHttpClient http, + @Value("${your.api.connect-timeout-seconds:10}") long c, + @Value("${your.api.connection-request-timeout-seconds:10}") long r, + @Value("${your.api.read-timeout-seconds:15}") long t) { + var f = new HttpComponentsClientHttpRequestFactory(http); + f.setConnectTimeout(Duration.ofSeconds(c)); + f.setConnectionRequestTimeout(Duration.ofSeconds(r)); + f.setReadTimeout(Duration.ofSeconds(t)); + return f; + } + + @Bean + RestClient yourRestClient(RestClient.Builder builder, HttpComponentsClientHttpRequestFactory rf, + List customizers) { + builder.requestFactory(rf); + if (customizers != null) customizers.forEach(c -> c.customize(builder)); return builder.build(); } @Bean - ApiClient yourApiClient(RestClient yourRestClient, - @Value("${your.api.base-url}") String baseUrl) { - return new ApiClient(yourRestClient).setBasePath(baseUrl); + ApiClient yourApiClient(RestClient rest, @Value("${your.api.base-url}") String baseUrl) { + return new ApiClient(rest).setBasePath(baseUrl); } @Bean - YourControllerApi yourControllerApi(ApiClient yourApiClient) { - return new YourControllerApi(yourApiClient); + YourControllerApi yourControllerApi(ApiClient apiClient) { + return new YourControllerApi(apiClient); } } ``` -**application.properties:** +**application.properties** ```properties your.api.base-url=http://localhost:8084/your-service +your.api.max-connections-total=64 +your.api.max-connections-per-route=16 +your.api.connect-timeout-seconds=10 +your.api.connection-request-timeout-seconds=10 +your.api.read-timeout-seconds=15 ``` -> **Note:** This setup is for **demos/local development**. -> For production, encapsulate `YourControllerApi` behind an **Adapter interface** and use a **pooled HTTP client** (e.g., Apache HttpClient5). --- -## 8) Usage Example +## 🧪 Example Usage ```java -package .openapi.client.usecase; - -import .openapi.client.adapter.YourClientAdapter; -import .openapi.client.common.ServiceClientResponse; -import .openapi.client.generated.dto.YourCreateRequest; -import .openapi.client.generated.dto.YourCreateResponse; -import org.springframework.stereotype.Component; - -@Component -public class DemoUseCase { - - private final YourClientAdapter yourClient; +var response = yourClientAdapter.getYourEntity(42); +var dto = response.getData(); +var serverTime = response.getMeta().serverTime(); +``` - public DemoUseCase(YourClientAdapter yourClient) { - this.yourClient = yourClient; - } +Error handling: - public void run() { - YourCreateRequest req = new YourCreateRequest(); - req.setName("Alice"); +```java +try{ + yourClientAdapter.getYourEntity(999); +}catch( +ClientProblemException ex){ +var pd = ex.getProblem(); + System.err. - ServiceClientResponse resp = yourClient.createYourEntity(req); +println(pd.getTitle() +": "+pd. - var payload = resp.getData(); - var response = yourClient.createYourEntity(req); - var data = response.getData(); - log.info("Created entity id={} name={}", data.getId(), data.getName()); - } -} +getDetail()); + } ``` --- -## 9) Notes - -* Dependencies like `spring-web`, `jakarta.*` are often marked **provided** — your host app must supply them. -* Re-run `curl` + `mvn clean install` whenever your service’s OpenAPI spec changes. -* Optional vendor extension `x-class-extra-annotation` can add annotations (e.g., Jackson or Lombok) on generated - wrappers. - ---- - -## 10) Folder Structure (Suggested) +## 🧭 Folder Structure (Suggested) ``` your-service-client/ ├─ src/main/java//openapi/client/common/ │ ├─ ServiceClientResponse.java - │ └─ ClientErrorDetail.java + │ ├─ ClientMeta.java + │ ├─ Page.java + │ └─ error/ClientProblemException.java ├─ src/main/java//openapi/client/adapter/ - │ └─ YourClientAdapter.java + │ ├─ YourClientAdapter.java + │ └─ YourClientAdapterImpl.java ├─ src/main/resources/openapi-templates/ - │ ├─ api_wrapper.mustache - │ └─ model.mustache - ├─ src/main/resources/your-api-docs.yaml + │ └─ api_wrapper.mustache + ├─ src/main/resources/customer-api-docs.yaml └─ pom.xml ``` --- -## 11) Maven Setup +## ✅ Key Points -See [Client-Side Adoption (POM Setup)](client-side-adoption-pom.md) for the full `pom.xml` configuration, including -required plugins (`maven-dependency-plugin`, `maven-resources-plugin`, `openapi-generator-maven-plugin`, etc.) -and dependency declarations. - ---- +* Mirrors `{ data, meta }` structure from the server side. +* Fully supports nested generics and vendor extensions (`x-data-container`, `x-data-item`). +* Decodes non‑2xx responses into `ProblemDetail` and throws `ClientProblemException`. +* Modular design: adapters hide generated artifacts from your domain logic. -✅ With this setup, your client project generates **type-safe wrappers** that align with `ServiceResponse` from the -server side, without any boilerplate duplication. +Your client is now **fully aligned** with the new generics‑aware and ProblemDetail‑compatible architecture. diff --git a/docs/adoption/server-side-adoption.md b/docs/adoption/server-side-adoption.md index 95bad31..5a8fde7 100644 --- a/docs/adoption/server-side-adoption.md +++ b/docs/adoption/server-side-adoption.md @@ -1,42 +1,52 @@ --- + layout: default -title: Server-Side Adoption +title: Server-Side Adoption (Simplified) parent: Adoption Guides nav_order: 1 ---- +------------ -# Adopt the server‑side pieces in your own Spring Boot service (MVC + Springdoc) +# Server-Side Adoption — Spring Boot + Springdoc -**Purpose:** Copy the minimal set of classes/config from `customer-service` into your own microservice so that: +**Goal:** Integrate a minimal, production-ready setup into your Spring MVC service so it returns unified`{ data, meta }` +envelopes, automatically registers generic wrappers in OpenAPI, and enables thin client generation via +`ServiceClientResponse`. -* You return **`ServiceResponse`** from controllers. -* The OpenAPI spec **auto-registers wrapper schemas** (e.g., `ServiceResponseCustomerDto`) with vendor extensions. -* A client module can later generate **thin, generics-aware wrappers**. - -> Scope: Spring MVC (WebMVC) + Springdoc. No WebFlux. +> Scope: Spring MVC (WebMVC) + Springdoc (no WebFlux). --- -## 1) Result overview +## 1️⃣ Overview + +Your service will: -After this guide, your service will: +* Return success bodies like: -* Expose CRUD (or your own endpoints) returning `ServiceResponse`. -* Publish Swagger UI and `/v3/api-docs(.yaml)` with **composed wrapper schemas**. -* Include vendor extensions: +```json +{ + "data": { + /* T */ + }, + "meta": { + "serverTime": "2025-01-01T12:34:56Z", + "sort": [] + } +} +``` - * `x-api-wrapper: true` - * `x-api-wrapper-datatype: ` - * *(optional)* `x-class-extra-annotation: "@..."` +* Expose Swagger UI and `/v3/api-docs(.yaml)` including: + + * Base `ServiceResponse` + * Composed wrappers for each DTO (`ServiceResponseCustomerDto`, etc.) + * Vendor extensions: `x-api-wrapper`, `x-api-wrapper-datatype`, *(optionally)* `x-data-container`, `x-data-item` --- -## 2) Add dependencies (pom.xml) +## 2️⃣ Dependencies (pom.xml) ```xml - org.springframework.boot spring-boot-starter-web @@ -45,141 +55,101 @@ After this guide, your service will: org.springframework.boot spring-boot-starter-validation - - org.springdoc springdoc-openapi-starter-webmvc-ui 2.8.13 - - - - org.springframework.boot - spring-boot-configuration-processor - true - - - - - org.springframework.boot - spring-boot-starter-test - test - ``` -Add your usual build plugins (compiler, surefire/failsafe, jacoco) as you prefer. - -> **Note:** Make sure all `common.openapi` configuration classes are inside your main -> Spring Boot application’s component scan (same base package or a sub-package). -> If you place them elsewhere, adjust `@SpringBootApplication(scanBasePackages=...)` -> to include their package. +> ✅ Ensure `common.openapi` packages are inside your application's scan base package. --- -## 3) Create the generic response envelope +## 3️⃣ Core Response Envelope -**`common/api/response/ServiceResponse.java`** - -```java -package +Include your unified response primitives under `common/api/response/`. -.common.api.response; +**`ServiceResponse.java`** -import java.util.Collections; -import java.util.List; +```java +package .common.api.response; -import org.springframework.http.HttpStatus; +public record ServiceResponse(T data, Meta meta) { -public record ServiceResponse(int status, String message, T data, List errors) { public static ServiceResponse ok(T data) { - return new ServiceResponse<>(HttpStatus.OK.value(), "OK", data, Collections.emptyList()); - } - - public static ServiceResponse of(HttpStatus status, String message, T data) { - return new ServiceResponse<>(status.value(), message, data, Collections.emptyList()); + return new ServiceResponse<>(data, Meta.now()); } - public static ServiceResponse error(HttpStatus status, String message) { - return new ServiceResponse<>(status.value(), message, null, Collections.emptyList()); - } - - public static ServiceResponse error(HttpStatus status, String message, List errors) { - return new ServiceResponse<>(status.value(), message, null, errors != null ? errors : Collections.emptyList()); + public static ServiceResponse ok(T data, Meta meta) { + return new ServiceResponse<>(data, meta != null ? meta : Meta.now()); } } ``` -**`common/api/response/ErrorDetail.java`** +**`Meta.java`** ```java -package .common.api.response; +package -public record ErrorDetail(String errorCode, String message) { +.common.api.response; + +import java.time.Instant; +import java.util.List; + +public record Meta(Instant serverTime, List sort) { + + public static Meta now() { + return new Meta(Instant.now(), List.of()); + } + + public static Meta now(List sort) { + return new Meta(Instant.now(), sort == null ? List.of() : List.copyOf(sort)); + } } ``` -> **Note:** Ensure ServiceResponse and ErrorDetail are in a package visible to both controllers and OpenAPI config ( -> e.g., common.api.response). -> If you place them in a different package, make sure springdoc picks them up in schema generation. - -> You can keep your existing error model; only the field names `status`, `message`, `data`, `errors` are used by the -> OpenAPI base envelope below. +These define the `{ data, meta }` envelope shared across all controllers. --- -## 4) OpenAPI base envelope + vendor extensions +## 4️⃣ OpenAPI Schema Setup -Create the following under `common/openapi/`. +Define and register your reusable OpenAPI schema components directly in your service. Below are the key files and +minimal inline examples — each followed by a link to its full source. -**`OpenApiSchemas.java`** +**`OpenApiSchemas.java`** — centralizes all schema names and vendor extension keys. ```java -package - -.common.openapi; - -import .common.api.response.ServiceResponse; +package .common.openapi; public final class OpenApiSchemas { - // Base envelope schema names - public static final String SCHEMA_SERVICE_RESPONSE = ServiceResponse.class.getSimpleName(); - public static final String SCHEMA_SERVICE_RESPONSE_VOID = SCHEMA_SERVICE_RESPONSE + "Void"; - - // Common property keys - public static final String PROP_STATUS = "status"; - public static final String PROP_MESSAGE = "message"; - public static final String PROP_ERRORS = "errors"; - public static final String PROP_ERROR_CODE = "errorCode"; + public static final String PROP_DATA = "data"; + public static final String PROP_META = "meta"; + + public static final String SCHEMA_SERVICE_RESPONSE = "ServiceResponse"; + public static final String SCHEMA_SERVICE_RESPONSE_VOID = "ServiceResponseVoid"; + public static final String SCHEMA_META = "Meta"; - // Vendor extension keys public static final String EXT_API_WRAPPER = "x-api-wrapper"; public static final String EXT_API_WRAPPER_DATATYPE = "x-api-wrapper-datatype"; - public static final String EXT_CLASS_EXTRA_ANNOTATION = "x-class-extra-annotation"; + public static final String EXT_DATA_CONTAINER = "x-data-container"; + public static final String EXT_DATA_ITEM = "x-data-item"; private OpenApiSchemas() { } } ``` -**`SwaggerResponseCustomizer.java`** — defines the *base* `ServiceResponse` envelope as a schema once. +➡️ [View full source →](snippets/OpenApiSchemas.java) -```java -package - -.common.openapi; +--- -import static .common.openapi.OpenApiSchemas.*; +**`SwaggerResponseCustomizer.java`** — registers base envelope schemas (`ServiceResponse`, `Meta`, etc.). -import io.swagger.v3.oas.models.media.ArraySchema; -import io.swagger.v3.oas.models.media.IntegerSchema; -import io.swagger.v3.oas.models.media.ObjectSchema; -import io.swagger.v3.oas.models.media.StringSchema; -import org.springdoc.core.customizers.OpenApiCustomizer; -import org.springframework.context.annotation.Bean; -import org.springframework.context.annotation.Configuration; +```java @Configuration public class SwaggerResponseCustomizer { @@ -187,75 +157,52 @@ public class SwaggerResponseCustomizer { @Bean public OpenApiCustomizer responseEnvelopeSchemas() { return openApi -> { - if (!openApi.getComponents().getSchemas().containsKey(SCHEMA_SERVICE_RESPONSE)) { - openApi.getComponents().addSchemas( - SCHEMA_SERVICE_RESPONSE, - new ObjectSchema() - .addProperty(PROP_STATUS, new IntegerSchema().format("int32")) - .addProperty(PROP_MESSAGE, new StringSchema()) - .addProperty(PROP_ERRORS, new ArraySchema().items( - new ObjectSchema() - .addProperty(PROP_ERROR_CODE, new StringSchema()) - .addProperty(PROP_MESSAGE, new StringSchema())))); - } - if (!openApi.getComponents().getSchemas().containsKey(SCHEMA_SERVICE_RESPONSE_VOID)) { - openApi.getComponents().addSchemas( - SCHEMA_SERVICE_RESPONSE_VOID, - new ObjectSchema() - .addProperty(PROP_STATUS, new IntegerSchema().format("int32")) - .addProperty(PROP_MESSAGE, new StringSchema()) - .addProperty(PROP_DATA, new ObjectSchema()) - .addProperty(PROP_ERRORS, new ArraySchema().items( - new ObjectSchema() - .addProperty(PROP_ERROR_CODE, new StringSchema()) - .addProperty(PROP_MESSAGE, new StringSchema())))); - } + var schemas = openApi.getComponents().getSchemas(); + + schemas.computeIfAbsent("ServiceResponse", k -> new ObjectSchema() + .addProperty("data", new Schema<>()) + .addProperty("meta", new Schema<>().$ref("#/components/schemas/Meta"))); + + schemas.computeIfAbsent("Meta", k -> new ObjectSchema() + .addProperty("serverTime", new StringSchema().format("date-time")) + .addProperty("sort", new ArraySchema().items(new ObjectSchema()))); }; } } ``` -**`ApiResponseSchemaFactory.java`** — builds a *composed* wrapper per concrete `T` (e.g., `CustomerDto`). +➡️ [View full source →](snippets/SwaggerResponseCustomizer.java) -```java -package - -.common.openapi; - -import static .common.openapi.OpenApiSchemas.*; - -import io.swagger.v3.oas.models.media.ComposedSchema; -import io.swagger.v3.oas.models.media.ObjectSchema; -import io.swagger.v3.oas.models.media.Schema; +--- -import java.util.List; +**`ApiResponseSchemaFactory.java`** — composes a new wrapper schema per DTO and enriches it with vendor extensions. +```java public final class ApiResponseSchemaFactory { - private ApiResponseSchemaFactory() { - } - - public static Schema createComposedWrapper(String dataRefName) { - return createComposedWrapper(dataRefName, null); - } - public static Schema createComposedWrapper(String dataRefName, String classExtraAnnotation) { + public static Schema createComposedWrapper(String dataRef) { var schema = new ComposedSchema(); schema.setAllOf(List.of( - new Schema<>().$ref("#/components/schemas/" + SCHEMA_SERVICE_RESPONSE), - new ObjectSchema().addProperty(PROP_DATA, new Schema<>().$ref("#/components/schemas/" + dataRefName)) + new Schema<>().$ref("#/components/schemas/ServiceResponse"), + new ObjectSchema().addProperty("data", new Schema<>().$ref("#/components/schemas/" + dataRef)) )); - - schema.addExtension(EXT_API_WRAPPER, true); - schema.addExtension(EXT_API_WRAPPER_DATATYPE, dataRefName); - if (classExtraAnnotation != null && !classExtraAnnotation.isBlank()) { - schema.addExtension(EXT_CLASS_EXTRA_ANNOTATION, classExtraAnnotation); - } + schema.addExtension("x-api-wrapper", true); + schema.addExtension("x-api-wrapper-datatype", dataRef); return schema; } } ``` -**`ResponseTypeIntrospector.java`** — unwraps return types until it finds `ServiceResponse` and extracts `T`. +➡️ [View full source →](snippets/ApiResponseSchemaFactory.java) + +--- + +## 5️⃣ Auto‑Registration Logic + +Add dynamic schema registration so OpenAPI automatically composes wrappers for all controllers returning +`ServiceResponse`. + +**`ResponseTypeIntrospector.java`** — unwraps controller return types to detect `ServiceResponse`. ```java package @@ -265,279 +212,208 @@ package import .common.api.response.ServiceResponse; import java.lang.reflect.Method; import java.util.Optional; -import java.util.Set; -import java.util.concurrent.CompletionStage; -import java.util.concurrent.Future; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; import org.springframework.core.ResolvableType; -import org.springframework.http.ResponseEntity; import org.springframework.stereotype.Component; -import org.springframework.web.context.request.async.DeferredResult; -import org.springframework.web.context.request.async.WebAsyncTask; @Component public final class ResponseTypeIntrospector { - private static final Logger log = LoggerFactory.getLogger(ResponseTypeIntrospector.class); - private static final int MAX_UNWRAP_DEPTH = 8; - private static final Set REACTOR_WRAPPERS = Set.of("reactor.core.publisher.Mono", "reactor.core.publisher.Flux"); public Optional extractDataRefName(Method method) { if (method == null) return Optional.empty(); ResolvableType type = ResolvableType.forMethodReturnType(method); - type = unwrapToServiceResponse(type); - Class raw = type.resolve(); - if (raw == null || !ServiceResponse.class.isAssignableFrom(raw)) return Optional.empty(); + if (!ServiceResponse.class.isAssignableFrom(type.resolve())) return Optional.empty(); if (!type.hasGenerics()) return Optional.empty(); Class dataClass = type.getGeneric(0).resolve(); - Optional ref = Optional.ofNullable(dataClass).map(Class::getSimpleName); - - if (log.isDebugEnabled()) { - log.debug("Introspected method [{}]: wrapper [{}], data [{}]", method.toGenericString(), raw.getSimpleName(), ref.orElse("")); - } - return ref; - } - - private ResolvableType unwrapToServiceResponse(ResolvableType type) { - for (int guard = 0; guard < MAX_UNWRAP_DEPTH; guard++) { - Class raw = type.resolve(); - if (raw == null || ServiceResponse.class.isAssignableFrom(raw)) return type; - ResolvableType next = nextLayer(type, raw); - if (next == null) return type; - type = next; - } - return type; - } - - private ResolvableType nextLayer(ResolvableType current, Class raw) { - return switch (raw) { - case Class c when ResponseEntity.class.isAssignableFrom(c) -> current.getGeneric(0); - case Class c when CompletionStage.class.isAssignableFrom(c) || Future.class.isAssignableFrom(c) -> - current.getGeneric(0); - case Class c when DeferredResult.class.isAssignableFrom(c) || WebAsyncTask.class.isAssignableFrom(c) -> - current.getGeneric(0); - case Class c when REACTOR_WRAPPERS.contains(c.getName()) -> current.getGeneric(0); - default -> null; - }; + return Optional.ofNullable(dataClass).map(Class::getSimpleName); } } ``` -**`autoreg/AutoWrapperSchemaCustomizer.java`** — collects all controller return types and registers composed schemas. - -```java -package +➡️ [View full source →](snippets/ResponseTypeIntrospector.java) -.common.openapi.autoreg; +--- -import .common.openapi.ApiResponseSchemaFactory; -import .common.openapi.OpenApiSchemas; -import .common.openapi.introspector.ResponseTypeIntrospector; -import java.util.Collections; -import java.util.LinkedHashSet; -import java.util.Set; +**`AutoWrapperSchemaCustomizer.java`** — scans controllers and dynamically registers composed wrapper schemas for each +detected DTO. -import org.springdoc.core.customizers.OpenApiCustomizer; -import org.springframework.beans.factory.ListableBeanFactory; -import org.springframework.beans.factory.annotation.Value; -import org.springframework.context.annotation.Bean; -import org.springframework.context.annotation.Configuration; -import org.springframework.web.method.HandlerMethod; -import org.springframework.web.servlet.mvc.method.annotation.RequestMappingHandlerMapping; +```java @Configuration public class AutoWrapperSchemaCustomizer { - private final Set dataRefs; - private final String classExtraAnnotation; - public AutoWrapperSchemaCustomizer( - ListableBeanFactory beanFactory, - ResponseTypeIntrospector introspector, - @Value("${app.openapi.wrapper.class-extra-annotation:}") String classExtraAnnotation) { - - Set refs = new LinkedHashSet<>(); - beanFactory.getBeansOfType(RequestMappingHandlerMapping.class).values() - .forEach(rmh -> rmh.getHandlerMethods().values().stream() - .map(HandlerMethod::getMethod) - .forEach(m -> introspector.extractDataRefName(m).ifPresent(refs::add))); - - this.dataRefs = Collections.unmodifiableSet(refs); - this.classExtraAnnotation = (classExtraAnnotation == null || classExtraAnnotation.isBlank()) ? null : classExtraAnnotation; + private final Set dataRefs; + private final ResponseTypeIntrospector introspector; + + public AutoWrapperSchemaCustomizer(ListableBeanFactory beans, ResponseTypeIntrospector introspector) { + this.introspector = introspector; + this.dataRefs = beans.getBeansOfType(RequestMappingHandlerMapping.class).values().stream() + .flatMap(rmh -> rmh.getHandlerMethods().values().stream()) + .map(HandlerMethod::getMethod) + .map(introspector::extractDataRefName) + .flatMap(Optional::stream) + .collect(Collectors.toSet()); } @Bean public OpenApiCustomizer autoResponseWrappers() { return openApi -> dataRefs.forEach(ref -> { - String name = OpenApiSchemas.SCHEMA_SERVICE_RESPONSE + ref; - openApi.getComponents().addSchemas(name, ApiResponseSchemaFactory.createComposedWrapper(ref, classExtraAnnotation)); + openApi.getComponents().addSchemas( + "ServiceResponse" + ref, + ApiResponseSchemaFactory.createComposedWrapper(ref) + ); }); } } ``` -**`OpenApiConfig.java`** — optional, for title/version/server URL. +➡️ [View full source →](snippets/AutoWrapperSchemaCustomizer.java) -```java -package +--- + +## 6️⃣ Global Problem Responses (RFC 7807) -.common.openapi; +Add automatic `ProblemDetail` registration and standard error responses for all operations. -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; +**`GlobalErrorResponsesCustomizer.java`** — auto-registers `ProblemDetail` schema and attaches default responses (400, +404, 405, 500). + +```java @Configuration -public class OpenApiConfig { - @Value("${app.openapi.version:${project.version:unknown}}") - private String version; - @Value("${app.openapi.base-url:}") - private String baseUrl; +public class GlobalErrorResponsesCustomizer { @Bean - public OpenAPI serviceOpenAPI() { - var openapi = new OpenAPI().info(new Info().title("Your Service API").version(version).description("Generic responses via OpenAPI")); - if (baseUrl != null && !baseUrl.isBlank()) { - openapi.addServersItem(new Server().url(baseUrl).description("Local service URL")); - } - return openapi; + OpenApiCustomizer addDefaultProblemResponses() { + return openApi -> openApi.getPaths().forEach((path, item) -> + item.readOperations().forEach(op -> { + var problem = new Schema<>().$ref("#/components/schemas/ProblemDetail"); + var content = new Content().addMediaType("application/problem+json", new MediaType().schema(problem)); + op.getResponses().addApiResponse("400", new ApiResponse().description("Bad Request").content(content)); + op.getResponses().addApiResponse("404", new ApiResponse().description("Not Found").content(content)); + op.getResponses().addApiResponse("405", new ApiResponse().description("Method Not Allowed").content(content)); + op.getResponses().addApiResponse("500", new ApiResponse().description("Internal Server Error").content(content)); + }) + ); } } ``` +➡️ [View full source →](snippets/GlobalErrorResponsesCustomizer.java) + +> Ensures your API spec always includes standardized problem responses without extra boilerplate. + --- -## 5) Application configuration (application.yml) - -```yaml -server: - port: 8084 - servlet: - context-path: /your-service - -spring: - application: - name: your-service - profiles: - active: local - -app: - openapi: - version: @project.version@ - base-url: "http://localhost:${server.port}${server.servlet.context-path:}" - # wrapper: - # class-extra-annotation: "@com.fasterxml.jackson.annotation.JsonIgnoreProperties(ignoreUnknown = true)" - -springdoc: - default-consumes-media-type: application/json - default-produces-media-type: application/json -``` +### Optional: Problem extensions (RFC7807) -* Set `base-url` if you want Swagger UI to show the server URL. -* Uncomment `class-extra-annotation` only if you want to push an extra annotation to generated wrapper classes. +Some projects enrich `ProblemDetail` with structured error data inside `extensions.errors`. +These simple records provide a reusable base for that purpose. ---- +**`ErrorItem.java`** -## 6) Return `ServiceResponse` from controllers +```java +package -Example controller method (update to your domain): +.common.api.response.error; + +import com.fasterxml.jackson.annotation.JsonInclude; + +@JsonInclude(JsonInclude.Include.NON_NULL) +public record ErrorItem(String code, String message, String field, String resource, String id) { +} +``` + +**`ProblemExtensions.java`** ```java +package -@RestController -@RequestMapping(value = "/v1/customers", produces = MediaType.APPLICATION_JSON_VALUE) -@Validated -class CustomerController { - private final CustomerService customerService; +.common.api.response.error; - public CustomerController(CustomerService customerService) { - this.customerService = customerService; - } +import com.fasterxml.jackson.annotation.JsonInclude; - @PostMapping(consumes = MediaType.APPLICATION_JSON_VALUE) - @ResponseStatus(HttpStatus.CREATED) - public ResponseEntity> createCustomer(@Valid @RequestBody CustomerCreateRequest request) { - CustomerDto created = customerService.createCustomer(request); - CustomerCreateResponse body = new CustomerCreateResponse(created, Instant.now()); - URI location = ServletUriComponentsBuilder.fromCurrentRequest().path("/{id}").buildAndExpand(created.customerId()).toUri(); - return ResponseEntity.created(location).body(ServiceResponse.of(HttpStatus.CREATED, "CREATED", body)); - } +import java.util.List; - @GetMapping("/{customerId}") - public ResponseEntity> getCustomer(@PathVariable @Min(1) Integer customerId) { - CustomerDto dto = customerService.getCustomer(customerId); - return ResponseEntity.ok(ServiceResponse.ok(dto)); +@JsonInclude(JsonInclude.Include.NON_NULL) +public record ProblemExtensions(List errors) { + public static ProblemExtensions ofErrors(List errors) { + return new ProblemExtensions(errors); } } ``` -> You can wrap *any* DTO type the same way. The auto‑registration picks up all controller methods that return -`ServiceResponse` (including `ResponseEntity>`, `CompletionStage<...>`, etc.). +> Usage example: in a `@RestControllerAdvice`, +> `pd.setProperty("extensions", ProblemExtensions.ofErrors(List.of(...)))` +> and optionally `pd.setProperty("errorCode", "VALIDATION_FAILED")`. --- -## 7) Run & verify +## 7️⃣ Example Controller -1. Start your service and open: +```java - * Swagger UI → `http://localhost:8084/your-service/swagger-ui/index.html` - * OpenAPI JSON → `http://localhost:8084/your-service/v3/api-docs` - * OpenAPI YAML → `http://localhost:8084/your-service/v3/api-docs.yaml` -2. In the **Schemas** section, confirm you see: +@RestController +@RequestMapping("/v1/customers") +class CustomerController { + private final CustomerService service; - * `ServiceResponse` - * `ServiceResponseVoid` - * `ServiceResponse` generated as **composed** schemas with `x-api-wrapper` vendor extensions. + @GetMapping("/{id}") + ResponseEntity> get(@PathVariable int id) { + return ResponseEntity.ok(ServiceResponse.ok(service.getCustomer(id))); + } +} +``` --- -## 8) What the client generator relies on +## 8️⃣ Verification + +Run your service and verify: -* `ServiceResponse` base envelope schema (added by `SwaggerResponseCustomizer`). -* A composed schema per `T` with extensions: +1. Swagger UI → `http://localhost:8084/your-service/swagger-ui/index.html` +2. OpenAPI JSON → `http://localhost:8084/your-service/v3/api-docs` - * `x-api-wrapper: true` - * `x-api-wrapper-datatype: ` - * *(optional)* `x-class-extra-annotation` -* These let the client module generate **thin wrappers** that extend `ServiceClientResponse`. +Confirm these: + +* `ServiceResponse` base schema exists. +* Composed schemas appear: `ServiceResponseCustomerDto`, etc. +* Vendor extensions (`x-api-wrapper`, `x-api-wrapper-datatype`, ...) are present. --- -## 9) Common pitfalls +## 9️⃣ Troubleshooting -* **No composed wrappers appear:** ensure your controllers actually return `ServiceResponse` and that - `AutoWrapperSchemaCustomizer` is loaded (it’s a `@Configuration`). -* **Wrong `data` `$ref`:** the DTO class name must match the schema name Springdoc emits (usually the simple type name). - If you use custom schema names, adapt `extractDataRefName` to your naming. -* **Profiles/paths:** if you change `context-path` or port, also update `app.openapi.base-url`. -* **Extra annotations:** if you don’t need additional annotations on generated client wrappers, keep - `class-extra-annotation` **unset**. +| Problem | Likely Cause | +|----------------------|------------------------------------------------| +| No composed wrappers | Controller doesn’t return `ServiceResponse` | +| Missing Meta | Schema not registered or excluded from scan | +| `$ref` mismatch | DTO class name differs from schema reference | --- -## 10) Minimal folder map (suggested) +## 📁 Folder Map (Minimal) ``` src/main/java// common/api/response/ - ErrorDetail.java + Meta.java ServiceResponse.java common/openapi/ OpenApiSchemas.java SwaggerResponseCustomizer.java ApiResponseSchemaFactory.java - OpenApiConfig.java - introspector/ - ResponseTypeIntrospector.java + ResponseTypeIntrospector.java + GlobalErrorResponsesCustomizer.java autoreg/ AutoWrapperSchemaCustomizer.java api/controller/ YourControllers... ``` -That’s it — your service now publishes a spec that is **generics-aware** and ready for client generation. +--- + +✅ Your service now exposes a **generics‑aware**, `ProblemDetail`‑compliant OpenAPI 3.1 spec — ready for thin, type‑safe +client generation. diff --git a/docs/adoption/snippets/ApiResponseSchemaFactory.java b/docs/adoption/snippets/ApiResponseSchemaFactory.java new file mode 100644 index 0000000..72b18ea --- /dev/null +++ b/docs/adoption/snippets/ApiResponseSchemaFactory.java @@ -0,0 +1,55 @@ +package io.github.bsayli.customerservice.common.openapi; + +import static io.github.bsayli.customerservice.common.openapi.OpenApiSchemas.*; + +import io.swagger.v3.oas.models.media.ComposedSchema; +import io.swagger.v3.oas.models.media.ObjectSchema; +import io.swagger.v3.oas.models.media.Schema; +import java.util.List; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +public final class ApiResponseSchemaFactory { + + private static final Logger log = LoggerFactory.getLogger(ApiResponseSchemaFactory.class); + + private ApiResponseSchemaFactory() {} + + public static Schema createComposedWrapper(String dataRefName) { + return createComposedWrapper(dataRefName, null); + } + + public static Schema createComposedWrapper(String dataRefName, String classExtraAnnotation) { + if (log.isDebugEnabled()) { + log.debug( + "Creating composed wrapper for dataRef='{}', extraAnnotation='{}'", + dataRefName, + classExtraAnnotation); + } + + var schema = new ComposedSchema(); + schema.setAllOf( + List.of( + new Schema<>().$ref("#/components/schemas/" + SCHEMA_SERVICE_RESPONSE), + new ObjectSchema() + .addProperty( + PROP_DATA, new Schema<>().$ref("#/components/schemas/" + dataRefName)))); + + schema.addExtension(EXT_API_WRAPPER, true); + schema.addExtension(EXT_API_WRAPPER_DATATYPE, dataRefName); + + if (classExtraAnnotation != null && !classExtraAnnotation.isBlank()) { + schema.addExtension(EXT_CLASS_EXTRA_ANNOTATION, classExtraAnnotation); + if (log.isDebugEnabled()) { + log.debug("Added extension {}='{}'", EXT_CLASS_EXTRA_ANNOTATION, classExtraAnnotation); + } + } + + if (log.isDebugEnabled()) { + log.debug( + "Composed schema created for '{}': extensions={}", dataRefName, schema.getExtensions()); + } + + return schema; + } +} diff --git a/docs/adoption/snippets/AutoWrapperSchemaCustomizer.java b/docs/adoption/snippets/AutoWrapperSchemaCustomizer.java new file mode 100644 index 0000000..b9e2da4 --- /dev/null +++ b/docs/adoption/snippets/AutoWrapperSchemaCustomizer.java @@ -0,0 +1,157 @@ +package io.github.bsayli.customerservice.common.openapi.autoreg; + +import io.github.bsayli.customerservice.common.openapi.ApiResponseSchemaFactory; +import io.github.bsayli.customerservice.common.openapi.OpenApiSchemas; +import io.github.bsayli.customerservice.common.openapi.introspector.ResponseTypeIntrospector; +import io.swagger.v3.oas.models.OpenAPI; +import io.swagger.v3.oas.models.media.ArraySchema; +import io.swagger.v3.oas.models.media.ComposedSchema; +import io.swagger.v3.oas.models.media.JsonSchema; +import io.swagger.v3.oas.models.media.ObjectSchema; +import io.swagger.v3.oas.models.media.Schema; +import java.util.*; +import java.util.stream.Collectors; +import org.springdoc.core.customizers.OpenApiCustomizer; +import org.springframework.beans.factory.ListableBeanFactory; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.web.method.HandlerMethod; +import org.springframework.web.servlet.mvc.method.annotation.RequestMappingHandlerMapping; + +@Configuration +public class AutoWrapperSchemaCustomizer { + + private static final String SCHEMA_REF_PREFIX = "#/components/schemas/"; + private static final String CONTENT = "content"; + + private final Set dataRefs; + private final String classExtraAnnotation; + private final Set genericContainers; + + public AutoWrapperSchemaCustomizer( + ListableBeanFactory beanFactory, + ResponseTypeIntrospector introspector, + @Value("${app.openapi.wrapper.class-extra-annotation:}") String classExtraAnnotation, + @Value("${app.openapi.wrapper.generic-containers:Page}") String genericContainersProp) { + + this.dataRefs = + beanFactory.getBeansOfType(RequestMappingHandlerMapping.class).values().stream() + .flatMap(rmh -> rmh.getHandlerMethods().values().stream()) + .map(HandlerMethod::getMethod) + .map(introspector::extractDataRefName) + .flatMap(Optional::stream) + .collect(Collectors.toCollection(LinkedHashSet::new)); + + this.classExtraAnnotation = + (classExtraAnnotation == null || classExtraAnnotation.isBlank()) + ? null + : classExtraAnnotation; + + this.genericContainers = + Arrays.stream(genericContainersProp.split(",")) + .map(String::trim) + .filter(s -> !s.isEmpty()) + .collect(Collectors.toUnmodifiableSet()); + } + + @Bean + public OpenApiCustomizer autoResponseWrappers() { + return openApi -> + dataRefs.forEach( + ref -> { + String wrapperName = OpenApiSchemas.SCHEMA_SERVICE_RESPONSE + ref; + openApi + .getComponents() + .addSchemas( + wrapperName, + ApiResponseSchemaFactory.createComposedWrapper(ref, classExtraAnnotation)); + enrichWrapperExtensions(openApi, wrapperName, ref); + }); + } + + private void enrichWrapperExtensions(OpenAPI openApi, String wrapperName, String dataRefName) { + String container = matchContainer(dataRefName); + if (container == null) return; + + Map schemas = + (openApi.getComponents() != null) ? openApi.getComponents().getSchemas() : null; + if (schemas == null) return; + + Schema raw = schemas.get(dataRefName); + Schema containerSchema = resolveObjectLikeSchema(schemas, raw, new LinkedHashSet<>()); + if (containerSchema == null) return; + + String itemName = extractItemNameFromSchema(containerSchema); + if (itemName == null) return; + + Schema wrapper = schemas.get(wrapperName); + if (wrapper == null) return; + + wrapper.addExtension(OpenApiSchemas.EXT_DATA_CONTAINER, container); + wrapper.addExtension(OpenApiSchemas.EXT_DATA_ITEM, itemName); + } + + private Schema resolveObjectLikeSchema( + Map schemas, Schema schema, Set visited) { + if (schema == null) return null; + + Schema cur = derefIfNeeded(schemas, schema, visited); + if (cur == null) return null; + + if (isObjectLike(cur)) return cur; + + if (cur instanceof ComposedSchema cs && cs.getAllOf() != null) { + for (Schema s : cs.getAllOf()) { + Schema resolved = resolveObjectLikeSchema(schemas, s, visited); + if (resolved != null) return resolved; + } + } + return null; + } + + private boolean isObjectLike(Schema s) { + return (s instanceof ObjectSchema) + || "object".equals(s.getType()) + || (s.getProperties() != null && !s.getProperties().isEmpty()); + } + + private Schema derefIfNeeded(Map schemas, Schema s, Set visited) { + if (s == null) return null; + String ref = s.get$ref(); + if (ref == null || !ref.startsWith(SCHEMA_REF_PREFIX)) return s; + + String name = ref.substring(SCHEMA_REF_PREFIX.length()); + if (!visited.add(name)) return null; // cycle guard + return schemas.get(name); + } + + private String extractItemNameFromSchema(Schema containerSchema) { + Map props = containerSchema.getProperties(); + if (props == null) return null; + + Schema content = props.get(CONTENT); + if (content == null) return null; + + Schema items = null; + if (content instanceof ArraySchema arr) { + items = arr.getItems(); + } else if ("array".equals(content.getType())) { + items = content.getItems(); + } else if (content instanceof JsonSchema js + && js.getTypes() != null + && js.getTypes().contains("array")) { + items = js.getItems(); + } + if (items == null) return null; + + String itemRef = items.get$ref(); + if (itemRef == null || !itemRef.startsWith(SCHEMA_REF_PREFIX)) return null; + + return itemRef.substring(SCHEMA_REF_PREFIX.length()); + } + + private String matchContainer(String dataRefName) { + return genericContainers.stream().filter(dataRefName::startsWith).findFirst().orElse(null); + } +} diff --git a/docs/adoption/snippets/GlobalErrorResponsesCustomizer.java b/docs/adoption/snippets/GlobalErrorResponsesCustomizer.java new file mode 100644 index 0000000..3104386 --- /dev/null +++ b/docs/adoption/snippets/GlobalErrorResponsesCustomizer.java @@ -0,0 +1,167 @@ +package io.github.bsayli.customerservice.common.openapi; + +import static io.github.bsayli.customerservice.common.openapi.OpenApiSchemas.SCHEMA_PROBLEM_DETAIL; + +import io.swagger.v3.oas.models.media.ArraySchema; +import io.swagger.v3.oas.models.media.Content; +import io.swagger.v3.oas.models.media.IntegerSchema; +import io.swagger.v3.oas.models.media.MediaType; +import io.swagger.v3.oas.models.media.ObjectSchema; +import io.swagger.v3.oas.models.media.Schema; +import io.swagger.v3.oas.models.media.StringSchema; +import io.swagger.v3.oas.models.responses.ApiResponse; +import java.util.Map; +import org.springdoc.core.customizers.OpenApiCustomizer; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +@Configuration +public class GlobalErrorResponsesCustomizer { + + private static final String MEDIA_TYPE_PROBLEM_JSON = "application/problem+json"; + private static final String REF_PROBLEM_DETAIL = "#/components/schemas/" + SCHEMA_PROBLEM_DETAIL; + private static final String SCHEMA_ERROR_ITEM = "ErrorItem"; + + private static final String STATUS_400 = "400"; + private static final String STATUS_404 = "404"; + private static final String STATUS_405 = "405"; + private static final String STATUS_500 = "500"; + + private static final String DESC_BAD_REQUEST = "Bad Request"; + private static final String DESC_NOT_FOUND = "Not Found"; + private static final String DESC_METHOD_NOT_ALLOWED = "Method Not Allowed"; + private static final String DESC_INTERNAL_ERROR = "Internal Server Error"; + + @Bean + OpenApiCustomizer addDefaultProblemResponses() { + return openApi -> { + var components = openApi.getComponents(); + if (components == null) return; + + ensureErrorItemSchema(components.getSchemas()); + ensureProblemDetailSchema(components.getSchemas()); + + openApi + .getPaths() + .forEach( + (path, item) -> + item.readOperations() + .forEach( + op -> { + var responses = op.getResponses(); + var problemContent = + new Content() + .addMediaType( + MEDIA_TYPE_PROBLEM_JSON, + new MediaType() + .schema(new Schema<>().$ref(REF_PROBLEM_DETAIL))); + + responses.addApiResponse( + STATUS_400, + new ApiResponse() + .description(DESC_BAD_REQUEST) + .content(problemContent)); + responses.addApiResponse( + STATUS_404, + new ApiResponse() + .description(DESC_NOT_FOUND) + .content(problemContent)); + responses.addApiResponse( + STATUS_405, + new ApiResponse() + .description(DESC_METHOD_NOT_ALLOWED) + .content(problemContent)); + responses.addApiResponse( + STATUS_500, + new ApiResponse() + .description(DESC_INTERNAL_ERROR) + .content(problemContent)); + })); + }; + } + + @SuppressWarnings("rawtypes") + private void ensureProblemDetailSchema(Map schemas) { + if (schemas == null) return; + if (schemas.containsKey(SCHEMA_PROBLEM_DETAIL)) return; + + ObjectSchema pd = new ObjectSchema(); + + StringSchema type = new StringSchema(); + type.setFormat("uri"); + type.setDescription("Problem type as a URI."); + pd.addProperty("type", type); + + StringSchema title = new StringSchema(); + title.setDescription("Short, human-readable summary of the problem type."); + pd.addProperty("title", title); + + IntegerSchema status = new IntegerSchema(); + status.setFormat("int32"); + status.setDescription("HTTP status code for this problem."); + pd.addProperty("status", status); + + StringSchema detail = new StringSchema(); + detail.setDescription("Human-readable explanation specific to this occurrence."); + pd.addProperty("detail", detail); + + StringSchema instance = new StringSchema(); + instance.setFormat("uri"); + instance.setDescription("URI that identifies this specific occurrence."); + pd.addProperty("instance", instance); + + StringSchema errorCode = new StringSchema(); + errorCode.setDescription("Application-specific error code."); + pd.addProperty("errorCode", errorCode); + + // extensions.errors[] + ArraySchema errorsArray = new ArraySchema(); + errorsArray.setItems(new Schema<>().$ref("#/components/schemas/" + SCHEMA_ERROR_ITEM)); + errorsArray.setDescription("List of error items (field-level or domain-specific)."); + + ObjectSchema extensions = new ObjectSchema(); + extensions.addProperty("errors", errorsArray); + extensions.setDescription("Additional problem metadata."); + extensions.setAdditionalProperties(Boolean.FALSE); + + pd.addProperty("extensions", extensions); + + pd.setAdditionalProperties(Boolean.TRUE); + + schemas.put(SCHEMA_PROBLEM_DETAIL, pd); + } + + @SuppressWarnings("rawtypes") + private void ensureErrorItemSchema(Map schemas) { + if (schemas == null) return; + if (schemas.containsKey(SCHEMA_ERROR_ITEM)) return; + + ObjectSchema errorItem = new ObjectSchema(); + + StringSchema code = new StringSchema(); + code.setDescription("Short application-specific error code."); + errorItem.addProperty("code", code); + + StringSchema message = new StringSchema(); + message.setDescription("Human-readable error message."); + errorItem.addProperty("message", message); + + StringSchema field = new StringSchema(); + field.setDescription("Field name when error is field-specific."); + errorItem.addProperty("field", field); + + StringSchema resource = new StringSchema(); + resource.setDescription("Domain resource name if applicable."); + errorItem.addProperty("resource", resource); + + StringSchema id = new StringSchema(); + id.setDescription("Resource identifier if applicable."); + errorItem.addProperty("id", id); + + errorItem.setDescription("Standard error item structure."); + errorItem.setRequired(java.util.List.of("code", "message")); + errorItem.setAdditionalProperties(Boolean.FALSE); + + schemas.put(SCHEMA_ERROR_ITEM, errorItem); + } +} diff --git a/docs/adoption/snippets/OpenApiSchemas.java b/docs/adoption/snippets/OpenApiSchemas.java new file mode 100644 index 0000000..ea9705c --- /dev/null +++ b/docs/adoption/snippets/OpenApiSchemas.java @@ -0,0 +1,30 @@ +package io.github.bsayli.customerservice.common.openapi; + +import io.github.bsayli.customerservice.common.api.response.ServiceResponse; + +public final class OpenApiSchemas { + + // ---- Common property keys + public static final String PROP_DATA = "data"; + public static final String PROP_META = "meta"; + + // ---- Base envelopes + public static final String SCHEMA_SERVICE_RESPONSE = ServiceResponse.class.getSimpleName(); + public static final String SCHEMA_SERVICE_RESPONSE_VOID = SCHEMA_SERVICE_RESPONSE + "Void"; + + // ---- Other shared schemas + public static final String SCHEMA_META = "Meta"; + public static final String SCHEMA_SORT = "Sort"; + public static final String SCHEMA_PROBLEM_DETAIL = "ProblemDetail"; + + // ---- Vendor extensions + public static final String EXT_API_WRAPPER = "x-api-wrapper"; + public static final String EXT_API_WRAPPER_DATATYPE = "x-api-wrapper-datatype"; + public static final String EXT_CLASS_EXTRA_ANNOTATION = "x-class-extra-annotation"; + + // ---- Vendor extensions (nested/container awareness) + public static final String EXT_DATA_CONTAINER = "x-data-container"; // e.g. "Page" + public static final String EXT_DATA_ITEM = "x-data-item"; // e.g. "CustomerDto" + + private OpenApiSchemas() {} +} diff --git a/docs/adoption/snippets/ResponseTypeIntrospector.java b/docs/adoption/snippets/ResponseTypeIntrospector.java new file mode 100644 index 0000000..56dca0b --- /dev/null +++ b/docs/adoption/snippets/ResponseTypeIntrospector.java @@ -0,0 +1,77 @@ +package io.github.bsayli.customerservice.common.openapi.introspector; + +import io.github.bsayli.customerservice.common.api.response.ServiceResponse; +import java.lang.reflect.Method; +import java.util.Optional; +import java.util.Set; +import java.util.concurrent.CompletionStage; +import java.util.concurrent.Future; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.core.ResolvableType; +import org.springframework.http.ResponseEntity; +import org.springframework.stereotype.Component; +import org.springframework.web.context.request.async.DeferredResult; +import org.springframework.web.context.request.async.WebAsyncTask; + +@Component +public final class ResponseTypeIntrospector { + + private static final Logger log = LoggerFactory.getLogger(ResponseTypeIntrospector.class); + private static final int MAX_UNWRAP_DEPTH = 8; + private static final Set REACTOR_WRAPPERS = + Set.of("reactor.core.publisher.Mono", "reactor.core.publisher.Flux"); + + public Optional extractDataRefName(Method method) { + if (method == null) return Optional.empty(); + + ResolvableType t = ResolvableType.forMethodReturnType(method); + t = unwrapToServiceResponse(t); + + Class raw = t.resolve(); + if (raw == null || !ServiceResponse.class.isAssignableFrom(raw)) return Optional.empty(); + if (!t.hasGenerics()) return Optional.empty(); + + ResolvableType dataType = t.getGeneric(0); + String ref = buildRefName(dataType); + + if (log.isDebugEnabled()) { + log.debug("Introspected method [{}]: dataRef={}", method.toGenericString(), ref); + } + return Optional.of(ref); + } + + private ResolvableType unwrapToServiceResponse(ResolvableType type) { + for (int i = 0; i < MAX_UNWRAP_DEPTH; i++) { + Class raw = type.resolve(); + if (raw == null || ServiceResponse.class.isAssignableFrom(raw)) return type; + ResolvableType next = nextLayer(type, raw); + if (next == null) return type; + type = next; + } + return type; + } + + private ResolvableType nextLayer(ResolvableType current, Class raw) { + if (ResponseEntity.class.isAssignableFrom(raw)) return current.getGeneric(0); + if (CompletionStage.class.isAssignableFrom(raw) || Future.class.isAssignableFrom(raw)) + return current.getGeneric(0); + if (DeferredResult.class.isAssignableFrom(raw) || WebAsyncTask.class.isAssignableFrom(raw)) + return current.getGeneric(0); + if (REACTOR_WRAPPERS.contains(raw.getName())) return current.getGeneric(0); + return null; + } + + private String buildRefName(ResolvableType type) { + Class raw = type.resolve(); + if (raw == null) return "Object"; + String base = raw.getSimpleName(); + if (!type.hasGenerics()) return base; + + StringBuilder sb = new StringBuilder(base); + for (ResolvableType g : type.getGenerics()) { + sb.append(buildRefName(g)); + } + return sb.toString(); + } +} diff --git a/docs/adoption/snippets/SwaggerResponseCustomizer.java b/docs/adoption/snippets/SwaggerResponseCustomizer.java new file mode 100644 index 0000000..f14436c --- /dev/null +++ b/docs/adoption/snippets/SwaggerResponseCustomizer.java @@ -0,0 +1,61 @@ +package io.github.bsayli.customerservice.common.openapi; + +import static io.github.bsayli.customerservice.common.openapi.OpenApiSchemas.*; + +import io.swagger.v3.oas.models.media.*; +import org.springdoc.core.customizers.OpenApiCustomizer; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +@Configuration +public class SwaggerResponseCustomizer { + + private static final String COMPONENTS_SCHEMAS = "#/components/schemas/"; + + @Bean + public OpenApiCustomizer responseEnvelopeSchemas() { + return openApi -> { + var schemas = openApi.getComponents().getSchemas(); + if (schemas == null) { + openApi.getComponents().setSchemas(new java.util.LinkedHashMap<>()); + schemas = openApi.getComponents().getSchemas(); + } + + if (!schemas.containsKey(SCHEMA_SORT)) { + schemas.put( + SCHEMA_SORT, + new ObjectSchema() + .addProperty("field", new StringSchema()) + .addProperty( + "direction", new StringSchema()._enum(java.util.List.of("asc", "desc")))); + } + + if (!schemas.containsKey(SCHEMA_META)) { + schemas.put( + SCHEMA_META, + new ObjectSchema() + .addProperty("serverTime", new StringSchema().format("date-time")) + .addProperty( + "sort", + new ArraySchema() + .items(new Schema<>().$ref(COMPONENTS_SCHEMAS + SCHEMA_SORT)))); + } + + if (!schemas.containsKey(SCHEMA_SERVICE_RESPONSE)) { + schemas.put( + SCHEMA_SERVICE_RESPONSE, + new ObjectSchema() + .addProperty(PROP_DATA, new ObjectSchema()) + .addProperty(PROP_META, new Schema<>().$ref(COMPONENTS_SCHEMAS + SCHEMA_META))); + } + + if (!schemas.containsKey(SCHEMA_SERVICE_RESPONSE_VOID)) { + schemas.put( + SCHEMA_SERVICE_RESPONSE_VOID, + new ObjectSchema() + .addProperty(PROP_DATA, new ObjectSchema()) + .addProperty(PROP_META, new Schema<>().$ref(COMPONENTS_SCHEMAS + SCHEMA_META))); + } + }; + } +} diff --git a/docs/images/architectural-diagram.png b/docs/images/architectural-diagram.png new file mode 100644 index 0000000..57d8cca Binary files /dev/null and b/docs/images/architectural-diagram.png differ diff --git a/docs/images/generated-client-wrapper-after.png b/docs/images/generated-client-wrapper-after.png index 37421ea..123b62e 100644 Binary files a/docs/images/generated-client-wrapper-after.png and b/docs/images/generated-client-wrapper-after.png differ diff --git a/docs/images/generated-client-wrapper-before.png b/docs/images/generated-client-wrapper-before.png index cfcc607..9609c38 100644 Binary files a/docs/images/generated-client-wrapper-before.png and b/docs/images/generated-client-wrapper-before.png differ diff --git a/docs/images/openapi-generics-cover.png b/docs/images/openapi-generics-cover.png index 46a7ec3..60f19b7 100644 Binary files a/docs/images/openapi-generics-cover.png and b/docs/images/openapi-generics-cover.png differ diff --git a/docs/images/swagger-customer-create.png b/docs/images/swagger-customer-create.png deleted file mode 100644 index c012671..0000000 Binary files a/docs/images/swagger-customer-create.png and /dev/null differ diff --git a/docs/index.md b/docs/index.md index 163ec4e..c102d0a 100644 --- a/docs/index.md +++ b/docs/index.md @@ -1,41 +1,24 @@ --- + layout: default title: Home nav_order: 1 ---- +------------ # Spring Boot OpenAPI Generics Clients Welcome! 👋 -This project demonstrates how to extend **OpenAPI Generator** with **generics-aware wrappers**, avoiding duplicated -response models and keeping client code clean and type-safe. +This project demonstrates a **modern, generics-aware OpenAPI client generation pattern** for Spring Boot 3.4+, featuring +the unified `{ data, meta }` response model and full **nested generic support** — from `ServiceResponse` to +`ServiceResponse>`. --- -## 🚩 Problem - -By default, **OpenAPI Generator does not support generics**. When backend teams use a generic response wrapper like -`ServiceResponse`, the generator produces **one full wrapper per endpoint**, duplicating fields such as `status`, -`message`, and `errors`. +## 💡 Overview -This causes: - -* ❌ Dozens of nearly identical classes -* ❌ High maintenance overhead -* ❌ Boilerplate code scattered across clients - ---- - -## 💡 Solution - -This project shows how to: - -* Mark wrapper schemas in OpenAPI using a small **Springdoc customizer**. -* Provide a **tiny Mustache template overlay** so the generator emits **thin shells** extending a reusable generic base. -* Preserve **compile-time type safety** while removing repetitive wrappers. - -Result: Instead of duplicating fields, the generator creates wrappers like: +Using Springdoc on the backend and OpenAPI Generator 7.16.0 on the client side, this setup enables seamless code +generation where all responses are **type-safe**, **clean**, and **boilerplate-free**. ```java public class ServiceResponseCustomerDto @@ -43,48 +26,111 @@ public class ServiceResponseCustomerDto } ``` +Each generated client wrapper now automatically supports nested generic envelopes such as: + +```java +ServiceClientResponse> +``` + --- -## ✅ Benefits +## ✅ Key Features -* Strong typing without boilerplate -* A single generic base (`ServiceClientResponse`) for all responses -* Easier maintenance — update the base once, all clients benefit -* Clean, consistent contracts across microservices +* **Unified response model:** `{ data, meta }` replaces legacy status/message/errors structure. +* **Nested generics support:** Handles both `ServiceResponse` and `ServiceResponse>`. +* **RFC 7807 compliant errors:** All non-2xx responses are mapped into `ProblemDetail` and thrown as + `ClientProblemException`. +* **Generics-aware OpenAPI Generator overlay:** Mustache templates produce thin, type-safe wrappers. +* **Simple integration:** Works with any Spring Boot service exposing `/v3/api-docs.yaml`. --- -## 📘 Adoption Guides +## 🧩 Architecture -Choose one of the following to integrate this pattern into your own project: - -* [Server-Side Adoption](adoption/server-side-adoption.md) -* [Client-Side Adoption](adoption/client-side-adoption.md) +``` +[customer-service] → publishes OpenAPI spec (/v3/api-docs.yaml) + │ + └──► [customer-service-client] → generates thin wrappers extending ServiceClientResponse + │ + └──► used by consumer microservices via adapters +``` --- ## 🚀 Quick Start ```bash -# Run the sample server +# Run the backend cd customer-service && mvn spring-boot:run -# Generate and build the client +# Generate the OpenAPI client cd ../customer-service-client && mvn clean install ``` -Generated wrappers can be found under: +Generated wrappers appear under: `target/generated-sources/openapi/src/gen/java` +Each class extends `ServiceClientResponse` and is compatible with the `{ data, meta }` response structure. + +--- + +## 🧱 Example Response + +```json +{ + "data": { + "customerId": 1, + "name": "Jane Doe", + "email": "jane@example.com" + }, + "meta": { + "serverTime": "2025-01-01T12:34:56Z", + "sort": [] + } +} +``` + +Client usage: + +```java +ServiceClientResponse response = api.getCustomer(1); +CustomerDto dto = response.getData(); +Instant serverTime = response.getMeta().serverTime(); +``` + --- -## 📂 References & Links +## ⚙️ Toolchain + +| Component | Version | Purpose | +|-----------------------|---------|----------------------------------| +| **Java** | 21 | Language baseline | +| **Spring Boot** | 3.4.10 | REST + OpenAPI provider | +| **Springdoc** | 2.8.13 | OpenAPI 3.1 integration | +| **OpenAPI Generator** | 7.16.0 | Generics-aware client generation | +| **HttpClient5** | 5.5 | Production-grade HTTP backend | + +--- + +## 📚 Learn More + +* [Server-Side Adoption](adoption/server-side-adoption.md) +* [Client-Side Adoption](adoption/client-side-adoption.md) + +--- + +## 🔗 References \ No newline at end of file + + +--- + +✅ With this setup, you get **end-to-end generics awareness**, clean `{ data, meta }` responses, nested generic wrappers, +and unified error handling — all generated automatically.