Skip to content

Commit f4be6bd

Browse files
committed
Bugfixes / adding tests
1 parent c674e09 commit f4be6bd

14 files changed

Lines changed: 289 additions & 125 deletions

File tree

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

Lines changed: 28 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1049,20 +1049,28 @@ public void preprocessOpenAPI(OpenAPI openAPI) {
10491049
pathItem.readOperations().forEach(operation -> {
10501050
if (operation.getOperationId() != null && operation.getResponses() != null) {
10511051
String sealedInterfaceName = camelize(operation.getOperationId()) + "Response";
1052-
sealedInterfaceToOperationId.put(sealedInterfaceName, operation.getOperationId());
10531052

10541053
operation.getResponses().forEach((statusCode, response) -> {
10551054
if (response.getContent() != null) {
10561055
response.getContent().forEach((mediaType, content) -> {
10571056
if (content.getSchema() != null && content.getSchema().get$ref() != null) {
10581057
String ref = content.getSchema().get$ref();
10591058
String modelName = ModelUtils.getSimpleRef(ref);
1060-
modelToSealedInterfaces.computeIfAbsent(modelName, k -> new ArrayList<>())
1061-
.add(sealedInterfaceName);
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+
}
10621064
}
10631065
});
10641066
}
10651067
});
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+
}
10661074
}
10671075
});
10681076
});
@@ -1243,21 +1251,26 @@ public void setReturnContainer(final String returnContainer) {
12431251
if (useSealedResponseInterfaces && responses != null && !responses.isEmpty()) {
12441252
// Generate sealed interface name from operation ID
12451253
String sealedInterfaceName = camelize(operation.operationId) + "Response";
1246-
operation.vendorExtensions.put("x-sealed-response-interface", sealedInterfaceName);
12471254

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());
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);
12541259

1255-
operation.vendorExtensions.put("x-sealed-response-types", responseTypes);
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());
12561266

1257-
// Track which models should implement this sealed interface
1258-
for (String responseType : responseTypes) {
1259-
modelToSealedInterfaces.computeIfAbsent(responseType, k -> new ArrayList<>())
1260-
.add(sealedInterfaceName);
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+
}
12611274
}
12621275
}
12631276

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}}{{#useSealedResponseInterfaces}}{{#vendorExtensions.x-sealed-response-interface}}{{vendorExtensions.x-sealed-response-interface}}{{/vendorExtensions.x-sealed-response-interface}}{{/useSealedResponseInterfaces}}{{^useSealedResponseInterfaces}}{{>returnTypes}}{{/useSealedResponseInterfaces}}{{#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/test/java/org/openapitools/codegen/kotlin/spring/KotlinSpringServerCodegenTest.java

Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@
2727

2828
import java.io.File;
2929
import java.io.IOException;
30+
import java.nio.charset.StandardCharsets;
3031
import java.nio.file.Files;
3132
import java.nio.file.Path;
3233
import java.nio.file.Paths;
@@ -4558,6 +4559,110 @@ public void testSealedResponseInterfacesDisabled() throws IOException {
45584559
": CreateUserResponse",
45594560
": GetUserResponse");
45604561
}
4562+
4563+
@Test
4564+
public void testSealedResponseInterfacesNoDuplicates() throws IOException {
4565+
File output = Files.createTempDirectory("test").toFile().getCanonicalFile();
4566+
output.deleteOnExit();
4567+
String outputPath = output.getAbsolutePath().replace('\\', '/');
4568+
4569+
OpenAPI openAPI = new OpenAPIParser()
4570+
.readLocation("src/test/resources/3_0/kotlin/sealed-response-interfaces-duplicates.yaml", null, new ParseOptions()).getOpenAPI();
4571+
4572+
KotlinSpringServerCodegen codegen = new KotlinSpringServerCodegen();
4573+
codegen.setOutputDir(output.getAbsolutePath());
4574+
codegen.additionalProperties().put(CodegenConstants.MODEL_PACKAGE, "org.openapitools.model");
4575+
codegen.additionalProperties().put(CodegenConstants.API_PACKAGE, "org.openapitools.api");
4576+
codegen.additionalProperties().put(USE_SEALED_RESPONSE_INTERFACES, "true");
4577+
codegen.additionalProperties().put(INTERFACE_ONLY, "true");
4578+
4579+
ClientOptInput input = new ClientOptInput();
4580+
input.openAPI(openAPI);
4581+
input.config(codegen);
4582+
4583+
DefaultGenerator generator = new DefaultGenerator();
4584+
generator.opts(input).generate();
4585+
4586+
// Verify Order model does NOT have duplicate sealed interface implementations
4587+
Path orderFile = Paths.get(outputPath + "/src/main/kotlin/org/openapitools/model/Order.kt");
4588+
String orderContent = Files.readString(orderFile);
4589+
4590+
// Count occurrences of "PlaceOrderResponse" in the class declaration line
4591+
// Should appear exactly once, not multiple times
4592+
String classDeclaration = orderContent.lines()
4593+
.filter(line -> line.contains("data class Order") || line.contains(") : "))
4594+
.collect(Collectors.joining("\n"));
4595+
4596+
// Count how many times PlaceOrderResponse appears
4597+
long placeOrderCount = classDeclaration.chars()
4598+
.filter(c -> c == ',')
4599+
.count() + 1; // Number of interfaces = number of commas + 1
4600+
4601+
// Should have PlaceOrderResponse and GetOrderByIdResponse, not duplicates
4602+
Assert.assertTrue(classDeclaration.contains("PlaceOrderResponse"),
4603+
"Order should implement PlaceOrderResponse");
4604+
Assert.assertTrue(classDeclaration.contains("GetOrderByIdResponse"),
4605+
"Order should implement GetOrderByIdResponse");
4606+
4607+
// Check for duplicate imports
4608+
long importCount = orderContent.lines()
4609+
.filter(line -> line.contains("import org.openapitools.model.PlaceOrderResponse"))
4610+
.count();
4611+
Assert.assertEquals(importCount, 1L, "PlaceOrderResponse should be imported exactly once, not " + importCount);
4612+
4613+
// Verify no duplicate interface implementations
4614+
// The pattern should be ") : InterfaceA, InterfaceB {" not ") : InterfaceA, InterfaceA, InterfaceB, InterfaceB {"
4615+
Assert.assertFalse(classDeclaration.matches(".*PlaceOrderResponse.*,\\s*PlaceOrderResponse.*"),
4616+
"PlaceOrderResponse should not appear twice in implements list");
4617+
Assert.assertFalse(classDeclaration.matches(".*GetOrderByIdResponse.*,\\s*GetOrderByIdResponse.*"),
4618+
"GetOrderByIdResponse should not appear twice in implements list");
4619+
}
4620+
4621+
@Test
4622+
public void testSealedResponseInterfacesVoidResponse() throws IOException {
4623+
File output = Files.createTempDirectory("test").toFile().getCanonicalFile();
4624+
output.deleteOnExit();
4625+
String outputPath = output.getAbsolutePath().replace('\\', '/');
4626+
4627+
OpenAPI openAPI = new OpenAPIParser()
4628+
.readLocation("src/test/resources/3_0/kotlin/sealed-response-interfaces-void-response.yaml", null, new ParseOptions()).getOpenAPI();
4629+
4630+
KotlinSpringServerCodegen codegen = new KotlinSpringServerCodegen();
4631+
codegen.setOutputDir(output.getAbsolutePath());
4632+
codegen.additionalProperties().put(CodegenConstants.MODEL_PACKAGE, "org.openapitools.model");
4633+
codegen.additionalProperties().put(CodegenConstants.API_PACKAGE, "org.openapitools.api");
4634+
codegen.additionalProperties().put(USE_SEALED_RESPONSE_INTERFACES, "true");
4635+
codegen.additionalProperties().put(INTERFACE_ONLY, "true");
4636+
4637+
ClientOptInput input = new ClientOptInput();
4638+
input.openAPI(openAPI);
4639+
input.config(codegen);
4640+
4641+
DefaultGenerator generator = new DefaultGenerator();
4642+
generator.opts(input).generate();
4643+
4644+
// Read generated SealedResponseInterfaces.kt
4645+
Path sealedInterfacesPath = Paths.get(outputPath + "/src/main/kotlin/org/openapitools/model/SealedResponseInterfaces.kt");
4646+
String sealedInterfacesContent = new String(Files.readAllBytes(sealedInterfacesPath), StandardCharsets.UTF_8);
4647+
4648+
// CreateUserResponse should NOT be generated (void response operation)
4649+
Assert.assertFalse(sealedInterfacesContent.contains("sealed interface CreateUserResponse"),
4650+
"CreateUserResponse should not be generated for operations with no response content");
4651+
4652+
// CreatePetResponse should be generated (has response content)
4653+
Assert.assertTrue(sealedInterfacesContent.contains("sealed interface CreatePetResponse"),
4654+
"CreatePetResponse should be generated for operations with response content");
4655+
4656+
// Read generated Pet.kt
4657+
Path petPath = Paths.get(outputPath + "/src/main/kotlin/org/openapitools/model/Pet.kt");
4658+
String petContent = new String(Files.readAllBytes(petPath), StandardCharsets.UTF_8);
4659+
4660+
// Pet should implement CreatePetResponse
4661+
Assert.assertTrue(petContent.contains("import org.openapitools.model.CreatePetResponse"),
4662+
"Pet should import CreatePetResponse");
4663+
Assert.assertTrue(petContent.contains(") : CreatePetResponse {") || petContent.contains(") : CreatePetResponse"),
4664+
"Pet should implement CreatePetResponse");
4665+
}
45614666
}
45624667

45634668

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
openapi: 3.0.3
2+
info:
3+
title: Duplicate Response Test API
4+
version: 1.0.0
5+
paths:
6+
/orders:
7+
post:
8+
operationId: placeOrder
9+
requestBody:
10+
required: true
11+
content:
12+
application/json:
13+
schema:
14+
$ref: '#/components/schemas/OrderRequest'
15+
responses:
16+
'200':
17+
description: Order placed successfully
18+
content:
19+
application/json:
20+
schema:
21+
$ref: '#/components/schemas/Order'
22+
'201':
23+
description: Order created
24+
content:
25+
application/json:
26+
schema:
27+
$ref: '#/components/schemas/Order'
28+
'405':
29+
description: Invalid input
30+
content:
31+
application/json:
32+
schema:
33+
$ref: '#/components/schemas/Order'
34+
/orders/{orderId}:
35+
get:
36+
operationId: getOrderById
37+
parameters:
38+
- name: orderId
39+
in: path
40+
required: true
41+
schema:
42+
type: string
43+
responses:
44+
'200':
45+
description: Order found
46+
content:
47+
application/json:
48+
schema:
49+
$ref: '#/components/schemas/Order'
50+
'404':
51+
description: Order not found
52+
content:
53+
application/json:
54+
schema:
55+
$ref: '#/components/schemas/ErrorResponse'
56+
components:
57+
schemas:
58+
Order:
59+
type: object
60+
required:
61+
- id
62+
- status
63+
properties:
64+
id:
65+
type: string
66+
status:
67+
type: string
68+
OrderRequest:
69+
type: object
70+
required:
71+
- quantity
72+
properties:
73+
quantity:
74+
type: integer
75+
ErrorResponse:
76+
type: object
77+
required:
78+
- code
79+
- message
80+
properties:
81+
code:
82+
type: string
83+
message:
84+
type: string
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
openapi: 3.0.0
2+
info:
3+
title: Test API
4+
version: 1.0.0
5+
paths:
6+
/user:
7+
post:
8+
operationId: createUser
9+
requestBody:
10+
content:
11+
application/json:
12+
schema:
13+
$ref: '#/components/schemas/User'
14+
responses:
15+
default:
16+
description: successful operation
17+
/pet:
18+
post:
19+
operationId: createPet
20+
requestBody:
21+
content:
22+
application/json:
23+
schema:
24+
$ref: '#/components/schemas/Pet'
25+
responses:
26+
'200':
27+
description: successful operation
28+
content:
29+
application/json:
30+
schema:
31+
$ref: '#/components/schemas/Pet'
32+
components:
33+
schemas:
34+
User:
35+
type: object
36+
properties:
37+
id:
38+
type: integer
39+
name:
40+
type: string
41+
Pet:
42+
type: object
43+
properties:
44+
id:
45+
type: integer
46+
name:
47+
type: string

samples/server/petstore/kotlin-spring-sealed-interfaces/.openapi-generator/FILES

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,3 @@
1-
.openapi-generator-ignore
21
README.md
32
build.gradle.kts
43
gradle/wrapper/gradle-wrapper.jar

samples/server/petstore/kotlin-spring-sealed-interfaces/gradlew

100644100755
File mode changed.

samples/server/petstore/kotlin-spring-sealed-interfaces/src/main/kotlin/org/openapitools/api/PetApi.kt

Lines changed: 4 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -7,14 +7,10 @@ package org.openapitools.api
77

88
import org.openapitools.model.ModelApiResponse
99
import org.openapitools.model.Pet
10-
import org.openapitools.model.DeletePetResponse
11-
import org.openapitools.model.UpdatePetWithFormResponse
1210
import org.openapitools.model.UpdatePetResponse
1311
import org.openapitools.model.AddPetResponse
14-
import org.openapitools.model.FindPetsByTagsResponse
1512
import org.openapitools.model.GetPetByIdResponse
1613
import org.openapitools.model.UploadFileResponse
17-
import org.openapitools.model.FindPetsByStatusResponse
1814
import org.springframework.http.HttpStatus
1915
import org.springframework.http.MediaType
2016
import org.springframework.http.ResponseEntity
@@ -64,7 +60,7 @@ interface PetApi {
6460
fun deletePet(
6561
@PathVariable("petId") petId: kotlin.Long,
6662
@RequestHeader(value = "api_key", required = false) apiKey: kotlin.String?
67-
): ResponseEntity<DeletePetResponse> {
63+
): ResponseEntity<Unit> {
6864
return ResponseEntity(HttpStatus.NOT_IMPLEMENTED)
6965
}
7066

@@ -77,7 +73,7 @@ interface PetApi {
7773
)
7874
fun findPetsByStatus(
7975
@NotNull @Valid @RequestParam(value = "status", required = true) status: kotlin.collections.List<kotlin.String>
80-
): ResponseEntity<FindPetsByStatusResponse> {
76+
): ResponseEntity<List<Pet>> {
8177
return ResponseEntity(HttpStatus.NOT_IMPLEMENTED)
8278
}
8379

@@ -90,7 +86,7 @@ interface PetApi {
9086
)
9187
fun findPetsByTags(
9288
@NotNull @Valid @RequestParam(value = "tags", required = true) tags: kotlin.collections.List<kotlin.String>
93-
): ResponseEntity<FindPetsByTagsResponse> {
89+
): ResponseEntity<List<Pet>> {
9490
return ResponseEntity(HttpStatus.NOT_IMPLEMENTED)
9591
}
9692

@@ -132,7 +128,7 @@ interface PetApi {
132128
@PathVariable("petId") petId: kotlin.Long,
133129
@Valid @RequestParam(value = "name", required = false) name: kotlin.String?,
134130
@Valid @RequestParam(value = "status", required = false) status: kotlin.String?
135-
): ResponseEntity<UpdatePetWithFormResponse> {
131+
): ResponseEntity<Unit> {
136132
return ResponseEntity(HttpStatus.NOT_IMPLEMENTED)
137133
}
138134

0 commit comments

Comments
 (0)