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 9457 ProblemDetail error model introduced in the updated customer-service / customer-service-client architecture.
🎯 Goals
- Generate thin, type‑safe wrappers extending
ServiceClientResponse<T>instead of duplicating envelopes. - Support nested generics such as
ServiceClientResponse<Page<CustomerDto>>. - Decode non‑2xx responses into RFC 9457 ProblemDetail and raise
ClientProblemException. - Allow seamless injection into Spring Boot apps using a pooled
RestClient.
⚙️ What You Get
- Generated wrappers per endpoint (e.g.,
ServiceResponseCustomerDto) extendingServiceClientResponse<T>. - Strong typing for
.getData()and.getMeta(). - Problem‑aware exception decoding (
ClientProblemException). - Spring configuration using
RestClientCustomizer+ Apache HttpClient5.
🧱 Prerequisites
- Java 21+
- Maven 3.9+
- A running OpenAPI provider exposing
/v3/api-docs.yaml(from your server‑side service)
🚀 Generate the Client
-
Download the OpenAPI spec:
curl -s http://localhost:8084/customer-service/v3/api-docs.yaml \ -o src/main/resources/customer-api-docs.yaml -
Build the client:
mvn clean install -
Inspect generated output:
target/generated-sources/openapi/src/gen/java- Look for classes like
ServiceResponseCustomerDtoextendingServiceClientResponse<CustomerDto>
🧹 Ignoring Redundant Generated DTOs
To prevent OpenAPI Generator from re-creating DTOs that already exist in the shared common package, add the following patterns to your .openapi-generator-ignore file. This avoids redundant model generation (e.g., Page, Meta, Sort, ServiceResponse) and keeps diffs minimal.
# --- Custom additions for generated DTO cleanup ---
**/src/gen/java/**/generated/dto/Page*.java
**/src/gen/java/**/generated/dto/ServiceResponse.java
**/src/gen/java/**/generated/dto/ServiceResponseVoid.java
**/src/gen/java/**/generated/dto/Meta.java
**/src/gen/java/**/generated/dto/Sort.java
Note: The paths above assume your
openapi-generator-maven-pluginusessourceFolder=src/gen/java. If your configuration differs, adjust the directory prefix accordingly.Also make sure your POM specifies the file override:
<ignoreFileOverride>${project.basedir}/.openapi-generator-ignore</ignoreFileOverride>
🧩 Core Classes (Shared Base)
Copy these into your client module under openapi/client/common:
ServiceClientResponse.java
package
<your.base>.openapi.client.common;
import java.util.Objects;
public class ServiceClientResponse<T> {
private T data;
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 + '}';
}
}
ClientMeta.java
package
<your.base>.openapi.client.common;
import java.time.Instant;
import java.util.List;
import <your.base>.openapi.client.common.sort.ClientSort;
public record ClientMeta(Instant serverTime, List<ClientSort> sort) {
}
Page.java
package
<your.base>.openapi.client.common;
import java.util.List;
public record Page<T>(List<T> content, int page, int size, long totalElements,
int totalPages, boolean hasNext, boolean hasPrev) {
}
ClientProblemException.java
package
<your.base>.openapi.client.common.error;
import io.github.bsayli.openapi.client.generated.dto.ProblemDetail;
public class ClientProblemException extends RuntimeException {
private final transient ProblemDetail problem;
private final int status;
public ClientProblemException(ProblemDetail problem, int status) {
super(problem != null ? problem.getTitle() + ": " + problem.getDetail() : "HTTP " + status);
this.problem = problem;
this.status = status;
}
public ProblemDetail getProblem() {
return problem;
}
public int getStatus() {
return status;
}
}
🧰 Mustache Template Overlay
Place templates under src/main/resources/openapi-templates/.
api_wrapper.mustache
import {{commonPackage}}.ServiceClientResponse;
{{#vendorExtensions.x-data-container}}
import {{commonPackage}}.{{vendorExtensions.x-data-container}};
{{/vendorExtensions.x-data-container}}
{{#vendorExtensions.x-class-extra-annotation}}
{{{vendorExtensions.x-class-extra-annotation}}}
{{/vendorExtensions.x-class-extra-annotation}}
public class {{classname}}
extends ServiceClientResponse<
{{#vendorExtensions.x-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}}
> {
}
This ensures wrappers extend the generic base, including nested containers.
🧩 Adapter Pattern (Recommended)
Encapsulate generated APIs behind your own adapter interface.
package
<your.base>.openapi.client.adapter;
import <your.base>.openapi.client.common.ServiceClientResponse;
import <your.base>.openapi.client.common.Page;
import <your.base>.openapi.client.generated.api.YourControllerApi;
import <your.base>.openapi.client.generated.dto .*;
public interface YourClientAdapter {
ServiceClientResponse<YourDto> getYourEntity(Integer id);
ServiceClientResponse<Page<YourDto>> listEntities();
ServiceClientResponse<YourDto> createEntity(YourCreateRequest req);
}
Then implement it using the generated API:
@Service
public class YourClientAdapterImpl implements YourClientAdapter {
private final YourControllerApi api;
public YourClientAdapterImpl(YourControllerApi api) {
this.api = api;
}
@Override
public ServiceClientResponse<YourDto> getYourEntity(Integer id) {
return api.getYourEntity(id);
}
@Override
public ServiceClientResponse<Page<YourDto>> listEntities() {
return api.getEntities();
}
@Override
public ServiceClientResponse<YourDto> createEntity(YourCreateRequest req) {
return api.createEntity(req);
}
}
🧠 Spring Boot Configuration
Production‑ready configuration using pooled Apache HttpClient5 and RestClientCustomizer.
@Configuration
public class YourApiClientConfig {
@Bean
RestClientCustomizer problemDetailStatusHandler(ObjectMapper om) {
return builder -> builder.defaultStatusHandler(
HttpStatusCode::isError,
(request, response) -> {
ProblemDetail pd = ProblemDetailSupport.extract(om, response);
throw new ClientProblemException(pd, response.getStatusCode().value());
});
}
@Bean(destroyMethod = "close")
CloseableHttpClient httpClient(@Value("${your.api.max-connections-total:64}") int total,
@Value("${your.api.max-connections-per-route:16}") int perRoute) {
var cm = PoolingHttpClientConnectionManagerBuilder.create()
.setMaxConnTotal(total).setMaxConnPerRoute(perRoute).build();
return HttpClients.custom()
.setConnectionManager(cm)
.evictExpiredConnections()
.evictIdleConnections(org.apache.hc.core5.util.TimeValue.ofSeconds(30))
.disableAutomaticRetries()
.setUserAgent("your-service-client")
.build();
}
@Bean
HttpComponentsClientHttpRequestFactory requestFactory(CloseableHttpClient http,
@Value("${your.api.connect-timeout-seconds:10}") long c,
@Value("${your.api.connection-request-timeout-seconds:10}") long r,
@Value("${your.api.read-timeout-seconds:15}") long t) {
var f = new HttpComponentsClientHttpRequestFactory(http);
f.setConnectTimeout(Duration.ofSeconds(c));
f.setConnectionRequestTimeout(Duration.ofSeconds(r));
f.setReadTimeout(Duration.ofSeconds(t));
return f;
}
@Bean
RestClient yourRestClient(RestClient.Builder builder, HttpComponentsClientHttpRequestFactory rf,
List<RestClientCustomizer> customizers) {
builder.requestFactory(rf);
if (customizers != null) customizers.forEach(c -> c.customize(builder));
return builder.build();
}
@Bean
ApiClient yourApiClient(RestClient rest, @Value("${your.api.base-url}") String baseUrl) {
return new ApiClient(rest).setBasePath(baseUrl);
}
@Bean
YourControllerApi yourControllerApi(ApiClient apiClient) {
return new YourControllerApi(apiClient);
}
}
application.properties
your.api.base-url=http://localhost:8084/your-service
your.api.max-connections-total=64
your.api.max-connections-per-route=16
your.api.connect-timeout-seconds=10
your.api.connection-request-timeout-seconds=10
your.api.read-timeout-seconds=15
🧪 Example Usage
var response = yourClientAdapter.getYourEntity(42);
var dto = response.getData();
var serverTime = response.getMeta().serverTime();
Error handling:
try{
yourClientAdapter.getYourEntity(999);
}catch(
ClientProblemException ex){
var pd = ex.getProblem();
System.err.
println(pd.getTitle() +": "+pd.
getDetail());
}
🧭 Folder Structure (Suggested)
your-service-client/
├─ src/main/java/<your/base>/openapi/client/common/
│ ├─ ServiceClientResponse.java
│ ├─ ClientMeta.java
│ ├─ Page.java
│ └─ error/ClientProblemException.java
├─ src/main/java/<your/base>/openapi/client/adapter/
│ ├─ YourClientAdapter.java
│ └─ YourClientAdapterImpl.java
├─ src/main/resources/openapi-templates/
│ └─ api_wrapper.mustache
├─ src/main/resources/customer-api-docs.yaml
└─ pom.xml
✅ Key Points
- Mirrors
{ data, meta }structure from the server side. - Fully supports nested generics and vendor extensions (
x-data-container,x-data-item). - Decodes non‑2xx responses into
ProblemDetailand throwsClientProblemException. - Modular design: adapters hide generated artifacts from your domain logic.
Your client is now fully aligned with the new generics‑aware and ProblemDetail‑compatible architecture.