Skip to content

Commit 4cf6aa8

Browse files
joseegarciaclaude
andcommitted
Fix #375: support OpenAPI 3.1 array-valued type (unions & nullable)
OpenAPI 3.1 / JSON Schema 2020-12 allows a schema's `type` to be an array, e.g. type: ["string", "null"] (the nullable idiom that replaces 3.0's `nullable: true`, and general unions). ApiTool.getType() read the type via JsonNode.textValue(), which returns null for an array node and collapsed to "", so every isObject/isArray/isString/isNumber/isDateTime predicate and MapperUtil.getSimpleType failed -> the generator silently produced wrong or empty types for those fields. - ApiTool.getType: when `type` is an array, resolve to the first non-"null" entry ("null" only marks the type as nullable). All the isX predicates and getSimpleType then work unchanged for union/nullable types. - MapperUtil.processNumber: read the type via ApiTool.getType instead of schema.get("type").asText(), so array-valued numeric types resolve. - Add TypeConstants.NULL. - Add a 3.1.0 regression fixture (testOpenApi31Types) covering ["string", "null"], ["integer","null"], ["number","null"]+double, int64 and a plain array, with golden assets. Bumps version 6.3.2 -> 6.4.0 across the engine, maven and gradle modules. Non-array `type` handling is unchanged, so existing 3.0 specs are unaffected. Follow-up 3.1 items (examples array, $ref siblings, webhooks) tracked in #375. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 00a29c3 commit 4cf6aa8

11 files changed

Lines changed: 324 additions & 7 deletions

File tree

multiapi-engine/pom.xml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44

55
<groupId>com.sngular</groupId>
66
<artifactId>multiapi-engine</artifactId>
7-
<version>6.3.2</version>
7+
<version>6.4.0</version>
88
<packaging>jar</packaging>
99

1010
<properties>

multiapi-engine/src/main/java/com/sngular/api/generator/plugin/common/model/TypeConstants.java

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,8 @@ public final class TypeConstants {
1818

1919
public static final String ARRAY = "array";
2020

21+
public static final String NULL = "null";
22+
2123
public static final String MAP = "map";
2224

2325
public static final String BIG_DECIMAL = "bigDecimal";

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

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -220,7 +220,23 @@ public static boolean hasType(final JsonNode schema) {
220220
}
221221

222222
public static String getType(final JsonNode schema) {
223-
return hasType(schema) ? StringUtils.defaultIfEmpty(getNodeAsString(schema, "type"), "") : "";
223+
if (!hasType(schema)) {
224+
return "";
225+
}
226+
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+
}
236+
}
237+
return resolvedType;
238+
}
239+
return StringUtils.defaultIfEmpty(typeNode.textValue(), "");
224240
}
225241

226242
public static boolean hasItems(final JsonNode schema) {

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -50,7 +50,7 @@ private static boolean checkIfNumber(final String nodeType) {
5050

5151
private static String processNumber(final JsonNode schema) {
5252

53-
final var nodeType = schema.get("type").asText();
53+
final var nodeType = ApiTool.getType(schema);
5454
final var formatType = schema.has("format") ? schema.get("format").asText() : null;
5555
String type = TypeConstants.INTEGER;
5656
if (TypeConstants.NUMBER.equalsIgnoreCase(nodeType)) {

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

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -147,6 +147,13 @@ 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_TYPES = List
151+
.of(SpecFile.builder().filePath("openapigenerator/testOpenApi31Types/api-test.yml")
152+
.apiPackage("com.sngular.multifileplugin.openapi31types")
153+
.modelPackage("com.sngular.multifileplugin.openapi31types.model")
154+
.clientPackage("com.sngular.multifileplugin.openapi31types.client")
155+
.modelNameSuffix("DTO").build());
156+
150157
static final List<SpecFile> TEST_ANY_OF_IN_RESPONSE = List
151158
.of(SpecFile.builder().filePath("openapigenerator/testAnyOfInResponse/api-test.yml")
152159
.apiPackage("com.sngular.multifileplugin.testanyofinresponse")
@@ -763,6 +770,24 @@ static Function<Path, Boolean> validateExternalRefGeneration() {
763770
DEFAULT_MODEL_API, expectedExceptionFiles, DEFAULT_EXCEPTION_API);
764771
}
765772

773+
static Function<Path, Boolean> validateOpenApi31Types() {
774+
775+
final String DEFAULT_TARGET_API = "generated/com/sngular/multifileplugin/openapi31types";
776+
777+
final String DEFAULT_MODEL_API = "generated/com/sngular/multifileplugin/openapi31types/model";
778+
779+
final String COMMON_PATH = "openapigenerator/testOpenApi31Types/";
780+
781+
final String ASSETS_PATH = COMMON_PATH + "assets/";
782+
783+
final List<String> expectedTestApiFile = List.of(ASSETS_PATH + "ProfileApi.java");
784+
785+
final List<String> expectedTestApiModelFiles = List.of(ASSETS_PATH + "ProfileDTO.java");
786+
787+
return path -> commonTest(path, expectedTestApiFile, expectedTestApiModelFiles, DEFAULT_TARGET_API,
788+
DEFAULT_MODEL_API, Collections.emptyList(), null);
789+
}
790+
766791
static Function<Path, Boolean> validateAnyOfInResponse() {
767792

768793
final String DEFAULT_TARGET_API = "generated/com/sngular/multifileplugin/testanyofinresponse";

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

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,8 @@ static Stream<Arguments> fileSpecToProcess() {
8383
OpenApiGeneratorFixtures.validateEnumsLombokGeneration()),
8484
Arguments.of("testExternalRefsGeneration", OpenApiGeneratorFixtures.TEST_EXTERNAL_REF_GENERATION,
8585
OpenApiGeneratorFixtures.validateExternalRefGeneration()),
86+
Arguments.of("testOpenApi31Types", OpenApiGeneratorFixtures.TEST_OPEN_API_31_TYPES,
87+
OpenApiGeneratorFixtures.validateOpenApi31Types()),
8688
Arguments.of("testAnyOfInResponse", OpenApiGeneratorFixtures.TEST_ANY_OF_IN_RESPONSE,
8789
OpenApiGeneratorFixtures.validateAnyOfInResponse()),
8890
Arguments.of("testOneOfInResponse", OpenApiGeneratorFixtures.TEST_ONE_OF_IN_RESPONSE,
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
---
2+
# Reproduction/regression for OpenAPI 3.1.x support (issue #375).
3+
# Exercises JSON Schema 2020-12 array-valued `type`, including the ["<type>", "null"]
4+
# nullable idiom that replaces 3.0's `nullable: true`, plus a int64 and a plain array.
5+
openapi: "3.1.0"
6+
info:
7+
version: 1.0.0
8+
title: Profile API (OpenAPI 3.1)
9+
license:
10+
name: MIT
11+
servers:
12+
- url: http://localhost:8080/v1
13+
tags:
14+
- name: profile
15+
paths:
16+
/profile:
17+
get:
18+
summary: Get the profile
19+
operationId: getProfile
20+
tags:
21+
- profile
22+
responses:
23+
'200':
24+
description: The requested profile
25+
content:
26+
application/json:
27+
schema:
28+
$ref: "#/components/schemas/Profile"
29+
components:
30+
schemas:
31+
Profile:
32+
type: object
33+
properties:
34+
id:
35+
type: string
36+
nickname:
37+
type: ["string", "null"]
38+
age:
39+
type: ["integer", "null"]
40+
score:
41+
type: ["number", "null"]
42+
format: double
43+
loginCount:
44+
type: integer
45+
format: int64
46+
tags:
47+
type: array
48+
items:
49+
type: string
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
package com.sngular.multifileplugin.openapi31types;
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.openapi31types.model.ProfileDTO;
20+
21+
public interface ProfileApi {
22+
23+
/**
24+
* GET /profile: Get the profile
25+
* @return The requested profile; (status code 200)
26+
*/
27+
28+
@Operation(
29+
operationId = "getProfile",
30+
summary = "Get the profile",
31+
tags = {"profile"},
32+
responses = {
33+
@ApiResponse(responseCode = "200", description = "The requested profile", content = @Content(mediaType = "application/json", schema = @Schema(implementation = ProfileDTO.class)))
34+
}
35+
)
36+
@RequestMapping(
37+
method = RequestMethod.GET,
38+
value = "/profile",
39+
produces = {"application/json"}
40+
)
41+
42+
default ResponseEntity<ProfileDTO> getProfile() {
43+
return new ResponseEntity<>(HttpStatus.NOT_IMPLEMENTED);
44+
}
45+
46+
}
Lines changed: 177 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,177 @@
1+
package com.sngular.multifileplugin.openapi31types.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+
import java.util.List;
10+
import java.util.ArrayList;
11+
12+
@JsonDeserialize(builder = ProfileDTO.ProfileDTOBuilder.class)
13+
public class ProfileDTO {
14+
15+
@JsonProperty(value ="tags")
16+
private List<String> tags;
17+
@JsonProperty(value ="id")
18+
private String id;
19+
@JsonProperty(value ="loginCount")
20+
private Long loginCount;
21+
@JsonProperty(value ="age")
22+
private Integer age;
23+
@JsonProperty(value ="score")
24+
private Double score;
25+
@JsonProperty(value ="nickname")
26+
private String nickname;
27+
28+
private ProfileDTO(ProfileDTOBuilder builder) {
29+
this.tags = builder.tags;
30+
this.id = builder.id;
31+
this.loginCount = builder.loginCount;
32+
this.age = builder.age;
33+
this.score = builder.score;
34+
this.nickname = builder.nickname;
35+
36+
}
37+
38+
public static ProfileDTO.ProfileDTOBuilder builder() {
39+
return new ProfileDTO.ProfileDTOBuilder();
40+
}
41+
42+
@JsonPOJOBuilder(buildMethodName = "build", withPrefix = "")
43+
public static class ProfileDTOBuilder {
44+
45+
private List<String> tags = new ArrayList<String>();
46+
private String id;
47+
private Long loginCount;
48+
private Integer age;
49+
private Double score;
50+
private String nickname;
51+
52+
public ProfileDTO.ProfileDTOBuilder tags(List<String> tags) {
53+
if (!tags.isEmpty()) {
54+
this.tags.addAll(tags);
55+
}
56+
return this;
57+
}
58+
59+
public ProfileDTO.ProfileDTOBuilder tag(String tag) {
60+
if (Objects.nonNull(tag)) {
61+
this.tags.add(tag);
62+
}
63+
return this;
64+
}
65+
66+
public ProfileDTO.ProfileDTOBuilder id(String id) {
67+
this.id = id;
68+
return this;
69+
}
70+
71+
public ProfileDTO.ProfileDTOBuilder loginCount(Long loginCount) {
72+
this.loginCount = loginCount;
73+
return this;
74+
}
75+
76+
public ProfileDTO.ProfileDTOBuilder age(Integer age) {
77+
this.age = age;
78+
return this;
79+
}
80+
81+
public ProfileDTO.ProfileDTOBuilder score(Double score) {
82+
this.score = score;
83+
return this;
84+
}
85+
86+
public ProfileDTO.ProfileDTOBuilder nickname(String nickname) {
87+
this.nickname = nickname;
88+
return this;
89+
}
90+
91+
public ProfileDTO build() {
92+
ProfileDTO profileDTO = new ProfileDTO(this);
93+
return profileDTO;
94+
}
95+
}
96+
97+
@Schema(name = "tags", required = false)
98+
public List<String> getTags() {
99+
return tags;
100+
}
101+
public void setTags(List<String> tags) {
102+
this.tags = tags;
103+
}
104+
105+
@Schema(name = "id", required = false)
106+
public String getId() {
107+
return id;
108+
}
109+
public void setId(String id) {
110+
this.id = id;
111+
}
112+
113+
@Schema(name = "loginCount", required = false)
114+
public Long getLoginCount() {
115+
return loginCount;
116+
}
117+
public void setLoginCount(Long loginCount) {
118+
this.loginCount = loginCount;
119+
}
120+
121+
@Schema(name = "age", required = false)
122+
public Integer getAge() {
123+
return age;
124+
}
125+
public void setAge(Integer age) {
126+
this.age = age;
127+
}
128+
129+
@Schema(name = "score", required = false)
130+
public Double getScore() {
131+
return score;
132+
}
133+
public void setScore(Double score) {
134+
this.score = score;
135+
}
136+
137+
@Schema(name = "nickname", required = false)
138+
public String getNickname() {
139+
return nickname;
140+
}
141+
public void setNickname(String nickname) {
142+
this.nickname = nickname;
143+
}
144+
145+
@Override
146+
public boolean equals(Object o) {
147+
if (this == o) {
148+
return true;
149+
}
150+
if (o == null || getClass() != o.getClass()) {
151+
return false;
152+
}
153+
ProfileDTO profileDTO = (ProfileDTO) o;
154+
return Objects.equals(this.tags, profileDTO.tags) && Objects.equals(this.id, profileDTO.id) && Objects.equals(this.loginCount, profileDTO.loginCount) && Objects.equals(this.age, profileDTO.age) && Objects.equals(this.score, profileDTO.score) && Objects.equals(this.nickname, profileDTO.nickname);
155+
}
156+
157+
@Override
158+
public int hashCode() {
159+
return Objects.hash(tags, id, loginCount, age, score, nickname);
160+
}
161+
162+
@Override
163+
public String toString() {
164+
StringBuilder sb = new StringBuilder();
165+
sb.append("ProfileDTO{");
166+
sb.append(" tags:").append(tags).append(",");
167+
sb.append(" id:").append(id).append(",");
168+
sb.append(" loginCount:").append(loginCount).append(",");
169+
sb.append(" age:").append(age).append(",");
170+
sb.append(" score:").append(score).append(",");
171+
sb.append(" nickname:").append(nickname);
172+
sb.append("}");
173+
return sb.toString();
174+
}
175+
176+
177+
}

scs-multiapi-gradle-plugin/build.gradle

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ repositories {
2121
}
2222

2323
group = 'com.sngular'
24-
version = '6.3.2'
24+
version = '6.4.0'
2525

2626
def SCSMultiApiPluginGroupId = group
2727
def SCSMultiApiPluginVersion = version
@@ -31,7 +31,7 @@ dependencies {
3131
shadow localGroovy()
3232
shadow gradleApi()
3333

34-
implementation 'com.sngular:multiapi-engine:6.3.2'
34+
implementation 'com.sngular:multiapi-engine:6.4.0'
3535
testImplementation 'org.assertj:assertj-core:3.24.2'
3636
testImplementation 'com.puppycrawl.tools:checkstyle:10.12.3'
3737
testImplementation 'org.junit.platform:junit-platform-launcher:1.9.2'

0 commit comments

Comments
 (0)