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
[](https://github.com/bsayli/spring-boot-openapi-generics-clients/actions/workflows/build.yml)
-[](https://github.com/bsayli/spring-boot-openapi-generics-clients/releases/latest)
+[](https://github.com/bsayli/spring-boot-openapi-generics-clients/releases/latest)
[](https://codecov.io/gh/bsayli/spring-boot-openapi-generics-clients)
[](https://openjdk.org/projects/jdk/21/)
[](https://spring.io/projects/spring-boot)
@@ -13,356 +13,296 @@
- 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
+
+
+
+
+ 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.
-
+---
-### 🖼 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):**
-
+
+
+
+ Each endpoint generated its own full response model — duplicated data and meta fields across classes.
+
**After (thin generic wrapper):**
-
-
----
-
-## ✅ 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
-
----
+
+
+
+ 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 @@
[](https://openapi-generator.tech/)
[](../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
+
+