Skip to content

Commit cd17035

Browse files
joseegarciaclaude
andcommitted
Support additional OpenAPI 3.1 / JSON Schema 2020-12 features
Follow-up to the $ref+description fix, addressing further 3.1 gaps found in an audit of the schema generator. Structural correctness fixes (narrow blast radius; only affect specs using these constructs): - $defs: collect top-level `$defs` as a schema source and resolve `$ref: '#/$defs/...'` (ApiTool.getComponentSchemas). Models under $defs now generate and are referenceable. - prefixItems / items:false: tuple arrays and `items:false` degrade to List<Object> instead of an empty/invalid element type. - type:"null": a scalar null type degrades to Object instead of emitting an invalid `null` Java type. - nullability: a field that is nullable (3.0 `nullable:true` or the 3.1 ["T","null"] idiom) is no longer marked required, so no spurious @NotNull is generated (ApiTool.isNullable). - patternProperties: an object with patternProperties maps to Map<String,V> instead of an empty POJO. - 3.1 binary strings: `contentEncoding` / `contentMediaType` are recognised as binary (MultipartFile), complementing `format: binary`. Adds regression test `testOpenApi31Completeness` covering all of the above. Documented limitations (left non-breaking, deferred as larger work): - multi-type unions still pick the first concrete type (tested behaviour; needs a real union type model); - description/summary/examples propagation (wide blast radius: 51 specs use field-level description); - contains/propertyNames/unevaluated*/dependent*/if-then-else validation (needs new validators; parsed-through today); - info.summary / license.identifier metadata; - $id/$anchor resolution and $schema/$dynamicRef (ignored non-fatally). Bumps version 6.4.2 -> 6.5.0 (new features). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 05ac448 commit cd17035

11 files changed

Lines changed: 545 additions & 17 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.4.2</version>
7+
<version>6.5.0</version>
88
<packaging>jar</packaging>
99

1010
<properties>

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

Lines changed: 71 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,8 @@ public final class ApiTool {
4242

4343
public static final String SCHEMAS = "schemas";
4444

45+
public static final String DEFS = "$defs";
46+
4547
public static final String REQUIRED = "required";
4648

4749
public static final String PARAMETERS = "parameters";
@@ -153,7 +155,21 @@ public static JsonNode getItems(final JsonNode schema) {
153155
}
154156

155157
public static Map<String, JsonNode> getComponentSchemas(final JsonNode openApi) {
156-
return getComponentSchemasByType(openApi, SCHEMAS);
158+
final var schemasMap = getComponentSchemasByType(openApi, SCHEMAS);
159+
// JSON Schema 2020-12 / OpenAPI 3.1: reusable schemas may live under a
160+
// top-level `$defs` object instead of `components/schemas`.
161+
schemasMap.putAll(getDefsSchemas(openApi));
162+
return schemasMap;
163+
}
164+
165+
private static Map<String, JsonNode> getDefsSchemas(final JsonNode openApi) {
166+
final var schemasMap = new HashMap<String, JsonNode>();
167+
if (hasNode(openApi, DEFS)) {
168+
final var defs = getNode(openApi, DEFS);
169+
defs.fieldNames().forEachRemaining(name -> schemasMap.put(
170+
DEFS.toUpperCase() + "/" + StringCaseUtils.titleToSnakeCase(name), getNode(defs, name)));
171+
}
172+
return schemasMap;
157173
}
158174

159175
private static Map<String, JsonNode> getComponentSchemasByType(final JsonNode openApi, final String schemaType) {
@@ -226,7 +242,15 @@ public static String getType(final JsonNode schema) {
226242
return "";
227243
}
228244
final JsonNode typeNode = getNode(schema, "type");
229-
return typeNode.isArray() ? getArrayType(typeNode) : StringUtils.defaultIfEmpty(typeNode.textValue(), "");
245+
if (typeNode.isArray()) {
246+
return getArrayType(typeNode);
247+
}
248+
// OpenAPI 3.1 / JSON Schema 2020-12: a scalar `type: "null"` carries no concrete
249+
// Java type; degrade to object so we never emit an invalid `null` type.
250+
if (TypeConstants.NULL.equalsIgnoreCase(typeNode.textValue())) {
251+
return TypeConstants.OBJECT;
252+
}
253+
return StringUtils.defaultIfEmpty(typeNode.textValue(), "");
230254
}
231255

232256
private static String getArrayType(final JsonNode typeNode) {
@@ -347,17 +371,59 @@ public static boolean isDateTime(final JsonNode schema) {
347371
public static boolean isBinary(final JsonNode schema) {
348372
final boolean isMultipartFile;
349373
if (hasType(schema) && TypeConstants.STRING.equalsIgnoreCase(getType(schema))) {
350-
if (hasNode(schema, FORMAT)) {
351-
isMultipartFile = "binary".equalsIgnoreCase(getNode(schema, FORMAT).textValue());
374+
if (hasNode(schema, FORMAT) && "binary".equalsIgnoreCase(getNode(schema, FORMAT).textValue())) {
375+
isMultipartFile = true;
352376
} else {
353-
isMultipartFile = false;
377+
// OpenAPI 3.1 / JSON Schema 2020-12 replace `format: binary` with the
378+
// `contentEncoding` / `contentMediaType` keywords for binary payloads.
379+
isMultipartFile = hasNode(schema, "contentEncoding") || hasNode(schema, "contentMediaType");
354380
}
355381
} else {
356382
isMultipartFile = false;
357383
}
358384
return isMultipartFile;
359385
}
360386

387+
public static boolean isNullable(final JsonNode schema) {
388+
if (Objects.isNull(schema)) {
389+
return false;
390+
}
391+
// OpenAPI 3.0 `nullable: true`.
392+
if (getNodeAsBoolean(schema, "nullable")) {
393+
return true;
394+
}
395+
// OpenAPI 3.1 idiom: `type: ["<type>", "null"]`.
396+
if (hasType(schema)) {
397+
final JsonNode typeNode = getNode(schema, "type");
398+
if (typeNode.isArray()) {
399+
for (final JsonNode element : typeNode) {
400+
if (TypeConstants.NULL.equalsIgnoreCase(element.asText())) {
401+
return true;
402+
}
403+
}
404+
} else {
405+
return TypeConstants.NULL.equalsIgnoreCase(typeNode.textValue());
406+
}
407+
}
408+
return false;
409+
}
410+
411+
public static boolean hasPatternProperties(final JsonNode schema) {
412+
return hasNode(schema, "patternProperties");
413+
}
414+
415+
public static JsonNode getPatternProperties(final JsonNode schema) {
416+
return getNode(schema, "patternProperties");
417+
}
418+
419+
public static boolean hasPrefixItems(final JsonNode schema) {
420+
return hasNode(schema, "prefixItems");
421+
}
422+
423+
public static JsonNode getPrefixItems(final JsonNode schema) {
424+
return getNode(schema, "prefixItems");
425+
}
426+
361427
public static List<JsonNode> findContentSchemas(final JsonNode schema) {
362428
return hasNode(schema, "content") ? schema.findValues("schema") : Collections.emptyList();
363429
}

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

Lines changed: 49 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -169,12 +169,12 @@ private static Set<SchemaFieldObject> getFields(
169169
baseDir));
170170
}
171171
} else if (TypeConstants.ARRAY.equalsIgnoreCase(ApiTool.getType(schema))) {
172-
final String itemType = ApiTool.hasRef(ApiTool.getItems(schema)) ? MapperUtil.getPojoNameFromRef(ApiTool.getItems(schema), specFile, null)
173-
: ApiTool.getType(ApiTool.getItems(schema));
174172
fieldObjectArrayList.add(SchemaFieldObject.builder()
175173
.baseName("items")
176-
.dataType(SchemaFieldObjectType.fromTypeList(TypeConstants.ARRAY, itemType))
174+
.dataType(SchemaFieldObjectType.fromTypeList(TypeConstants.ARRAY, resolveArrayItemType(schema, specFile)))
177175
.build());
176+
} else if (ApiTool.hasPatternProperties(schema)) {
177+
fieldObjectArrayList.add(buildPatternPropertiesField(ApiTool.getName(schema), schema, specFile));
178178
} else if (ApiTool.isAllOf(schema)) {
179179
fieldObjectArrayList.addAll(processAllOf(totalSchemas, ApiTool.getAllOf(schema), specFile, compositedSchemas, antiLoopList, baseDir));
180180
} else if (ApiTool.isAnyOf(schema)) {
@@ -209,6 +209,8 @@ private static List<SchemaFieldObject> processFieldObjectList(
209209
fieldObjectArrayList.addAll(processArray(fieldName, className, schema, specFile, totalSchemas, compositedSchemas, antiLoopList, baseDir));
210210
} else if (ApiTool.hasAdditionalProperties(schema)) {
211211
fieldObjectArrayList.addAll(processMap(fieldName, schema, specFile, totalSchemas, compositedSchemas, antiLoopList, baseDir));
212+
} else if (ApiTool.hasPatternProperties(schema)) {
213+
fieldObjectArrayList.add(buildPatternPropertiesField(fieldName, schema, specFile));
212214
} else if (ApiTool.hasRef(schema)) {
213215
fieldObjectArrayList.add(
214216
processRef(fieldName, schema, new SchemaFieldObjectType(MapperUtil.getSimpleType(schema, specFile)), totalSchemas, compositedSchemas, antiLoopList, specFile, baseDir));
@@ -426,11 +428,17 @@ private static List<SchemaFieldObject> processArray(
426428
final Map<String, SchemaObject> compositedSchemas, final Set<String> antiLoopList, final Path baseDir) {
427429
final List<SchemaFieldObject> fieldObjectArrayList = new LinkedList<>();
428430

429-
if (!ApiTool.hasItems(schema)) {
431+
if (!ApiTool.hasItems(schema) || ApiTool.getItems(schema).isBoolean()) {
432+
// No `items` schema, or `items: false` (JSON Schema 2020-12). If the array
433+
// declares positional `prefixItems` (tuple), or nothing typable at all, the
434+
// element type degrades to Object -> List<Object>.
435+
final boolean isTuple = ApiTool.hasPrefixItems(schema);
430436
fieldObjectArrayList.add(SchemaFieldObject
431437
.builder()
432438
.baseName(fieldName)
433-
.dataType(new SchemaFieldObjectType(TypeConstants.OBJECT))
439+
.dataType(isTuple
440+
? SchemaFieldObjectType.fromTypeList(TypeConstants.ARRAY, TypeConstants.OBJECT)
441+
: new SchemaFieldObjectType(TypeConstants.OBJECT))
434442
.build());
435443
} else {
436444
final var items = ApiTool.getItems(schema);
@@ -626,7 +634,9 @@ private static SchemaObject createComposedSchema(
626634
private static void setFieldType(
627635
final SchemaFieldObject field, final JsonNode schemaProperty, final JsonNode schema,
628636
final CommonSpecFile specFile, final String key) {
629-
field.setRequired(ApiTool.hasRequired(schema) && ApiTool.checkIfRequired(schema, key));
637+
// A nullable field (3.0 `nullable: true` or the 3.1 ["T","null"] idiom) must not be
638+
// marked required, otherwise the generated @NotNull would reject a legitimate null value.
639+
field.setRequired(ApiTool.hasRequired(schema) && ApiTool.checkIfRequired(schema, key) && !ApiTool.isNullable(schemaProperty));
630640
if (ApiTool.isArray(schemaProperty)) {
631641
final String typeArray;
632642
if (ApiTool.hasItems(schemaProperty)) {
@@ -888,5 +898,38 @@ private static String getImportClass(final String type) {
888898
return StringUtils.isNotBlank(type) && !TypeConstants.NO_IMPORT_TYPE.contains(type) ? StringUtils.capitalize(type) : "";
889899
}
890900

901+
private static String resolveArrayItemType(final JsonNode schema, final CommonSpecFile specFile) {
902+
final var items = ApiTool.getItems(schema);
903+
if (Objects.nonNull(items) && !items.isBoolean()) {
904+
return ApiTool.hasRef(items) ? MapperUtil.getPojoNameFromRef(items, specFile, null) : ApiTool.getType(items);
905+
}
906+
// `prefixItems` (tuple) or `items: false` -> degrade the element type to Object.
907+
return TypeConstants.OBJECT;
908+
}
909+
910+
private static SchemaFieldObject buildPatternPropertiesField(
911+
final String fieldName, final JsonNode schema, final CommonSpecFile specFile) {
912+
// JSON Schema 2020-12 `patternProperties` maps regex keys to a value schema.
913+
// We model it as Map<String, ValueType> using the first declared pattern's value schema.
914+
final var patternProps = ApiTool.getPatternProperties(schema);
915+
final var valueSchemas = patternProps.elements();
916+
final JsonNode valueSchema = valueSchemas.hasNext() ? valueSchemas.next() : null;
917+
final String valueType;
918+
if (Objects.isNull(valueSchema)) {
919+
valueType = TypeConstants.OBJECT;
920+
} else if (ApiTool.hasRef(valueSchema)) {
921+
valueType = MapperUtil.getPojoNameFromRef(valueSchema, specFile, null);
922+
} else if (isBasicType(valueSchema) && ApiTool.hasType(valueSchema)) {
923+
valueType = MapperUtil.getSimpleType(valueSchema, specFile);
924+
} else {
925+
valueType = TypeConstants.OBJECT;
926+
}
927+
return SchemaFieldObject
928+
.builder()
929+
.baseName(StringUtils.defaultIfBlank(fieldName, ADDITIONAL_PROPERTIES))
930+
.dataType(SchemaFieldObjectType.fromTypeList(TypeConstants.MAP, valueType))
931+
.build();
932+
}
933+
891934

892935
}

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

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

185+
static final List<SpecFile> TEST_OPEN_API_31_COMPLETENESS = List
186+
.of(SpecFile.builder().filePath("openapigenerator/testOpenApi31Completeness/api-test.yml")
187+
.apiPackage("com.sngular.multifileplugin.openapi31completeness")
188+
.modelPackage("com.sngular.multifileplugin.openapi31completeness.model")
189+
.clientPackage("com.sngular.multifileplugin.openapi31completeness.client")
190+
.modelNameSuffix("DTO").build());
191+
185192
static final List<SpecFile> TEST_EXTERNAL_PATH_ITEM_REF_GENERATION = List
186193
.of(SpecFile.builder().filePath("openapigenerator/testExternalPathItemRefsGeneration/api-test.yml")
187194
.apiPackage("com.sngular.multifileplugin.externalpathitemref")
@@ -891,6 +898,25 @@ static Function<Path, Boolean> validateOpenApi31Types() {
891898
DEFAULT_MODEL_API, Collections.emptyList(), null);
892899
}
893900

901+
static Function<Path, Boolean> validateOpenApi31Completeness() {
902+
903+
final String DEFAULT_TARGET_API = "generated/com/sngular/multifileplugin/openapi31completeness";
904+
905+
final String DEFAULT_MODEL_API = "generated/com/sngular/multifileplugin/openapi31completeness/model";
906+
907+
final String COMMON_PATH = "openapigenerator/testOpenApi31Completeness/";
908+
909+
final String ASSETS_PATH = COMMON_PATH + "assets/";
910+
911+
final List<String> expectedTestApiFile = List.of(ASSETS_PATH + "GadgetApi.java");
912+
913+
final List<String> expectedTestApiModelFiles = List.of(ASSETS_PATH + "GadgetDTO.java",
914+
ASSETS_PATH + "PersonDTO.java");
915+
916+
return path -> commonTest(path, expectedTestApiFile, expectedTestApiModelFiles, DEFAULT_TARGET_API,
917+
DEFAULT_MODEL_API, Collections.emptyList(), null);
918+
}
919+
894920
static Function<Path, Boolean> validateRefWithDescription() {
895921

896922
final String DEFAULT_TARGET_API = "generated/com/sngular/multifileplugin/refwithdescription";

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
@@ -87,6 +87,8 @@ static Stream<Arguments> fileSpecToProcess() {
8787
OpenApiGeneratorFixtures.validateOpenApi31Types()),
8888
Arguments.of("testRefWithDescription", OpenApiGeneratorFixtures.TEST_REF_WITH_DESCRIPTION,
8989
OpenApiGeneratorFixtures.validateRefWithDescription()),
90+
Arguments.of("testOpenApi31Completeness", OpenApiGeneratorFixtures.TEST_OPEN_API_31_COMPLETENESS,
91+
OpenApiGeneratorFixtures.validateOpenApi31Completeness()),
9092
Arguments.of("testWebhooks", OpenApiGeneratorFixtures.TEST_WEBHOOKS,
9193
OpenApiGeneratorFixtures.validateWebhooks()),
9294
Arguments.of("testOpenApi31Union", OpenApiGeneratorFixtures.TEST_OPEN_API_31_UNION,
Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
---
2+
# Regression for additional OpenAPI 3.1 / JSON Schema 2020-12 features:
3+
# - $defs as a schema source (#/$defs/Person) -> generates PersonDTO
4+
# - prefixItems tuple arrays -> List<Object>
5+
# - scalar type: "null" -> Object (no invalid `null` type)
6+
# - nullable via ["T","null"] idiom even when listed as required -> no @NotNull
7+
# - patternProperties object -> Map<String, T>
8+
# - 3.1 binary string via contentEncoding -> MultipartFile
9+
openapi: "3.1.0"
10+
info:
11+
version: 1.0.0
12+
title: Gadget API (OpenAPI 3.1)
13+
license:
14+
name: MIT
15+
servers:
16+
- url: http://localhost:8080/v1
17+
tags:
18+
- name: gadget
19+
paths:
20+
/gadget:
21+
get:
22+
summary: Get a gadget
23+
operationId: getGadget
24+
tags:
25+
- gadget
26+
responses:
27+
'200':
28+
description: The requested gadget
29+
content:
30+
application/json:
31+
schema:
32+
$ref: "#/components/schemas/Gadget"
33+
components:
34+
schemas:
35+
Gadget:
36+
type: object
37+
required:
38+
- serial
39+
properties:
40+
id:
41+
type: string
42+
serial:
43+
type: ["string", "null"]
44+
payload:
45+
type: string
46+
contentEncoding: base64
47+
coords:
48+
type: array
49+
prefixItems:
50+
- type: number
51+
- type: number
52+
metadata:
53+
type: object
54+
patternProperties:
55+
"^x-":
56+
type: string
57+
nothing:
58+
type: "null"
59+
owner:
60+
$ref: "#/$defs/Person"
61+
$defs:
62+
Person:
63+
type: object
64+
properties:
65+
name:
66+
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.openapi31completeness;
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.openapi31completeness.model.GadgetDTO;
20+
21+
public interface GadgetApi {
22+
23+
/**
24+
* GET /gadget: Get a gadget
25+
* @return The requested gadget; (status code 200)
26+
*/
27+
28+
@Operation(
29+
operationId = "getGadget",
30+
summary = "Get a gadget",
31+
tags = {"gadget"},
32+
responses = {
33+
@ApiResponse(responseCode = "200", description = "The requested gadget", content = @Content(mediaType = "application/json", schema = @Schema(implementation = GadgetDTO.class)))
34+
}
35+
)
36+
@RequestMapping(
37+
method = RequestMethod.GET,
38+
value = "/gadget",
39+
produces = {"application/json"}
40+
)
41+
42+
default ResponseEntity<GadgetDTO> getGadget() {
43+
return new ResponseEntity<>(HttpStatus.NOT_IMPLEMENTED);
44+
}
45+
46+
}

0 commit comments

Comments
 (0)