Skip to content

Commit d1d2f9b

Browse files
joseegarciaclaude
andcommitted
Harden 3.1 type/webhook handling and add edge-case tests
getType: extract array-type resolution into a helper. A genuine multi-type union (e.g. ["string","integer"]) now logs a warning and uses the first concrete type; a "null"-only/empty type array falls back to `object` instead of an empty type string. Adds edge-case regression fixtures: - testOpenApi31Union: union -> String (+warning), ["null"] -> Object - testWebhookPathCollision: webhook name colliding with an existing path is dropped (existing paths take precedence). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent beef8a1 commit d1d2f9b

9 files changed

Lines changed: 464 additions & 11 deletions

File tree

multiapi-engine/src/main/java/com/sngular/api/generator/plugin/common/tools/ApiTool.java

Lines changed: 21 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -21,11 +21,13 @@
2121
import com.sngular.api.generator.plugin.asyncapi.util.FactoryTypeEnum;
2222
import com.sngular.api.generator.plugin.common.files.FileLocation;
2323
import com.sngular.api.generator.plugin.common.model.TypeConstants;
24+
import lombok.extern.slf4j.Slf4j;
2425
import org.apache.commons.collections4.CollectionUtils;
2526
import org.apache.commons.collections4.IteratorUtils;
2627
import org.apache.commons.collections4.Transformer;
2728
import org.apache.commons.lang3.StringUtils;
2829

30+
@Slf4j
2931
public final class ApiTool {
3032

3133
public static final String FORMAT = "format";
@@ -224,19 +226,27 @@ public static String getType(final JsonNode schema) {
224226
return "";
225227
}
226228
final JsonNode typeNode = getNode(schema, "type");
227-
if (typeNode.isArray()) {
228-
// OpenAPI 3.1 / JSON Schema 2020-12: "type" may be an array (e.g. ["string", "null"]).
229-
// Resolve to the first non-"null" entry; "null" only marks the type as nullable.
230-
String resolvedType = "";
231-
for (final JsonNode element : typeNode) {
232-
if (!TypeConstants.NULL.equalsIgnoreCase(element.asText())) {
233-
resolvedType = element.asText();
234-
break;
235-
}
229+
return typeNode.isArray() ? getArrayType(typeNode) : StringUtils.defaultIfEmpty(typeNode.textValue(), "");
230+
}
231+
232+
private static String getArrayType(final JsonNode typeNode) {
233+
// OpenAPI 3.1 / JSON Schema 2020-12: "type" may be an array (e.g. ["string", "null"]).
234+
// "null" only marks the type as nullable; keep the concrete (non-"null") types.
235+
final List<String> concreteTypes = new ArrayList<>();
236+
for (final JsonNode element : typeNode) {
237+
if (!TypeConstants.NULL.equalsIgnoreCase(element.asText())) {
238+
concreteTypes.add(element.asText());
236239
}
237-
return resolvedType;
238240
}
239-
return StringUtils.defaultIfEmpty(typeNode.textValue(), "");
241+
if (concreteTypes.isEmpty()) {
242+
// Only "null" (or an empty array): no concrete type to generate, fall back to object.
243+
return TypeConstants.OBJECT;
244+
}
245+
if (concreteTypes.size() > 1) {
246+
log.warn("Schema declares a multi-type union {}; only '{}' is generated, the remaining types are ignored.",
247+
concreteTypes, concreteTypes.get(0));
248+
}
249+
return concreteTypes.get(0);
240250
}
241251

242252
public static boolean hasItems(final JsonNode schema) {

multiapi-engine/src/test/java/com/sngular/api/generator/plugin/openapi/OpenApiGeneratorFixtures.java

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -147,6 +147,20 @@ public final class OpenApiGeneratorFixtures {
147147
.clientPackage("com.sngular.multifileplugin.externalref.client").modelNamePrefix("Api")
148148
.modelNameSuffix("DTO").build());
149149

150+
static final List<SpecFile> TEST_OPEN_API_31_UNION = List
151+
.of(SpecFile.builder().filePath("openapigenerator/testOpenApi31Union/api-test.yml")
152+
.apiPackage("com.sngular.multifileplugin.openapi31union")
153+
.modelPackage("com.sngular.multifileplugin.openapi31union.model")
154+
.clientPackage("com.sngular.multifileplugin.openapi31union.client")
155+
.modelNameSuffix("DTO").build());
156+
157+
static final List<SpecFile> TEST_WEBHOOK_PATH_COLLISION = List
158+
.of(SpecFile.builder().filePath("openapigenerator/testWebhookPathCollision/api-test.yml")
159+
.apiPackage("com.sngular.multifileplugin.webhookpathcollision")
160+
.modelPackage("com.sngular.multifileplugin.webhookpathcollision.model")
161+
.clientPackage("com.sngular.multifileplugin.webhookpathcollision.client")
162+
.modelNameSuffix("DTO").build());
163+
150164
static final List<SpecFile> TEST_WEBHOOKS = List
151165
.of(SpecFile.builder().filePath("openapigenerator/testWebhooks/api-test.yml")
152166
.apiPackage("com.sngular.multifileplugin.webhooks")
@@ -777,6 +791,42 @@ static Function<Path, Boolean> validateExternalRefGeneration() {
777791
DEFAULT_MODEL_API, expectedExceptionFiles, DEFAULT_EXCEPTION_API);
778792
}
779793

794+
static Function<Path, Boolean> validateOpenApi31Union() {
795+
796+
final String DEFAULT_TARGET_API = "generated/com/sngular/multifileplugin/openapi31union";
797+
798+
final String DEFAULT_MODEL_API = "generated/com/sngular/multifileplugin/openapi31union/model";
799+
800+
final String COMMON_PATH = "openapigenerator/testOpenApi31Union/";
801+
802+
final String ASSETS_PATH = COMMON_PATH + "assets/";
803+
804+
final List<String> expectedTestApiFile = List.of(ASSETS_PATH + "ItemApi.java");
805+
806+
final List<String> expectedTestApiModelFiles = List.of(ASSETS_PATH + "ItemDTO.java");
807+
808+
return path -> commonTest(path, expectedTestApiFile, expectedTestApiModelFiles, DEFAULT_TARGET_API,
809+
DEFAULT_MODEL_API, Collections.emptyList(), null);
810+
}
811+
812+
static Function<Path, Boolean> validateWebhookPathCollision() {
813+
814+
final String DEFAULT_TARGET_API = "generated/com/sngular/multifileplugin/webhookpathcollision";
815+
816+
final String DEFAULT_MODEL_API = "generated/com/sngular/multifileplugin/webhookpathcollision/model";
817+
818+
final String COMMON_PATH = "openapigenerator/testWebhookPathCollision/";
819+
820+
final String ASSETS_PATH = COMMON_PATH + "assets/";
821+
822+
final List<String> expectedTestApiFile = List.of(ASSETS_PATH + "NewPetApi.java");
823+
824+
final List<String> expectedTestApiModelFiles = List.of(ASSETS_PATH + "PetDTO.java");
825+
826+
return path -> commonTest(path, expectedTestApiFile, expectedTestApiModelFiles, DEFAULT_TARGET_API,
827+
DEFAULT_MODEL_API, Collections.emptyList(), null);
828+
}
829+
780830
static Function<Path, Boolean> validateWebhooks() {
781831

782832
final String DEFAULT_TARGET_API = "generated/com/sngular/multifileplugin/webhooks";

multiapi-engine/src/test/java/com/sngular/api/generator/plugin/openapi/OpenApiGeneratorTest.java

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,10 @@ static Stream<Arguments> fileSpecToProcess() {
8787
OpenApiGeneratorFixtures.validateOpenApi31Types()),
8888
Arguments.of("testWebhooks", OpenApiGeneratorFixtures.TEST_WEBHOOKS,
8989
OpenApiGeneratorFixtures.validateWebhooks()),
90+
Arguments.of("testOpenApi31Union", OpenApiGeneratorFixtures.TEST_OPEN_API_31_UNION,
91+
OpenApiGeneratorFixtures.validateOpenApi31Union()),
92+
Arguments.of("testWebhookPathCollision", OpenApiGeneratorFixtures.TEST_WEBHOOK_PATH_COLLISION,
93+
OpenApiGeneratorFixtures.validateWebhookPathCollision()),
9094
Arguments.of("testAnyOfInResponse", OpenApiGeneratorFixtures.TEST_ANY_OF_IN_RESPONSE,
9195
OpenApiGeneratorFixtures.validateAnyOfInResponse()),
9296
Arguments.of("testOneOfInResponse", OpenApiGeneratorFixtures.TEST_ONE_OF_IN_RESPONSE,
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
---
2+
# Edge-case regression for OpenAPI 3.1 array-valued `type` (issue #375):
3+
# a genuine multi-type union (no "null") and a "null"-only type.
4+
# The generator picks the first concrete type for a union (logging a warning) and
5+
# falls back to `object` when the array has no concrete type.
6+
openapi: "3.1.0"
7+
info:
8+
version: 1.0.0
9+
title: Union types API (OpenAPI 3.1)
10+
license:
11+
name: MIT
12+
servers:
13+
- url: http://localhost:8080/v1
14+
tags:
15+
- name: item
16+
paths:
17+
/item:
18+
get:
19+
summary: Get the item
20+
operationId: getItem
21+
tags:
22+
- item
23+
responses:
24+
'200':
25+
description: The requested item
26+
content:
27+
application/json:
28+
schema:
29+
$ref: "#/components/schemas/Item"
30+
components:
31+
schemas:
32+
Item:
33+
type: object
34+
properties:
35+
id:
36+
type: string
37+
code:
38+
type: ["string", "integer"]
39+
note:
40+
type: ["null"]
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
package com.sngular.multifileplugin.openapi31union;
2+
3+
import java.util.Optional;
4+
import java.util.List;
5+
import java.util.Map;
6+
import javax.validation.Valid;
7+
8+
import io.swagger.v3.oas.annotations.Operation;
9+
import io.swagger.v3.oas.annotations.Parameter;
10+
import io.swagger.v3.oas.annotations.media.Content;
11+
import io.swagger.v3.oas.annotations.media.Schema;
12+
import io.swagger.v3.oas.annotations.responses.ApiResponse;
13+
import org.springframework.http.MediaType;
14+
import org.springframework.http.HttpStatus;
15+
import org.springframework.http.ResponseEntity;
16+
import org.springframework.web.bind.annotation.*;
17+
import org.springframework.web.context.request.NativeWebRequest;
18+
19+
import com.sngular.multifileplugin.openapi31union.model.ItemDTO;
20+
21+
public interface ItemApi {
22+
23+
/**
24+
* GET /item: Get the item
25+
* @return The requested item; (status code 200)
26+
*/
27+
28+
@Operation(
29+
operationId = "getItem",
30+
summary = "Get the item",
31+
tags = {"item"},
32+
responses = {
33+
@ApiResponse(responseCode = "200", description = "The requested item", content = @Content(mediaType = "application/json", schema = @Schema(implementation = ItemDTO.class)))
34+
}
35+
)
36+
@RequestMapping(
37+
method = RequestMethod.GET,
38+
value = "/item",
39+
produces = {"application/json"}
40+
)
41+
42+
default ResponseEntity<ItemDTO> getItem() {
43+
return new ResponseEntity<>(HttpStatus.NOT_IMPLEMENTED);
44+
}
45+
46+
}
Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
1+
package com.sngular.multifileplugin.openapi31union.model;
2+
3+
import java.util.Objects;
4+
5+
import com.fasterxml.jackson.databind.annotation.JsonDeserialize;
6+
import com.fasterxml.jackson.databind.annotation.JsonPOJOBuilder;
7+
import com.fasterxml.jackson.annotation.JsonProperty;
8+
import io.swagger.v3.oas.annotations.media.Schema;
9+
10+
@JsonDeserialize(builder = ItemDTO.ItemDTOBuilder.class)
11+
public class ItemDTO {
12+
13+
@JsonProperty(value ="id")
14+
private String id;
15+
@JsonProperty(value ="code")
16+
private String code;
17+
@JsonProperty(value ="note")
18+
private Object note;
19+
20+
private ItemDTO(ItemDTOBuilder builder) {
21+
this.id = builder.id;
22+
this.code = builder.code;
23+
this.note = builder.note;
24+
25+
}
26+
27+
public static ItemDTO.ItemDTOBuilder builder() {
28+
return new ItemDTO.ItemDTOBuilder();
29+
}
30+
31+
@JsonPOJOBuilder(buildMethodName = "build", withPrefix = "")
32+
public static class ItemDTOBuilder {
33+
34+
private String id;
35+
private String code;
36+
private Object note;
37+
38+
public ItemDTO.ItemDTOBuilder id(String id) {
39+
this.id = id;
40+
return this;
41+
}
42+
43+
public ItemDTO.ItemDTOBuilder code(String code) {
44+
this.code = code;
45+
return this;
46+
}
47+
48+
public ItemDTO.ItemDTOBuilder note(Object note) {
49+
this.note = note;
50+
return this;
51+
}
52+
53+
public ItemDTO build() {
54+
ItemDTO itemDTO = new ItemDTO(this);
55+
return itemDTO;
56+
}
57+
}
58+
59+
@Schema(name = "id", required = false)
60+
public String getId() {
61+
return id;
62+
}
63+
public void setId(String id) {
64+
this.id = id;
65+
}
66+
67+
@Schema(name = "code", required = false)
68+
public String getCode() {
69+
return code;
70+
}
71+
public void setCode(String code) {
72+
this.code = code;
73+
}
74+
75+
@Schema(name = "note", required = false)
76+
public Object getNote() {
77+
return note;
78+
}
79+
public void setNote(Object note) {
80+
this.note = note;
81+
}
82+
83+
@Override
84+
public boolean equals(Object o) {
85+
if (this == o) {
86+
return true;
87+
}
88+
if (o == null || getClass() != o.getClass()) {
89+
return false;
90+
}
91+
ItemDTO itemDTO = (ItemDTO) o;
92+
return Objects.equals(this.id, itemDTO.id) && Objects.equals(this.code, itemDTO.code) && Objects.equals(this.note, itemDTO.note);
93+
}
94+
95+
@Override
96+
public int hashCode() {
97+
return Objects.hash(id, code, note);
98+
}
99+
100+
@Override
101+
public String toString() {
102+
StringBuilder sb = new StringBuilder();
103+
sb.append("ItemDTO{");
104+
sb.append(" id:").append(id).append(",");
105+
sb.append(" code:").append(code).append(",");
106+
sb.append(" note:").append(note);
107+
sb.append("}");
108+
return sb.toString();
109+
}
110+
111+
112+
}
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
---
2+
# Edge-case regression for OpenAPI 3.1 webhooks (issue #375): a webhook whose name
3+
# collides with an existing path. Existing `paths` entries must take precedence, so
4+
# the `/newPet` GET is generated and the colliding webhook is dropped.
5+
openapi: "3.1.0"
6+
info:
7+
version: 1.0.0
8+
title: Webhook/path collision API (OpenAPI 3.1)
9+
license:
10+
name: MIT
11+
servers:
12+
- url: http://localhost:8080/v1
13+
tags:
14+
- name: pet
15+
paths:
16+
/newPet:
17+
get:
18+
summary: Get the latest new pet
19+
operationId: getNewPet
20+
tags:
21+
- pet
22+
responses:
23+
'200':
24+
description: The latest new pet
25+
content:
26+
application/json:
27+
schema:
28+
$ref: "#/components/schemas/Pet"
29+
webhooks:
30+
newPet:
31+
post:
32+
summary: New pet notification (must be ignored, collides with /newPet)
33+
operationId: newPetWebhook
34+
requestBody:
35+
content:
36+
application/json:
37+
schema:
38+
$ref: "#/components/schemas/Pet"
39+
responses:
40+
'200':
41+
description: Notification acknowledged
42+
components:
43+
schemas:
44+
Pet:
45+
type: object
46+
properties:
47+
id:
48+
type: integer
49+
format: int64
50+
name:
51+
type: string

0 commit comments

Comments
 (0)