Skip to content

Commit b32faa0

Browse files
authored
Type safe error handling in kotlin spring server with useSealedResponseInterfaces flag (#23003)
* Add type safe returns * Bugfixes / adding tests * Add sealed interfaces sample to github hook
1 parent 22e1d6a commit b32faa0

33 files changed

Lines changed: 1968 additions & 1 deletion

File tree

.github/workflows/samples-kotlin-server-jdk17.yaml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ on:
1010
- 'samples/server/petstore/kotlin-springboot-*/**'
1111
- 'samples/server/petstore/kotlin-server-required-and-nullable-properties/**'
1212
- 'samples/server/petstore/kotlin-spring-declarative*/**'
13+
- 'samples/server/petstore/kotlin-spring-sealed-interfaces/**'
1314
# comment out due to gradle build failure
1415
# - samples/server/petstore/kotlin-spring-default/**
1516
pull_request:
@@ -21,6 +22,7 @@ on:
2122
- 'samples/server/petstore/kotlin-springboot-*/**'
2223
- 'samples/server/petstore/kotlin-server-required-and-nullable-properties/**'
2324
- 'samples/server/petstore/kotlin-spring-declarative*/**'
25+
- 'samples/server/petstore/kotlin-spring-sealed-interfaces/**'
2426
# comment out due to gradle build failure
2527
# - samples/server/petstore/kotlin-spring-default/**
2628

@@ -57,6 +59,7 @@ jobs:
5759
- samples/server/petstore/kotlin-spring-declarative-interface-reactive-coroutines
5860
- samples/server/petstore/kotlin-spring-declarative-interface-reactive-reactor-wrapped
5961
- samples/server/petstore/kotlin-spring-declarative-interface-wrapped
62+
- samples/server/petstore/kotlin-spring-sealed-interfaces
6063
# comment out due to gradle build failure
6164
# - samples/server/petstore/kotlin-spring-default/
6265
steps:
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
generatorName: kotlin-spring
2+
outputDir: samples/server/petstore/kotlin-spring-sealed-interfaces
3+
inputSpec: modules/openapi-generator/src/test/resources/3_0/petstore.yaml
4+
templateDir: modules/openapi-generator/src/main/resources/kotlin-spring
5+
additionalProperties:
6+
useSealedResponseInterfaces: true
7+
interfaceOnly: true
8+
dateLibrary: java8
9+
useSpringBoot3: true
10+
reactive: false
11+
documentationProvider: none

docs/generators/kotlin-spring.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,7 @@ These options may be applied as additional-properties (cli) or configOptions (pl
5959
|useFeignClientUrl|Whether to generate Feign client with url parameter.| |true|
6060
|useFlowForArrayReturnType|Whether to use Flow for array/collection return types when reactive is enabled. If false, will use List instead.| |true|
6161
|useResponseEntity|Whether (when false) to return actual type (e.g. List<Fruit>) and handle non-happy path responses via exceptions flow or (when true) return entire ResponseEntity (e.g. ResponseEntity<List<Fruit>>). If disabled, method are annotated using a @ResponseStatus annotation, which has the status of the first response declared in the Api definition| |true|
62+
|useSealedResponseInterfaces|Generate sealed interfaces for endpoint responses that all possible response types implement. Allows controllers to return any valid response type in a type-safe manner (e.g., sealed interface CreateUserResponse implemented by User, ConflictResponse, ErrorResponse)| |false|
6263
|useSpringBoot3|Generate code and provide dependencies for use with Spring Boot ≥ 3 (use jakarta instead of javax in imports). Enabling this option will also enable `useJakartaEe`.| |false|
6364
|useSwaggerUI|Open the OpenApi specification in swagger-ui. Will also import and configure needed dependencies| |true|
6465
|useTags|Whether to use tags for creating interface and controller class names| |false|

modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/KotlinSpringServerCodegen.java

Lines changed: 154 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -97,6 +97,7 @@ public class KotlinSpringServerCodegen extends AbstractKotlinCodegen
9797
public static final String USE_REQUEST_MAPPING_ON_CONTROLLER = "useRequestMappingOnController";
9898
public static final String USE_REQUEST_MAPPING_ON_INTERFACE = "useRequestMappingOnInterface";
9999
public static final String AUTO_X_SPRING_PAGINATED = "autoXSpringPaginated";
100+
public static final String USE_SEALED_RESPONSE_INTERFACES = "useSealedResponseInterfaces";
100101

101102
@Getter
102103
public enum DeclarativeInterfaceReactiveMode {
@@ -161,13 +162,19 @@ public String getDescription() {
161162
@Setter private DeclarativeInterfaceReactiveMode declarativeInterfaceReactiveMode = DeclarativeInterfaceReactiveMode.coroutines;
162163
@Setter private boolean useResponseEntity = true;
163164
@Setter private boolean autoXSpringPaginated = false;
165+
@Setter private boolean useSealedResponseInterfaces = false;
164166

165167
@Getter @Setter
166168
protected boolean useSpringBoot3 = false;
167169
protected RequestMappingMode requestMappingMode = RequestMappingMode.controller;
168170
private DocumentationProvider documentationProvider;
169171
private AnnotationLibrary annotationLibrary;
170172

173+
// Map to track which models implement which sealed response interfaces
174+
private Map<String, List<String>> modelToSealedInterfaces = new HashMap<>();
175+
private Map<String, String> sealedInterfaceToOperationId = new HashMap<>();
176+
private boolean sealedInterfacesFileWritten = false;
177+
171178
public KotlinSpringServerCodegen() {
172179
super();
173180

@@ -250,6 +257,9 @@ public KotlinSpringServerCodegen() {
250257
addSwitch(USE_RESPONSE_ENTITY,
251258
"Whether (when false) to return actual type (e.g. List<Fruit>) and handle non-happy path responses via exceptions flow or (when true) return entire ResponseEntity (e.g. ResponseEntity<List<Fruit>>). If disabled, method are annotated using a @ResponseStatus annotation, which has the status of the first response declared in the Api definition",
252259
useResponseEntity);
260+
addSwitch(USE_SEALED_RESPONSE_INTERFACES,
261+
"Generate sealed interfaces for endpoint responses that all possible response types implement. Allows controllers to return any valid response type in a type-safe manner (e.g., sealed interface CreateUserResponse implemented by User, ConflictResponse, ErrorResponse)",
262+
useSealedResponseInterfaces);
253263
addOption(X_KOTLIN_IMPLEMENTS_SKIP, "A list of fully qualified interfaces that should NOT be implemented despite their presence in vendor extension `x-kotlin-implements`. Example: yaml `xKotlinImplementsSkip: [com.some.pack.WithPhotoUrls]` skips implementing the interface in any schema", "empty list");
254264
addOption(X_KOTLIN_IMPLEMENTS_FIELDS_SKIP, "A list of fields per schema name that should NOT be created with `override` keyword despite their presence in vendor extension `x-kotlin-implements-fields` for the schema. Example: yaml `xKotlinImplementsFieldsSkip: Pet: [photoUrls]` skips `override` for `photoUrls` in schema `Pet`", "empty map");
255265
addOption(SCHEMA_IMPLEMENTS, "A map of single interface or a list of interfaces per schema name that should be implemented (serves similar purpose as `x-kotlin-implements`, but is fully decoupled from the api spec). Example: yaml `schemaImplements: {Pet: com.some.pack.WithId, Category: [com.some.pack.CategoryInterface], Dog: [com.some.pack.Canine, com.some.pack.OtherInterface]}` implements interfaces in schemas `Pet` (interface `com.some.pack.WithId`), `Category` (interface `com.some.pack.CategoryInterface`), `Dog`(interfaces `com.some.pack.Canine`, `com.some.pack.OtherInterface`)", "empty map");
@@ -496,6 +506,12 @@ public void processOpts() {
496506
this.setUseResponseEntity(Boolean.parseBoolean(additionalProperties.get(USE_RESPONSE_ENTITY).toString()));
497507
}
498508
writePropertyBack(USE_RESPONSE_ENTITY, useResponseEntity);
509+
510+
if(additionalProperties.containsKey(USE_SEALED_RESPONSE_INTERFACES)) {
511+
this.setUseSealedResponseInterfaces(Boolean.parseBoolean(additionalProperties.get(USE_SEALED_RESPONSE_INTERFACES).toString()));
512+
}
513+
writePropertyBack(USE_SEALED_RESPONSE_INTERFACES, useSealedResponseInterfaces);
514+
499515
additionalProperties.put("springHttpStatus", new SpringHttpStatusLambda());
500516

501517
// Set basePackage from invokerPackage
@@ -1024,6 +1040,42 @@ public void preprocessOpenAPI(OpenAPI openAPI) {
10241040
this.additionalProperties.put(SERVER_PORT, URLPathUtils.getPort(url, 8080));
10251041
}
10261042

1043+
// Build modelToSealedInterfaces map early, before models are processed
1044+
// Note: We check additionalProperties here because processOpts() hasn't been called yet
1045+
boolean shouldUseSealedInterfaces = additionalProperties.containsKey(USE_SEALED_RESPONSE_INTERFACES)
1046+
&& Boolean.parseBoolean(additionalProperties.get(USE_SEALED_RESPONSE_INTERFACES).toString());
1047+
if (shouldUseSealedInterfaces && openAPI.getPaths() != null) {
1048+
openAPI.getPaths().forEach((pathName, pathItem) -> {
1049+
pathItem.readOperations().forEach(operation -> {
1050+
if (operation.getOperationId() != null && operation.getResponses() != null) {
1051+
String sealedInterfaceName = camelize(operation.getOperationId()) + "Response";
1052+
1053+
operation.getResponses().forEach((statusCode, response) -> {
1054+
if (response.getContent() != null) {
1055+
response.getContent().forEach((mediaType, content) -> {
1056+
if (content.getSchema() != null && content.getSchema().get$ref() != null) {
1057+
String ref = content.getSchema().get$ref();
1058+
String modelName = ModelUtils.getSimpleRef(ref);
1059+
List<String> interfaces = modelToSealedInterfaces.computeIfAbsent(modelName, k -> new ArrayList<>());
1060+
// Only add if not already present to avoid duplicates
1061+
if (!interfaces.contains(sealedInterfaceName)) {
1062+
interfaces.add(sealedInterfaceName);
1063+
}
1064+
}
1065+
});
1066+
}
1067+
});
1068+
1069+
// Only register sealed interface if at least one model implements it
1070+
// This prevents generating empty sealed interfaces for operations with no response content
1071+
if (modelToSealedInterfaces.values().stream().anyMatch(list -> list.contains(sealedInterfaceName))) {
1072+
sealedInterfaceToOperationId.put(sealedInterfaceName, operation.getOperationId());
1073+
}
1074+
}
1075+
});
1076+
});
1077+
}
1078+
10271079
// TODO: Handle tags
10281080
}
10291081

@@ -1079,6 +1131,53 @@ public ModelsMap postProcessModelsEnum(ModelsMap objs) {
10791131
imports.add(itemJsonProperty);
10801132
});
10811133

1134+
// Add sealed interface implementations to models if enabled
1135+
// Note: We check additionalProperties here because processOpts() may not have been called yet
1136+
boolean shouldUseSealedInterfaces = additionalProperties.containsKey(USE_SEALED_RESPONSE_INTERFACES)
1137+
&& Boolean.parseBoolean(additionalProperties.get(USE_SEALED_RESPONSE_INTERFACES).toString());
1138+
if (shouldUseSealedInterfaces) {
1139+
objs.getModels().stream()
1140+
.map(ModelMap::getModel)
1141+
.forEach(cm -> {
1142+
String modelName = cm.classname;
1143+
if (modelToSealedInterfaces.containsKey(modelName)) {
1144+
List<String> sealedInterfaces = modelToSealedInterfaces.get(modelName);
1145+
cm.vendorExtensions.put("x-implements-sealed-interfaces", sealedInterfaces);
1146+
1147+
// Add imports for each sealed interface
1148+
for (String sealedInterface : sealedInterfaces) {
1149+
String importStatement = modelPackage + "." + sealedInterface;
1150+
cm.imports.add(sealedInterface);
1151+
Map<String, String> item = new HashMap<>();
1152+
item.put("import", importStatement);
1153+
imports.add(item);
1154+
}
1155+
}
1156+
});
1157+
1158+
// Write sealed interfaces file once
1159+
if (!sealedInterfacesFileWritten && !sealedInterfaceToOperationId.isEmpty()) {
1160+
List<Map<String, String>> sealedInterfacesList = new ArrayList<>();
1161+
sealedInterfaceToOperationId.forEach((sealedInterfaceName, operationId) -> {
1162+
Map<String, String> sealedInterface = new HashMap<>();
1163+
sealedInterface.put("name", sealedInterfaceName);
1164+
sealedInterface.put("operationId", operationId);
1165+
sealedInterfacesList.add(sealedInterface);
1166+
});
1167+
1168+
Map<String, Object> sealedInterfacesData = new HashMap<>();
1169+
sealedInterfacesData.put("package", modelPackage);
1170+
sealedInterfacesData.put("sealedInterfaces", sealedInterfacesList);
1171+
1172+
additionalProperties.put("sealedInterfacesData", sealedInterfacesData);
1173+
supportingFiles.add(new SupportingFile("sealedResponseInterfaces.mustache",
1174+
(sourceFolder + File.separator + modelPackage).replace(".", File.separator),
1175+
"SealedResponseInterfaces.kt"));
1176+
1177+
sealedInterfacesFileWritten = true;
1178+
}
1179+
}
1180+
10821181
return objs;
10831182
}
10841183

@@ -1147,10 +1246,59 @@ public void setReturnContainer(final String returnContainer) {
11471246
operation.returnContainer = returnContainer;
11481247
}
11491248
});
1249+
1250+
// Generate sealed response interface metadata if enabled
1251+
if (useSealedResponseInterfaces && responses != null && !responses.isEmpty()) {
1252+
// Generate sealed interface name from operation ID
1253+
String sealedInterfaceName = camelize(operation.operationId) + "Response";
1254+
1255+
// Only add vendor extension if the sealed interface was actually generated
1256+
// (i.e., the operation has at least one response with content)
1257+
if (sealedInterfaceToOperationId.containsKey(sealedInterfaceName)) {
1258+
operation.vendorExtensions.put("x-sealed-response-interface", sealedInterfaceName);
1259+
1260+
// Collect all unique response base types (models)
1261+
List<String> responseTypes = responses.stream()
1262+
.map(r -> r.baseType)
1263+
.filter(baseType -> baseType != null && !baseType.isEmpty())
1264+
.distinct()
1265+
.collect(Collectors.toList());
1266+
1267+
operation.vendorExtensions.put("x-sealed-response-types", responseTypes);
1268+
1269+
// Track which models should implement this sealed interface
1270+
for (String responseType : responseTypes) {
1271+
modelToSealedInterfaces.computeIfAbsent(responseType, k -> new ArrayList<>())
1272+
.add(sealedInterfaceName);
1273+
}
1274+
}
1275+
}
1276+
11501277
// if(implicitHeaders){
11511278
// removeHeadersFromAllParams(operation.allParams);
11521279
// }
11531280
});
1281+
1282+
// Add imports for sealed interfaces if feature is enabled
1283+
if (useSealedResponseInterfaces) {
1284+
Set<String> sealedInterfacesToImport = new HashSet<>();
1285+
ops.forEach(operation -> {
1286+
if (operation.vendorExtensions.containsKey("x-sealed-response-interface")) {
1287+
String sealedInterfaceName = (String) operation.vendorExtensions.get("x-sealed-response-interface");
1288+
operation.imports.add(sealedInterfaceName);
1289+
sealedInterfacesToImport.add(sealedInterfaceName);
1290+
}
1291+
});
1292+
1293+
// Add import statements to the operations imports map
1294+
List<Map<String, String>> imports = objs.getImports();
1295+
for (String sealedInterfaceName : sealedInterfacesToImport) {
1296+
String importStatement = modelPackage + "." + sealedInterfaceName;
1297+
Map<String, String> item = new HashMap<>();
1298+
item.put("import", importStatement);
1299+
imports.add(item);
1300+
}
1301+
}
11541302
}
11551303

11561304
return objs;
@@ -1159,6 +1307,12 @@ public void setReturnContainer(final String returnContainer) {
11591307
@Override
11601308
public Map<String, Object> postProcessSupportingFileData(Map<String, Object> objs) {
11611309
generateYAMLSpecFile(objs);
1310+
1311+
// Add sealed interfaces data if available
1312+
if (additionalProperties.containsKey("sealedInterfacesData")) {
1313+
objs.putAll((Map<String, Object>) additionalProperties.get("sealedInterfacesData"));
1314+
}
1315+
11621316
return objs;
11631317
}
11641318

modules/openapi-generator/src/main/resources/kotlin-spring/apiInterface.mustache

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -116,7 +116,7 @@ interface {{classname}} {
116116
{{/hasParams}}{{#swagger1AnnotationLibrary}}@ApiParam(hidden = true) {{/swagger1AnnotationLibrary}}{{#swagger2AnnotationLibrary}}@Parameter(hidden = true) {{/swagger2AnnotationLibrary}}{{#reactive}}exchange: org.springframework.web.server.ServerWebExchange{{/reactive}}{{^reactive}}request: {{javaxPackage}}.servlet.http.HttpServletRequest{{/reactive}}{{/includeHttpRequestContext}}{{#vendorExtensions.x-spring-paginated}}{{#hasParams}},
117117
{{/hasParams}}{{^hasParams}}{{#includeHttpRequestContext}},
118118
{{/includeHttpRequestContext}}{{/hasParams}}{{#swagger1AnnotationLibrary}}@ApiParam(hidden = true) {{/swagger1AnnotationLibrary}}{{#swagger2AnnotationLibrary}}@Parameter(hidden = true) {{/swagger2AnnotationLibrary}}pageable: Pageable{{/vendorExtensions.x-spring-paginated}}{{#hasParams}}
119-
{{/hasParams}}): {{#useResponseEntity}}ResponseEntity<{{/useResponseEntity}}{{>returnTypes}}{{#useResponseEntity}}>{{/useResponseEntity}}{{^skipDefaultApiInterface}} {
119+
{{/hasParams}}): {{#useResponseEntity}}ResponseEntity<{{/useResponseEntity}}{{#useSealedResponseInterfaces}}{{#vendorExtensions.x-sealed-response-interface}}{{vendorExtensions.x-sealed-response-interface}}{{/vendorExtensions.x-sealed-response-interface}}{{^vendorExtensions.x-sealed-response-interface}}{{>returnTypes}}{{/vendorExtensions.x-sealed-response-interface}}{{/useSealedResponseInterfaces}}{{^useSealedResponseInterfaces}}{{>returnTypes}}{{/useSealedResponseInterfaces}}{{#useResponseEntity}}>{{/useResponseEntity}}{{^skipDefaultApiInterface}} {
120120
{{^isDelegate}}
121121
return {{>returnValue}}
122122
{{/isDelegate}}

modules/openapi-generator/src/main/resources/kotlin-spring/dataClass.mustache

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,12 +20,17 @@
2020
){{/discriminator}}{{! no newline
2121
}}{{#parent}} : {{{.}}}{{#isMap}}(){{/isMap}}{{! no newline
2222
}}{{#vendorExtensions.x-kotlin-implements}}, {{{.}}}{{/vendorExtensions.x-kotlin-implements}}{{! <- serializableModel is also handled via x-kotlin-implements
23+
}}{{#vendorExtensions.x-implements-sealed-interfaces}}{{#.}}, {{{.}}}{{/.}}{{/vendorExtensions.x-implements-sealed-interfaces}}{{! <- add sealed interface implementations
2324
}}{{/parent}}{{! no newline
2425
}}{{^parent}}{{! no newline
2526
}}{{#vendorExtensions.x-kotlin-implements}}{{! no newline
2627
}}{{#-first}} : {{{.}}}{{/-first}}{{! no newline
2728
}}{{^-first}}, {{{.}}}{{/-first}}{{! no newline
2829
}}{{/vendorExtensions.x-kotlin-implements}}{{! no newline
30+
}}{{#vendorExtensions.x-implements-sealed-interfaces}}{{! no newline
31+
}}{{#-first}}{{^vendorExtensions.x-kotlin-implements}} : {{{.}}}{{/vendorExtensions.x-kotlin-implements}}{{#vendorExtensions.x-kotlin-implements}}, {{{.}}}{{/vendorExtensions.x-kotlin-implements}}{{/-first}}{{! no newline
32+
}}{{^-first}}, {{{.}}}{{/-first}}{{! no newline
33+
}}{{/vendorExtensions.x-implements-sealed-interfaces}}{{! no newline
2934
}}{{/parent}} {
3035
{{#discriminator}}
3136
{{#requiredVars}}
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
package {{package}}
2+
3+
{{#sealedInterfaces}}
4+
/**
5+
* Sealed interface for all possible responses from {{operationId}}
6+
*/
7+
sealed interface {{name}}
8+
9+
{{/sealedInterfaces}}

0 commit comments

Comments
 (0)