Skip to content

Commit 1e36d51

Browse files
joseegman-idoneeajoseegarciaclaudejemacineiras
authored
Fix #375: resolve nested external $ref and handle content-less responses (#380)
* 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> * Update scs-multiapi-gradle-plugin version to 6.4.0 * Annotate Gradle tasks with @DisableCachingByDefault Gradle 9.x plugin validation fails the build when a task type is neither @CacheableTask nor @DisableCachingByDefault. OpenApiTask and AsyncApiTask are code generators whose spec-file inputs are not declared as cacheable inputs, so caching is disabled explicitly with a documented reason. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * Fix #375: resolve nested external $ref in OpenAPI generator and handle content-less responses * Bump version to 6.4.1 for all Maven and Gradle builds * Shorten @DisableCachingByDefault reason to satisfy line-length limit The annotation reason pushed the line to 121 chars, tripping Codacy's 120-char limit (2 new issues). Trim the wording; behaviour is unchanged. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * Add .codacy.yml to exclude false-positive sources from analysis Codacy flagged the Gradle task glue classes for "missing Javadoc" (the project does not use Javadoc) and generated test-resource fixtures for style rules that do not apply to generated code. Exclude both so the analysis only reports real, actionable issues. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> --------- Co-authored-by: joseegarcia <jose.garcia@disashop.com> Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Co-authored-by: Jose E. Garcia Maciñeiras <68995937+jemacineiras@users.noreply.github.com>
1 parent 12fd597 commit 1e36d51

21 files changed

Lines changed: 473 additions & 16 deletions

File tree

.codacy.yml

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
---
2+
# Codacy configuration.
3+
#
4+
# exclude_paths accepts glob patterns relative to the repository root and removes
5+
# the matched files from static analysis. We exclude sources that only produce
6+
# false positives for this project's conventions:
7+
#
8+
# * Gradle task glue classes: thin DefaultTask subclasses. Codacy flags them for
9+
# "missing Javadoc", but this project does not use Javadoc, so the warning is
10+
# noise rather than a real defect.
11+
# * Generated test fixtures under src/test/resources: these are the generator's
12+
# expected-output files (not hand-written source) and trip rules such as
13+
# line-length that do not apply to generated code.
14+
exclude_paths:
15+
- 'scs-multiapi-gradle-plugin/src/main/groovy/com/sngular/api/generator/plugin/*Task.groovy'
16+
- '**/src/test/resources/**'

.gitignore

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,3 +44,6 @@ pom.xml.bak
4444
*.bak
4545

4646
scs-multiapi-maven-plugin/dependency-reduced-pom.xml
47+
48+
# Internal analysis documents (not part of the plugin distribution)
49+
SCS-MULTIAPI-6.3.3-CODEGEN-BLOCKER.md

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.4.0</version>
7+
<version>6.4.1</version>
88
<packaging>jar</packaging>
99

1010
<properties>

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -272,7 +272,7 @@ private static List<SchemaFieldObject> processObjectProperty(
272272
final SchemaFieldObject field;
273273
if (ApiTool.hasRef(fieldBody)) {
274274
final var typeName = MapperUtil.getRefSchemaName(fieldBody, fieldName);
275-
final var refSchema = totalSchemas.get(MapperUtil.getRefSchemaKey(fieldBody));
275+
var refSchema = totalSchemas.get(MapperUtil.getRefSchemaKey(fieldBody));
276276
if (!antiLoopList.contains(typeName) && Objects.nonNull(refSchema) && ApiTool.hasType(refSchema)
277277
&& ApiTool.hasItems(refSchema) || ApiTool.getRefValue(fieldBody).contains(fieldName)) {
278278
if (antiLoopList.contains(typeName) && ApiTool.getRefValue(fieldBody).contains(fieldName)) {

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

Lines changed: 74 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,9 @@
99
import java.nio.file.Files;
1010
import java.nio.file.Path;
1111
import java.nio.file.Paths;
12+
import java.util.Iterator;
1213
import java.util.Map;
14+
import java.util.Map.Entry;
1315
import java.util.Objects;
1416

1517
import com.fasterxml.jackson.databind.JsonNode;
@@ -34,10 +36,16 @@ public static JsonNode solveRef(final String refValue, final Map<String, JsonNod
3436
} else {
3537
final var refValueArr = refValue.split("#");
3638
final var filePath = refValueArr[0];
39+
final URI actualFileBase = resolveActualBaseUri(rootFilePath, filePath);
3740
solvedRef = getPojoFromRef(rootFilePath, filePath);
3841
if (ApiTool.hasComponents(solvedRef)) {
3942
schemaMap.putAll(ApiTool.getComponentSchemas(solvedRef));
40-
solvedRef = solvedRef.findValue(MapperUtil.getKey(refValueArr[1]));
43+
if (refValueArr.length > 1) {
44+
solvedRef = solvedRef.findValue(MapperUtil.getKey(refValueArr[1]));
45+
}
46+
}
47+
if (Objects.nonNull(solvedRef) && Objects.nonNull(actualFileBase)) {
48+
resolveNestedFileRefs(solvedRef, actualFileBase);
4149
}
4250
}
4351
} else {
@@ -46,6 +54,71 @@ public static JsonNode solveRef(final String refValue, final Map<String, JsonNod
4654
return solvedRef;
4755
}
4856

57+
/**
58+
* Computes a clean schema-map key from a file-based $ref value.
59+
* E.g. "./ServiceType.yml" -> "SCHEMAS/SERVICE_TYPE"
60+
*/
61+
static String computeFileSchemaKey(final String refValue) {
62+
try {
63+
final String[] parts = refValue.split("/");
64+
final String lastRaw = parts[parts.length - 1];
65+
String lastName = lastRaw;
66+
if (lastName.contains(".")) {
67+
lastName = lastName.substring(0, lastName.indexOf('.'));
68+
}
69+
String category = parts.length >= 2 ? parts[parts.length - 2] : "schemas";
70+
if (".".equals(category) || "..".equals(category)) {
71+
category = "schemas";
72+
}
73+
return StringUtils.upperCase(category + "/" + StringCaseUtils.titleToSnakeCase(lastName));
74+
} catch (final Exception e) {
75+
return null;
76+
}
77+
}
78+
79+
private static URI resolveActualBaseUri(final URI rootFilePath, final String filePath) {
80+
try {
81+
final String cleaned = cleanUpPath(filePath).replace('\\', '/');
82+
final Path rootPath = Paths.get(rootFilePath);
83+
if (Files.exists(rootPath) && Files.isDirectory(rootPath)) {
84+
return rootPath.resolve(cleaned).normalize().getParent().toUri();
85+
}
86+
final Path parent = rootPath.getParent();
87+
if (Objects.nonNull(parent)) {
88+
return parent.resolve(cleaned).normalize().getParent().toUri();
89+
}
90+
return null;
91+
} catch (final Exception e) {
92+
return null;
93+
}
94+
}
95+
96+
private static void resolveNestedFileRefs(final JsonNode node, final URI baseUri) {
97+
if (Objects.isNull(node) || !node.isObject()) {
98+
return;
99+
}
100+
final Iterator<Entry<String, JsonNode>> fields = node.fields();
101+
while (fields.hasNext()) {
102+
final Entry<String, JsonNode> field = fields.next();
103+
if (field.getValue().isObject() && field.getValue().has("$ref")) {
104+
final String refVal = field.getValue().get("$ref").textValue();
105+
if (StringUtils.isNotEmpty(refVal) && !refVal.startsWith("#") && !refVal.startsWith("http")) {
106+
try {
107+
final URI nestedBase = resolveActualBaseUri(baseUri, refVal);
108+
final JsonNode resolved = getPojoFromRef(baseUri, refVal);
109+
if (Objects.nonNull(resolved)) {
110+
field.setValue(resolved);
111+
resolveNestedFileRefs(resolved, nestedBase);
112+
}
113+
} catch (final Exception ignored) {
114+
}
115+
}
116+
} else {
117+
resolveNestedFileRefs(field.getValue(), baseUri);
118+
}
119+
}
120+
}
121+
49122
public static JsonNode getPojoFromRef(final URI rootFilePath, final String refPath) {
50123
final JsonNode schemaFile;
51124
try {

multiapi-engine/src/main/java/com/sngular/api/generator/plugin/openapi/utils/MapperPathUtil.java

Lines changed: 32 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66

77
package com.sngular.api.generator.plugin.openapi.utils;
88

9+
import java.net.URI;
910
import java.nio.file.Path;
1011
import java.util.ArrayList;
1112
import java.util.Collection;
@@ -15,6 +16,7 @@
1516
import java.util.Map;
1617
import java.util.Map.Entry;
1718
import java.util.Objects;
19+
import java.util.Optional;
1820
import java.util.function.BiConsumer;
1921

2022
import com.fasterxml.jackson.databind.JsonNode;
@@ -223,11 +225,15 @@ private static List<RequestObject> mapRequestObject(
223225
"InlineObject" + operationIdWithCap, globalObject, baseDir))
224226
.build());
225227
} else {
226-
final var requestBodyNode = globalObject.getRequestBodyNode(MapperUtil.getRefSchemaKey(requestBody)).orElseThrow();
228+
final Optional<JsonNode> requestBodyNode = globalObject.getRequestBodyNode(MapperUtil.getRefSchemaKey(requestBody));
229+
if (requestBodyNode.isEmpty()) {
230+
return requestObjects;
231+
}
232+
final JsonNode actualRequestBody = requestBodyNode.get();
227233
requestObjects.add(RequestObject.builder()
228234
.required(ApiTool.hasNode(requestBody, REQUIRED))
229-
.isFormData(ApiTool.getNode(requestBodyNode, CONTENT).has("multipart/form-data"))
230-
.contentObjects(mapContentObject(specFile, ApiTool.getNode(requestBodyNode, CONTENT),
235+
.isFormData(ApiTool.getNode(actualRequestBody, CONTENT).has("multipart/form-data"))
236+
.contentObjects(mapContentObject(specFile, ApiTool.getNode(actualRequestBody, CONTENT),
231237
operationIdWithCap, globalObject, baseDir))
232238
.build());
233239
}
@@ -242,8 +248,11 @@ private static List<ParameterObject> mapParameterObjects(
242248
if (Objects.nonNull(parameters) && !parameters.isEmpty()) {
243249
for (final JsonNode parameter : parameters) {
244250
if (ApiTool.hasRef(parameter)) {
245-
final JsonNode refParameter = globalObject.getParameterNode(MapperUtil.getRefSchemaKey(parameter)).orElseThrow();
246-
parameterObjects.add(buildParameterObject(specFile, globalObject, refParameter, baseDir));
251+
final Optional<JsonNode> optRefParameter = globalObject.getParameterNode(MapperUtil.getRefSchemaKey(parameter));
252+
if (optRefParameter.isEmpty()) {
253+
continue;
254+
}
255+
parameterObjects.add(buildParameterObject(specFile, globalObject, optRefParameter.get(), baseDir));
247256
} else if (ApiTool.hasNode(parameter, CONTENT)) {
248257
parameterObjects.addAll(buildParameterContent(contentClassName, parameter, specFile, globalObject, baseDir));
249258
} else {
@@ -344,7 +353,21 @@ private static void buildResponse(
344353
final JsonNode response) {
345354
var realResponse = response;
346355
if (ApiTool.hasRef(response)) {
347-
realResponse = globalObject.getResponseNode(MapperUtil.getRefSchemaKey(response)).orElseThrow();
356+
final String refValue = ApiTool.getRefValue(response);
357+
if (refValue.startsWith("#")) {
358+
final Optional<JsonNode> resolvedResponse = globalObject.getResponseNode(MapperUtil.getRefSchemaKey(response));
359+
if (resolvedResponse.isEmpty()) {
360+
return;
361+
}
362+
realResponse = resolvedResponse.get();
363+
} else {
364+
try {
365+
final URI baseUri = baseDir.resolve(specFile.getFilePath()).getParent().toUri();
366+
realResponse = SchemaUtil.getPojoFromRef(baseUri, refValue);
367+
} catch (final Exception e) {
368+
return;
369+
}
370+
}
348371
}
349372
final String operationIdWithCap = operationId.substring(0, 1).toUpperCase() + operationId.substring(1);
350373
final var content = ApiTool.getNode(realResponse, CONTENT);
@@ -427,6 +450,9 @@ private static JsonNode getRefSchema(JsonNode schema, SpecFile specFile, GlobalO
427450
if (refValue.contains("schemas")) {
428451
refSchema = SchemaUtil.solveRef(refValue, globalObject.getSchemaMap(),
429452
baseDir.resolve(specFile.getFilePath()).getParent().toUri());
453+
if (Objects.nonNull(refSchema) && !refValue.contains("#")) {
454+
globalObject.getSchemaMap().put(inlinePojoName, refSchema);
455+
}
430456
} else if (refValue.contains("requestBodies")) {
431457
refSchema = SchemaUtil.solveRef(refValue, globalObject.getRequestBodyMap(),
432458
baseDir.resolve(specFile.getFilePath()).getParent().toUri());

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

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -182,6 +182,20 @@ public final class OpenApiGeneratorFixtures {
182182
.clientPackage("com.sngular.multifileplugin.externalpathitemref.client")
183183
.modelNameSuffix("DTO").build());
184184

185+
static final List<SpecFile> TEST_NESTED_EXTERNAL_REFS = List
186+
.of(SpecFile.builder().filePath("openapigenerator/testNestedExternalRefs/api-test.yml")
187+
.apiPackage("com.sngular.multifileplugin.testnestedexternalref")
188+
.modelPackage("com.sngular.multifileplugin.testnestedexternalref.model")
189+
.modelNameSuffix("DTO")
190+
.useLombokModelAnnotation(true).build());
191+
192+
static final List<SpecFile> TEST_NO_CONTENT_RESPONSES = List
193+
.of(SpecFile.builder().filePath("openapigenerator/testNoContentResponses/api-test.yml")
194+
.apiPackage("com.sngular.multifileplugin.testnocontentresponses")
195+
.modelPackage("com.sngular.multifileplugin.testnocontentresponses.model")
196+
.modelNameSuffix("DTO")
197+
.useLombokModelAnnotation(true).build());
198+
185199
static final List<SpecFile> TEST_ANY_OF_IN_RESPONSE = List
186200
.of(SpecFile.builder().filePath("openapigenerator/testAnyOfInResponse/api-test.yml")
187201
.apiPackage("com.sngular.multifileplugin.testanyofinresponse")
@@ -1370,6 +1384,37 @@ static Function<Path, Boolean> validateApiWithNoComponents() {
13701384

13711385
}
13721386

1387+
static Function<Path, Boolean> validateNestedExternalRefs() {
1388+
final String DEFAULT_TARGET_API = "generated/com/sngular/multifileplugin/testnestedexternalref";
1389+
1390+
final String DEFAULT_MODEL_API = "generated/com/sngular/multifileplugin/testnestedexternalref/model";
1391+
1392+
final String COMMON_PATH = "openapigenerator/testNestedExternalRefs/";
1393+
1394+
final List<String> expectedTestApiFiles = List.of(COMMON_PATH + "assets/ServicesApi.java");
1395+
1396+
final List<String> expectedTestApiModelFiles = List
1397+
.of(COMMON_PATH + "assets/InlineResponse200ListServicesDTO.java", COMMON_PATH + "assets/Service_typeDTO.java");
1398+
1399+
return path -> commonTest(path, expectedTestApiFiles, expectedTestApiModelFiles, DEFAULT_TARGET_API,
1400+
DEFAULT_MODEL_API, Collections.emptyList(), null);
1401+
}
1402+
1403+
static Function<Path, Boolean> validateNoContentResponses() {
1404+
final String DEFAULT_TARGET_API = "generated/com/sngular/multifileplugin/testnocontentresponses";
1405+
1406+
final String DEFAULT_MODEL_API = "generated/com/sngular/multifileplugin/testnocontentresponses/model";
1407+
1408+
final String COMMON_PATH = "openapigenerator/testNoContentResponses/";
1409+
1410+
final List<String> expectedTestApiFiles = List.of(COMMON_PATH + "assets/ItemsApi.java");
1411+
1412+
final List<String> expectedTestApiModelFiles = List.of(COMMON_PATH + "assets/ItemDTO.java");
1413+
1414+
return path -> commonTest(path, expectedTestApiFiles, expectedTestApiModelFiles, DEFAULT_TARGET_API,
1415+
DEFAULT_MODEL_API, Collections.emptyList(), null);
1416+
}
1417+
13731418
private static Boolean commonTest(final Path resultPath, final List<String> expectedFile,
13741419
final List<String> expectedModelFiles, final String targetApi, final String targetModel,
13751420
final List<String> expectedExceptionFiles, final String targetException) {

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
@@ -93,6 +93,10 @@ static Stream<Arguments> fileSpecToProcess() {
9393
OpenApiGeneratorFixtures.validateWebhookPathCollision()),
9494
Arguments.of("testExternalPathItemRefsGeneration", OpenApiGeneratorFixtures.TEST_EXTERNAL_PATH_ITEM_REF_GENERATION,
9595
OpenApiGeneratorFixtures.validateExternalPathItemRefGeneration()),
96+
Arguments.of("testNestedExternalRefs", OpenApiGeneratorFixtures.TEST_NESTED_EXTERNAL_REFS,
97+
OpenApiGeneratorFixtures.validateNestedExternalRefs()),
98+
Arguments.of("testNoContentResponses", OpenApiGeneratorFixtures.TEST_NO_CONTENT_RESPONSES,
99+
OpenApiGeneratorFixtures.validateNoContentResponses()),
96100
Arguments.of("testAnyOfInResponse", OpenApiGeneratorFixtures.TEST_ANY_OF_IN_RESPONSE,
97101
OpenApiGeneratorFixtures.validateAnyOfInResponse()),
98102
Arguments.of("testOneOfInResponse", OpenApiGeneratorFixtures.TEST_ONE_OF_IN_RESPONSE,
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
---
2+
openapi: "3.0.0"
3+
info:
4+
version: 1.0.0
5+
title: Nested External Ref Test
6+
license:
7+
name: MIT
8+
servers:
9+
- url: http://localhost:8080/v1
10+
paths:
11+
/services:
12+
get:
13+
summary: List all services
14+
operationId: listServices
15+
tags:
16+
- services
17+
responses:
18+
'200':
19+
description: A list of services
20+
content:
21+
application/json:
22+
schema:
23+
$ref: "schemas/Service.yml"
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
package com.sngular.multifileplugin.testnestedexternalref.model;
2+
3+
import com.fasterxml.jackson.annotation.JsonProperty;
4+
import lombok.Builder;
5+
import lombok.Value;
6+
import lombok.extern.jackson.Jacksonized;
7+
8+
@Value
9+
public class InlineResponse200ListServicesDTO {
10+
11+
@JsonProperty(value ="service_id")
12+
private String service_id;
13+
14+
@JsonProperty(value ="service_type")
15+
private Service_typeDTO service_type;
16+
17+
18+
@Builder
19+
@Jacksonized
20+
private InlineResponse200ListServicesDTO(String service_id, Service_typeDTO service_type) {
21+
this.service_id = service_id;
22+
this.service_type = service_type;
23+
24+
}
25+
26+
}

0 commit comments

Comments
 (0)