Skip to content

Commit ab3f529

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

14 files changed

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

45634667

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)