Skip to content

Commit 3797e58

Browse files
committed
feat(customer-service): add optional support for extra class annotations in OpenAPI wrappers
Introduced vendor extension `x-class-extra-annotation` to allow attaching optional annotations (e.g., Jackson, Lombok) on generated wrapper classes. This is disabled by default and only applied when configured via `app.openapi.wrapper.class-extra-annotation`.
1 parent 1d6469a commit 3797e58

File tree

7 files changed

+140
-9
lines changed

7 files changed

+140
-9
lines changed

customer-service-client/README.md

Lines changed: 34 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -434,7 +434,40 @@ It enqueues responses for **all CRUD operations** and asserts correct mapping in
434434
curl -s http://localhost:8084/customer-service/v3/api-docs.yaml \
435435
-o src/main/resources/customer-api-docs.yaml
436436
mvn -q clean install
437-
```
437+
```
438+
439+
---
440+
441+
### ⚙️ Optional: Extra Class Annotations
442+
443+
The generator also supports an **optional vendor extension** to attach annotations directly on top of the generated
444+
wrapper classes.
445+
446+
For example, if the OpenAPI schema contains:
447+
448+
```yaml
449+
components:
450+
schemas:
451+
ServiceResponseCustomerDeleteResponse:
452+
type: object
453+
x-api-wrapper: true
454+
x-api-wrapper-datatype: CustomerDeleteResponse
455+
x-class-extra-annotation: "@com.fasterxml.jackson.annotation.JsonIgnoreProperties(ignoreUnknown = true)"
456+
```
457+
458+
The generated wrapper becomes:
459+
460+
```java
461+
462+
@com.fasterxml.jackson.annotation.JsonIgnoreProperties(ignoreUnknown = true)
463+
public class ServiceResponseCustomerDeleteResponse
464+
extends io.github.bsayli.openapi.client.common.ServiceClientResponse<CustomerDeleteResponse> {
465+
}
466+
```
467+
468+
By default this feature is **not required** and we recommend using the plain `ServiceClientResponse<T>` wrappers
469+
as-is. However, the hook is available if your project needs to enforce additional annotations (e.g., Jackson, Lombok)
470+
on top of generated wrapper classes.
438471

439472
---
440473

customer-service/README.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -218,6 +218,10 @@ mvn test
218218
* Provides **unit tests** for both controller and service layers.
219219
* Profiles: `local` (default) and `dev` available — can be extended per environment.
220220
* Focused on clarity and minimal setup.
221+
* Optional: You can attach extra annotations (e.g., Jackson) to generated wrapper classes by setting
222+
`app.openapi.wrapper.class-extra-annotation` in `application.yml`.
223+
See [customer-service-client README](../customer-service-client/README.md#-optional-extra-class-annotations) for
224+
details.
221225

222226
---
223227

customer-service/src/main/java/io/github/bsayli/customerservice/common/openapi/ApiResponseSchemaFactory.java

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,15 +11,25 @@ public final class ApiResponseSchemaFactory {
1111
private ApiResponseSchemaFactory() {}
1212

1313
public static Schema<?> createComposedWrapper(String dataRefName) {
14+
return createComposedWrapper(dataRefName, null);
15+
}
16+
17+
public static Schema<?> createComposedWrapper(String dataRefName, String classExtraAnnotation) {
1418
var schema = new ComposedSchema();
1519
schema.setAllOf(
1620
List.of(
1721
new Schema<>().$ref("#/components/schemas/" + SCHEMA_SERVICE_RESPONSE),
1822
new ObjectSchema()
1923
.addProperty(
2024
PROP_DATA, new Schema<>().$ref("#/components/schemas/" + dataRefName))));
25+
2126
schema.addExtension(EXT_API_WRAPPER, true);
2227
schema.addExtension(EXT_API_WRAPPER_DATATYPE, dataRefName);
28+
29+
if (classExtraAnnotation != null && !classExtraAnnotation.isBlank()) {
30+
schema.addExtension(EXT_CLASS_EXTRA_ANNOTATION, classExtraAnnotation);
31+
}
32+
2333
return schema;
2434
}
2535
}

customer-service/src/main/java/io/github/bsayli/customerservice/common/openapi/OpenApiSchemas.java

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ public final class OpenApiSchemas {
1717
// Vendor extensions
1818
public static final String EXT_API_WRAPPER = "x-api-wrapper";
1919
public static final String EXT_API_WRAPPER_DATATYPE = "x-api-wrapper-datatype";
20+
public static final String EXT_CLASS_EXTRA_ANNOTATION = "x-class-extra-annotation";
2021

2122
private OpenApiSchemas() {}
2223
}

customer-service/src/main/java/io/github/bsayli/customerservice/common/openapi/autoreg/AutoWrapperSchemaCustomizer.java

Lines changed: 16 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -5,10 +5,10 @@
55
import io.github.bsayli.customerservice.common.openapi.introspector.ResponseTypeIntrospector;
66
import java.util.Collections;
77
import java.util.LinkedHashSet;
8-
import java.util.Map;
98
import java.util.Set;
109
import org.springdoc.core.customizers.OpenApiCustomizer;
1110
import org.springframework.beans.factory.ListableBeanFactory;
11+
import org.springframework.beans.factory.annotation.Value;
1212
import org.springframework.context.annotation.Bean;
1313
import org.springframework.context.annotation.Configuration;
1414
import org.springframework.web.method.HandlerMethod;
@@ -18,20 +18,28 @@
1818
public class AutoWrapperSchemaCustomizer {
1919

2020
private final Set<String> dataRefs;
21+
private final String classExtraAnnotation;
2122

2223
public AutoWrapperSchemaCustomizer(
23-
ListableBeanFactory beanFactory, ResponseTypeIntrospector introspector) {
24+
ListableBeanFactory beanFactory,
25+
ResponseTypeIntrospector introspector,
26+
@Value("${app.openapi.wrapper.class-extra-annotation:}") String classExtraAnnotation) {
27+
2428
Set<String> refs = new LinkedHashSet<>();
25-
Map<String, RequestMappingHandlerMapping> mappings =
26-
beanFactory.getBeansOfType(RequestMappingHandlerMapping.class);
27-
mappings
29+
beanFactory
30+
.getBeansOfType(RequestMappingHandlerMapping.class)
2831
.values()
2932
.forEach(
3033
rmh ->
3134
rmh.getHandlerMethods().values().stream()
3235
.map(HandlerMethod::getMethod)
3336
.forEach(m -> introspector.extractDataRefName(m).ifPresent(refs::add)));
37+
3438
this.dataRefs = Collections.unmodifiableSet(refs);
39+
this.classExtraAnnotation =
40+
(classExtraAnnotation == null || classExtraAnnotation.isBlank())
41+
? null
42+
: classExtraAnnotation;
3543
}
3644

3745
@Bean
@@ -42,7 +50,9 @@ public OpenApiCustomizer autoResponseWrappers() {
4250
String name = OpenApiSchemas.SCHEMA_SERVICE_RESPONSE + ref;
4351
openApi
4452
.getComponents()
45-
.addSchemas(name, ApiResponseSchemaFactory.createComposedWrapper(ref));
53+
.addSchemas(
54+
name,
55+
ApiResponseSchemaFactory.createComposedWrapper(ref, classExtraAnnotation));
4656
});
4757
}
4858
}

customer-service/src/main/resources/application.yml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,8 @@ app:
2424
openapi:
2525
version: @project.version@
2626
base-url: "http://localhost:${server.port}${server.servlet.context-path:}"
27+
#wrapper:
28+
#class-extra-annotation: "@com.fasterxml.jackson.annotation.JsonIgnoreProperties(ignoreUnknown = true)"
2729

2830
springdoc:
2931
default-consumes-media-type: application/json

customer-service/src/test/java/io/github/bsayli/customerservice/common/openapi/autoreg/AutoWrapperSchemaCustomizerTest.java

Lines changed: 73 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -54,7 +54,7 @@ void registersSchemas_forDiscoveredRefs() throws Exception {
5454
};
5555
});
5656

57-
var customizerCfg = new AutoWrapperSchemaCustomizer(beanFactory, introspector);
57+
var customizerCfg = new AutoWrapperSchemaCustomizer(beanFactory, introspector, null);
5858
OpenApiCustomizer customizer = customizerCfg.autoResponseWrappers();
5959

6060
var openAPI = new OpenAPI().components(new Components());
@@ -79,7 +79,7 @@ void noRefs_noSchemasAdded() {
7979

8080
when(beanFactory.getBeansOfType(RequestMappingHandlerMapping.class)).thenReturn(Map.of());
8181

82-
var customizerCfg = new AutoWrapperSchemaCustomizer(beanFactory, introspector);
82+
var customizerCfg = new AutoWrapperSchemaCustomizer(beanFactory, introspector, null);
8383
OpenApiCustomizer customizer = customizerCfg.autoResponseWrappers();
8484

8585
var openAPI = new OpenAPI().components(new Components());
@@ -92,6 +92,77 @@ void noRefs_noSchemasAdded() {
9292
"No schemas should be added when no refs exist");
9393
}
9494

95+
@Test
96+
@DisplayName("Adds x-class-extra-annotation when classExtraAnnotation is provided")
97+
void addsClassExtraAnnotation_whenConfigured() throws Exception {
98+
var beanFactory = mock(ListableBeanFactory.class);
99+
var handlerMapping = mock(RequestMappingHandlerMapping.class);
100+
var introspector = mock(ResponseTypeIntrospector.class);
101+
102+
var controller = new SampleController();
103+
Method foo = SampleController.class.getMethod("foo");
104+
105+
var handlerMap = new LinkedHashMap<RequestMappingInfo, HandlerMethod>();
106+
handlerMap.put(mock(RequestMappingInfo.class), new HandlerMethod(controller, foo));
107+
108+
when(handlerMapping.getHandlerMethods()).thenReturn(handlerMap);
109+
when(beanFactory.getBeansOfType(RequestMappingHandlerMapping.class))
110+
.thenReturn(Map.of("rmh", handlerMapping));
111+
when(introspector.extractDataRefName(any(Method.class))).thenReturn(Optional.of("FooRef"));
112+
113+
String classExtraAnn =
114+
"@com.fasterxml.jackson.annotation.JsonIgnoreProperties(ignoreUnknown = true)";
115+
116+
var customizerCfg = new AutoWrapperSchemaCustomizer(beanFactory, introspector, classExtraAnn);
117+
OpenApiCustomizer customizer = customizerCfg.autoResponseWrappers();
118+
119+
var openAPI = new OpenAPI().components(new Components());
120+
customizer.customise(openAPI);
121+
122+
var schemaName = OpenApiSchemas.SCHEMA_SERVICE_RESPONSE + "FooRef";
123+
var schema = openAPI.getComponents().getSchemas().get(schemaName);
124+
assertNotNull(schema, "schema must exist for FooRef");
125+
assertNotNull(schema.getExtensions(), "extensions map must be initialized");
126+
assertEquals(
127+
classExtraAnn,
128+
schema.getExtensions().get(OpenApiSchemas.EXT_CLASS_EXTRA_ANNOTATION),
129+
"x-class-extra-annotation must equal provided value");
130+
}
131+
132+
@Test
133+
@DisplayName("Does not add x-class-extra-annotation when classExtraAnnotation is blank")
134+
void doesNotAddClassExtraAnnotation_whenBlank() throws Exception {
135+
var beanFactory = mock(ListableBeanFactory.class);
136+
var handlerMapping = mock(RequestMappingHandlerMapping.class);
137+
var introspector = mock(ResponseTypeIntrospector.class);
138+
139+
var controller = new SampleController();
140+
Method foo = SampleController.class.getMethod("foo");
141+
142+
var handlerMap = new LinkedHashMap<RequestMappingInfo, HandlerMethod>();
143+
handlerMap.put(mock(RequestMappingInfo.class), new HandlerMethod(controller, foo));
144+
145+
when(handlerMapping.getHandlerMethods()).thenReturn(handlerMap);
146+
when(beanFactory.getBeansOfType(RequestMappingHandlerMapping.class))
147+
.thenReturn(Map.of("rmh", handlerMapping));
148+
when(introspector.extractDataRefName(any(Method.class))).thenReturn(Optional.of("FooRef"));
149+
150+
var customizerCfg = new AutoWrapperSchemaCustomizer(beanFactory, introspector, " ");
151+
OpenApiCustomizer customizer = customizerCfg.autoResponseWrappers();
152+
153+
var openAPI = new OpenAPI().components(new Components());
154+
customizer.customise(openAPI);
155+
156+
var schemaName = OpenApiSchemas.SCHEMA_SERVICE_RESPONSE + "FooRef";
157+
var schema = openAPI.getComponents().getSchemas().get(schemaName);
158+
assertNotNull(schema, "schema must exist for FooRef");
159+
160+
var ext = schema.getExtensions();
161+
assertTrue(
162+
ext == null || !ext.containsKey(OpenApiSchemas.EXT_CLASS_EXTRA_ANNOTATION),
163+
"x-class-extra-annotation must not be present when blank");
164+
}
165+
95166
static class SampleController {
96167
public String foo() {
97168
return "ok";

0 commit comments

Comments
 (0)