Skip to content

Commit bbc1642

Browse files
committed
feat(customer-service): add full CRUD support with type-safe generic responses
- Implemented complete CRUD endpoints in CustomerController - Introduced dedicated DTOs for create, update, list, and delete responses - Added CustomerControllerAdvice for consistent error handling - Enhanced unit tests for controller and service layers - Updated README.md with CRUD usage examples - Bumped service version to 0.3.0 to reflect new functionality
1 parent b5baedf commit bbc1642

37 files changed

+1212
-382
lines changed

README.md

Lines changed: 11 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -13,14 +13,16 @@
1313
</p>
1414

1515
**Type-safe client generation with Spring Boot & OpenAPI using generics.**
16-
This repository demonstrates how to teach OpenAPI Generator to work with generics in order to avoid boilerplate, reduce duplicated wrappers, and keep client code clean.
16+
This repository demonstrates how to teach OpenAPI Generator to work with generics in order to avoid boilerplate, reduce
17+
duplicated wrappers, and keep client code clean.
1718

1819
---
1920

2021
## 🚀 Problem Statement
2122

2223
Most backend teams standardize responses with a generic wrapper like `ApiResponse<T>`.
23-
However, **OpenAPI Generator does not natively support generics** — instead, it generates one wrapper per endpoint (duplicating fields like `status`, `message`, and `errors`).
24+
However, **OpenAPI Generator does not natively support generics** — instead, it generates one wrapper per endpoint
25+
(duplicating fields like `status`, `message`, and `errors`).
2426

2527
This creates:
2628

@@ -60,7 +62,7 @@ Use the generated API:
6062

6163
```java
6264
ApiClientResponse<CustomerCreateResponse> response =
63-
customerControllerApi.create(request);
65+
customerControllerApi.createCustomer(request);
6466
```
6567

6668
### 🖼 Demo Swagger Screenshot
@@ -75,6 +77,8 @@ And here’s the corresponding generated client class showing the generic wrappe
7577

7678
![Generated client wrapper](docs/images/generated-client-wrapper.png)
7779

80+
---
81+
7882
## 🛠 Tech Stack & Features
7983

8084
* 🚀 **Java 21** — modern language features
@@ -92,7 +96,7 @@ And here’s the corresponding generated client class showing the generic wrappe
9296
spring-boot-openapi-generics-clients/
9397
├── customer-service/ # Sample Spring Boot microservice (API producer)
9498
├── customer-service-client/ # Generated client using custom templates
95-
└── README.md
99+
└── README.md # Root documentation
96100
```
97101

98102
---
@@ -149,7 +153,7 @@ This pattern is useful when:
149153

150154
```java
151155
ApiClientResponse<CustomerCreateResponse> response =
152-
customerControllerApi.create(request);
156+
customerControllerApi.createCustomer(request);
153157
```
154158

155159
---
@@ -174,5 +178,6 @@ If parameters include spaces or special characters, wrap them in quotes `"..."`.
174178

175179
## 💬 Feedback
176180

177-
If you spot any mistakes in this README or have questions about the project, feel free to open an issue or start a discussion.
181+
If you spot any mistakes in this README or have questions about the project, feel free to open an issue or start a
182+
discussion.
178183
I’m happy to improve the documentation and clarify concepts further!

customer-service-client/README.md

Lines changed: 39 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,20 @@
11
# customer-service-client
22

3-
Generated Java client for the demo **customer-service**, showcasing **type‑safe generic responses** with OpenAPI + a custom Mustache template (wrapping payloads in a reusable `ApiClientResponse<T>`).
3+
Generated Java client for the demo **customer-service**, showcasing **type-safe generic responses** with OpenAPI + a
4+
custom Mustache template (wrapping payloads in a reusable `ApiClientResponse<T>`).
45

5-
This module demonstrates how to evolve OpenAPI Generator with minimal customization to support generic response envelopes — avoiding duplicated wrappers and preserving strong typing.
6+
This module demonstrates how to evolve OpenAPI Generator with minimal customization to support generic response
7+
envelopes — avoiding duplicated wrappers and preserving strong typing.
68

79
---
810

911
## ✅ What You Get
1012

1113
* Generated code using **OpenAPI Generator** (`restclient` with Spring Framework `RestClient`).
1214
* A reusable generic base: `io.github.bsayli.openapi.client.common.ApiClientResponse<T>`.
13-
* Thin wrappers per endpoint (e.g. `ApiResponseCustomerCreateResponse`) that extend the base.
14-
* Spring Boot configuration to autoexpose the client as beans.
15-
* A focused integration test using **OkHttp MockWebServer**.
15+
* Thin wrappers per endpoint (e.g. `ApiResponseCustomerCreateResponse`, `ApiResponseCustomerUpdateResponse`).
16+
* Spring Boot configuration to auto-expose the client as beans.
17+
* Focused integration tests using **OkHttp MockWebServer** covering all CRUD endpoints.
1618

1719
---
1820

@@ -23,14 +25,14 @@ This module demonstrates how to evolve OpenAPI Generator with minimal customizat
2325
```bash
2426
cd customer-service
2527
mvn spring-boot:run
26-
# Service base URL: http://localhost:8084/customer
28+
# Service base URL: http://localhost:8084/customer-service
2729
```
2830

2931
2. **Pull the OpenAPI spec into this module**
3032

3133
```bash
3234
cd customer-service-client
33-
curl -s http://localhost:8084/customer/v3/api-docs.yaml \
35+
curl -s http://localhost:8084/customer-service/v3/api-docs.yaml \
3436
-o src/main/resources/customer-api-docs.yaml
3537
```
3638

@@ -83,7 +85,7 @@ public class CustomerApiClientConfig {
8385
**application.properties:**
8486

8587
```properties
86-
customer.api.base-url=http://localhost:8084/customer
88+
customer.api.base-url=http://localhost:8084/customer-service
8789
```
8890

8991
**Usage example:**
@@ -97,19 +99,19 @@ public void createCustomer() {
9799
.name("Jane Doe")
98100
.email("[email protected]");
99101

100-
var resp = customerApi.create(req); // ApiResponseCustomerCreateResponse
102+
var resp = customerApi.createCustomer(req); // ApiResponseCustomerCreateResponse
101103

102-
System.out.println(resp.getStatus()); // 201
104+
System.out.println(resp.getStatus()); // 201
103105
System.out.println(resp.getData().getCustomer().getName()); // "Jane Doe"
104106
}
105107
```
106108

107109
### Option B — Manual Wiring (no Spring context)
108110

109111
```java
110-
var rest = RestClient.builder().baseUrl("http://localhost:8084/customer").build();
112+
var rest = RestClient.builder().baseUrl("http://localhost:8084/customer-service").build();
111113
var apiClient = new io.github.bsayli.openapi.client.generated.invoker.ApiClient(rest)
112-
.setBasePath("http://localhost:8084/customer");
114+
.setBasePath("http://localhost:8084/customer-service");
113115
var customerApi = new io.github.bsayli.openapi.client.generated.api.CustomerControllerApi(apiClient);
114116
```
115117

@@ -125,8 +127,7 @@ package io.github.bsayli.openapi.client.adapter.impl;
125127
import io.github.bsayli.openapi.client.adapter.CustomerClientAdapter;
126128
import io.github.bsayli.openapi.client.common.ApiClientResponse;
127129
import io.github.bsayli.openapi.client.generated.api.CustomerControllerApi;
128-
import io.github.bsayli.openapi.client.generated.dto.CustomerCreateRequest;
129-
import io.github.bsayli.openapi.client.generated.dto.CustomerCreateResponse;
130+
import io.github.bsayli.openapi.client.generated.dto.*;
130131
import org.springframework.stereotype.Service;
131132

132133
@Service
@@ -139,8 +140,28 @@ public class CustomerClientAdapterImpl implements CustomerClientAdapter {
139140
}
140141

141142
@Override
142-
public ApiClientResponse<CustomerCreateResponse> create(CustomerCreateRequest request) {
143-
return customerControllerApi.create(request);
143+
public ApiClientResponse<CustomerCreateResponse> createCustomer(CustomerCreateRequest request) {
144+
return customerControllerApi.createCustomer(request);
145+
}
146+
147+
@Override
148+
public ApiClientResponse<CustomerDto> getCustomer(Integer customerId) {
149+
return customerControllerApi.getCustomer(customerId);
150+
}
151+
152+
@Override
153+
public ApiClientResponse<CustomerListResponse> getCustomers() {
154+
return customerControllerApi.getCustomers();
155+
}
156+
157+
@Override
158+
public ApiClientResponse<CustomerUpdateResponse> updateCustomer(Integer customerId, CustomerUpdateRequest request) {
159+
return customerControllerApi.updateCustomer(customerId, request);
160+
}
161+
162+
@Override
163+
public ApiClientResponse<CustomerDeleteResponse> deleteCustomer(Integer customerId) {
164+
return customerControllerApi.deleteCustomer(customerId);
144165
}
145166
}
146167
```
@@ -149,6 +170,7 @@ This ensures:
149170

150171
* Generated code stays isolated.
151172
* Business code depends only on the adapter interface.
173+
* Naming conventions are consistent with the service (createCustomer, getCustomer, getCustomers, updateCustomer, deleteCustomer).
152174

153175
---
154176

@@ -177,7 +199,7 @@ Integration test with MockWebServer:
177199
mvn -q -DskipITs=false test
178200
```
179201

180-
It enqueues a `201` response and asserts correct mapping into `ApiResponseCustomerCreateResponse`.
202+
It enqueues responses for **all CRUD operations** and asserts correct mapping into the respective wrappers (e.g. `ApiResponseCustomerCreateResponse`, `ApiResponseCustomerUpdateResponse`).
181203

182204
---
183205

customer-service-client/pom.xml

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66

77
<groupId>io.github.bsayli</groupId>
88
<artifactId>customer-service-client</artifactId>
9-
<version>0.2.1</version>
9+
<version>0.3.0</version>
1010
<name>customer-service-client</name>
1111
<description>Generated client (RestClient) using generics-aware OpenAPI templates</description>
1212
<packaging>jar</packaging>
@@ -129,7 +129,8 @@
129129
<invokerPackage>io.github.bsayli.openapi.client.generated.invoker</invokerPackage>
130130

131131
<!-- Use our custom templates that implement generics-aware wrappers -->
132-
<templateDirectory>${project.basedir}/src/main/resources/openapi-templates</templateDirectory>
132+
<templateDirectory>${project.basedir}/src/main/resources/openapi-templates
133+
</templateDirectory>
133134

134135
<!-- Keep output lean -->
135136
<generateSupportingFiles>true</generateSupportingFiles>
Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,12 @@
11
package io.github.bsayli.openapi.client.adapter;
22

33
import io.github.bsayli.openapi.client.common.ApiClientResponse;
4-
import io.github.bsayli.openapi.client.generated.dto.CustomerCreateRequest;
5-
import io.github.bsayli.openapi.client.generated.dto.CustomerCreateResponse;
4+
import io.github.bsayli.openapi.client.generated.dto.*;
65

76
public interface CustomerClientAdapter {
8-
ApiClientResponse<CustomerCreateResponse> create(CustomerCreateRequest request);
7+
ApiClientResponse<CustomerCreateResponse> createCustomer(CustomerCreateRequest request);
8+
ApiClientResponse<CustomerDto> getCustomer(Integer customerId);
9+
ApiClientResponse<CustomerListResponse> getCustomers();
10+
ApiClientResponse<CustomerUpdateResponse> updateCustomer(Integer customerId, CustomerUpdateRequest request);
11+
ApiClientResponse<CustomerDeleteResponse> deleteCustomer(Integer customerId);
912
}

customer-service-client/src/main/java/io/github/bsayli/openapi/client/adapter/config/CustomerApiClientConfig.java

Lines changed: 15 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -10,20 +10,20 @@
1010
@Configuration
1111
public class CustomerApiClientConfig {
1212

13-
@Bean
14-
public RestClient customerRestClient(RestClient.Builder builder,
15-
@Value("${customer.api.base-url}") String baseUrl) {
16-
return builder.baseUrl(baseUrl).build();
17-
}
13+
@Bean
14+
public RestClient customerRestClient(
15+
RestClient.Builder builder, @Value("${customer.api.base-url}") String baseUrl) {
16+
return builder.baseUrl(baseUrl).build();
17+
}
1818

19-
@Bean
20-
public ApiClient customerApiClient(RestClient customerRestClient,
21-
@Value("${customer.api.base-url}") String baseUrl) {
22-
return new ApiClient(customerRestClient).setBasePath(baseUrl);
23-
}
19+
@Bean
20+
public ApiClient customerApiClient(
21+
RestClient customerRestClient, @Value("${customer.api.base-url}") String baseUrl) {
22+
return new ApiClient(customerRestClient).setBasePath(baseUrl);
23+
}
2424

25-
@Bean
26-
public CustomerControllerApi customerControllerApi(ApiClient customerApiClient) {
27-
return new CustomerControllerApi(customerApiClient);
28-
}
29-
}
25+
@Bean
26+
public CustomerControllerApi customerControllerApi(ApiClient customerApiClient) {
27+
return new CustomerControllerApi(customerApiClient);
28+
}
29+
}

customer-service-client/src/main/java/io/github/bsayli/openapi/client/adapter/impl/CustomerClientAdapterImpl.java

Lines changed: 34 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -5,19 +5,45 @@
55
import io.github.bsayli.openapi.client.generated.api.CustomerControllerApi;
66
import io.github.bsayli.openapi.client.generated.dto.CustomerCreateRequest;
77
import io.github.bsayli.openapi.client.generated.dto.CustomerCreateResponse;
8+
import io.github.bsayli.openapi.client.generated.dto.CustomerDeleteResponse;
9+
import io.github.bsayli.openapi.client.generated.dto.CustomerDto;
10+
import io.github.bsayli.openapi.client.generated.dto.CustomerListResponse;
11+
import io.github.bsayli.openapi.client.generated.dto.CustomerUpdateRequest;
12+
import io.github.bsayli.openapi.client.generated.dto.CustomerUpdateResponse;
813
import org.springframework.stereotype.Service;
914

1015
@Service
1116
public class CustomerClientAdapterImpl implements CustomerClientAdapter {
1217

13-
private final CustomerControllerApi customerControllerApi;
18+
private final CustomerControllerApi customerControllerApi;
1419

15-
public CustomerClientAdapterImpl(CustomerControllerApi customerControllerApi) {
16-
this.customerControllerApi = customerControllerApi;
17-
}
20+
public CustomerClientAdapterImpl(CustomerControllerApi customerControllerApi) {
21+
this.customerControllerApi = customerControllerApi;
22+
}
1823

19-
@Override
20-
public ApiClientResponse<CustomerCreateResponse> create(CustomerCreateRequest request) {
21-
return customerControllerApi.create(request);
22-
}
24+
@Override
25+
public ApiClientResponse<CustomerCreateResponse> createCustomer(CustomerCreateRequest request) {
26+
return customerControllerApi.createCustomer(request);
27+
}
28+
29+
@Override
30+
public ApiClientResponse<CustomerDto> getCustomer(Integer customerId) {
31+
return customerControllerApi.getCustomer(customerId);
32+
}
33+
34+
@Override
35+
public ApiClientResponse<CustomerListResponse> getCustomers() {
36+
return customerControllerApi.getCustomers();
37+
}
38+
39+
@Override
40+
public ApiClientResponse<CustomerUpdateResponse> updateCustomer(
41+
Integer customerId, CustomerUpdateRequest request) {
42+
return customerControllerApi.updateCustomer(customerId, request);
43+
}
44+
45+
@Override
46+
public ApiClientResponse<CustomerDeleteResponse> deleteCustomer(Integer customerId) {
47+
return customerControllerApi.deleteCustomer(customerId);
48+
}
2349
}
Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,3 @@
11
package io.github.bsayli.openapi.client.common;
22

3-
public record ApiClientError(String errorCode, String message) {
4-
}
3+
public record ApiClientError(String errorCode, String message) {}

0 commit comments

Comments
 (0)