Skip to content

Commit 809b1df

Browse files
committed
Add type safe returns
1 parent 9432aaf commit 809b1df

6 files changed

Lines changed: 364 additions & 1 deletion

File tree

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

Lines changed: 141 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,34 @@ 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+
sealedInterfaceToOperationId.put(sealedInterfaceName, operation.getOperationId());
1053+
1054+
operation.getResponses().forEach((statusCode, response) -> {
1055+
if (response.getContent() != null) {
1056+
response.getContent().forEach((mediaType, content) -> {
1057+
if (content.getSchema() != null && content.getSchema().get$ref() != null) {
1058+
String ref = content.getSchema().get$ref();
1059+
String modelName = ModelUtils.getSimpleRef(ref);
1060+
modelToSealedInterfaces.computeIfAbsent(modelName, k -> new ArrayList<>())
1061+
.add(sealedInterfaceName);
1062+
}
1063+
});
1064+
}
1065+
});
1066+
}
1067+
});
1068+
});
1069+
}
1070+
10271071
// TODO: Handle tags
10281072
}
10291073

@@ -1079,6 +1123,53 @@ public ModelsMap postProcessModelsEnum(ModelsMap objs) {
10791123
imports.add(itemJsonProperty);
10801124
});
10811125

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

@@ -1147,10 +1238,54 @@ public void setReturnContainer(final String returnContainer) {
11471238
operation.returnContainer = returnContainer;
11481239
}
11491240
});
1241+
1242+
// Generate sealed response interface metadata if enabled
1243+
if (useSealedResponseInterfaces && responses != null && !responses.isEmpty()) {
1244+
// Generate sealed interface name from operation ID
1245+
String sealedInterfaceName = camelize(operation.operationId) + "Response";
1246+
operation.vendorExtensions.put("x-sealed-response-interface", sealedInterfaceName);
1247+
1248+
// Collect all unique response base types (models)
1249+
List<String> responseTypes = responses.stream()
1250+
.map(r -> r.baseType)
1251+
.filter(baseType -> baseType != null && !baseType.isEmpty())
1252+
.distinct()
1253+
.collect(Collectors.toList());
1254+
1255+
operation.vendorExtensions.put("x-sealed-response-types", responseTypes);
1256+
1257+
// Track which models should implement this sealed interface
1258+
for (String responseType : responseTypes) {
1259+
modelToSealedInterfaces.computeIfAbsent(responseType, k -> new ArrayList<>())
1260+
.add(sealedInterfaceName);
1261+
}
1262+
}
1263+
11501264
// if(implicitHeaders){
11511265
// removeHeadersFromAllParams(operation.allParams);
11521266
// }
11531267
});
1268+
1269+
// Add imports for sealed interfaces if feature is enabled
1270+
if (useSealedResponseInterfaces) {
1271+
Set<String> sealedInterfacesToImport = new HashSet<>();
1272+
ops.forEach(operation -> {
1273+
if (operation.vendorExtensions.containsKey("x-sealed-response-interface")) {
1274+
String sealedInterfaceName = (String) operation.vendorExtensions.get("x-sealed-response-interface");
1275+
operation.imports.add(sealedInterfaceName);
1276+
sealedInterfacesToImport.add(sealedInterfaceName);
1277+
}
1278+
});
1279+
1280+
// Add import statements to the operations imports map
1281+
List<Map<String, String>> imports = objs.getImports();
1282+
for (String sealedInterfaceName : sealedInterfacesToImport) {
1283+
String importStatement = modelPackage + "." + sealedInterfaceName;
1284+
Map<String, String> item = new HashMap<>();
1285+
item.put("import", importStatement);
1286+
imports.add(item);
1287+
}
1288+
}
11541289
}
11551290

11561291
return objs;
@@ -1159,6 +1294,12 @@ public void setReturnContainer(final String returnContainer) {
11591294
@Override
11601295
public Map<String, Object> postProcessSupportingFileData(Map<String, Object> objs) {
11611296
generateYAMLSpecFile(objs);
1297+
1298+
// Add sealed interfaces data if available
1299+
if (additionalProperties.containsKey("sealedInterfacesData")) {
1300+
objs.putAll((Map<String, Object>) additionalProperties.get("sealedInterfacesData"));
1301+
}
1302+
11621303
return objs;
11631304
}
11641305

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}}{{/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)