From bf405c202123bee3ab596d7f7c3f3de5e17a28db Mon Sep 17 00:00:00 2001 From: Harry Chan <38070640+re-thc@users.noreply.github.com> Date: Fri, 20 Feb 2026 18:35:13 +0800 Subject: [PATCH 1/8] openapi 3.1 --- README.md | 6 + .../generator/core/openapi/DocContext.java | 4 + .../generator/core/openapi/KnownTypes.java | 14 +- .../core/openapi/OpenAPISerializer.java | 46 +++++- .../core/openapi/SchemaDocBuilder.java | 47 +++++- .../core/openapi/OpenAPISerializerTest.java | 33 +++++ .../http/maven/openapi/OpenAPIMergerUtil.java | 140 ++++++++++++++++-- .../openapi/jsonb/SchemaCustomAdaptor.java | 96 +++++++++++- .../maven/openapi/OpenAPIMergerUtilTest.java | 69 +++++++++ .../jsonb/SchemaCustomAdaptorTest.java | 78 ++++++++++ .../src/main/resources/moar-openapi.json | 1 - .../src/main/resources/public/openapi.json | 44 ++---- .../http/generator/JavalinProcessorTest.java | 15 +- .../resources/expectedInheritedOpenApi.json | 5 +- .../src/test/resources/expectedOpenApi.json | 8 +- .../src/main/resources/public/openapi.json | 37 ++--- .../src/main/resources/public/openapi.json | 69 ++++----- .../http/generator/NimaProcessorTest.java | 7 +- .../src/test/resources/expectedOpenApi.json | 8 +- .../src/main/resources/public/openapi.json | 43 ++---- .../src/test/resources/expectedOpenApi.json | 16 +- .../src/test/resources/expectedOpenApi.json | 16 +- 22 files changed, 622 insertions(+), 180 deletions(-) create mode 100644 http-generator-core/src/test/java/io/avaje/http/generator/core/openapi/OpenAPISerializerTest.java create mode 100644 openapi-maven-plugin/src/test/java/io/avaje/http/maven/openapi/OpenAPIMergerUtilTest.java create mode 100644 openapi-maven-plugin/src/test/java/io/avaje/http/maven/openapi/jsonb/SchemaCustomAdaptorTest.java diff --git a/README.md b/README.md index d31c602d7..fa634f272 100644 --- a/README.md +++ b/README.md @@ -103,6 +103,12 @@ To force the AP to generate with `@javax.inject.Singleton`(in the case where you ``` +OpenAPI output is generated as OpenAPI 3.1. + +The generated document sets: + +- `openapi: 3.1.2` + ### Usage with Javalin The annotation processor will generate controller classes implementing the `AvajeJavalinPlugin` interface, which we can register in javalin using: diff --git a/http-generator-core/src/main/java/io/avaje/http/generator/core/openapi/DocContext.java b/http-generator-core/src/main/java/io/avaje/http/generator/core/openapi/DocContext.java index af697bffc..e615fb9d5 100644 --- a/http-generator-core/src/main/java/io/avaje/http/generator/core/openapi/DocContext.java +++ b/http-generator-core/src/main/java/io/avaje/http/generator/core/openapi/DocContext.java @@ -29,6 +29,7 @@ import io.swagger.v3.oas.models.Operation; import io.swagger.v3.oas.models.PathItem; import io.swagger.v3.oas.models.Paths; +import io.swagger.v3.oas.models.SpecVersion; import io.swagger.v3.oas.models.info.Info; import io.swagger.v3.oas.models.media.Content; import io.swagger.v3.oas.models.media.Schema; @@ -63,6 +64,9 @@ public boolean isOpenApiAvailable() { private OpenAPI initOpenAPI() { OpenAPI openAPI = new OpenAPI(); + openAPI.setOpenapi("3.1.2"); + openAPI.setSpecVersion(SpecVersion.V31); + openAPI.setJsonSchemaDialect("https://spec.openapis.org/oas/3.1/dialect/base"); openAPI.setPaths(new Paths()); Server server = new Server(); diff --git a/http-generator-core/src/main/java/io/avaje/http/generator/core/openapi/KnownTypes.java b/http-generator-core/src/main/java/io/avaje/http/generator/core/openapi/KnownTypes.java index 5888f85b6..d2d9dcda9 100644 --- a/http-generator-core/src/main/java/io/avaje/http/generator/core/openapi/KnownTypes.java +++ b/http-generator-core/src/main/java/io/avaje/http/generator/core/openapi/KnownTypes.java @@ -101,7 +101,7 @@ public Schema createSchema() { private class BoolType implements KnownType { @Override public Schema createSchema() { - return new BooleanSchema().nullable(Boolean.FALSE); + return new BooleanSchema(); } } @@ -115,14 +115,14 @@ public Schema createSchema() { private class IntType implements KnownType { @Override public Schema createSchema() { - return new IntegerSchema().nullable(Boolean.FALSE); + return new IntegerSchema(); } } private class IntArrayType implements KnownType { @Override public Schema createSchema() { - return new ArraySchema().items(new IntegerSchema().nullable(Boolean.FALSE)); + return new ArraySchema().items(new IntegerSchema()); } } @@ -136,14 +136,14 @@ public Schema createSchema() { private class PLongType implements KnownType { @Override public Schema createSchema() { - return new IntegerSchema().format("int64").nullable(Boolean.FALSE); + return new IntegerSchema().format("int64"); } } private class PLongArrayType implements KnownType { @Override public Schema createSchema() { - return new ArraySchema().items(new IntegerSchema().format("int64").nullable(Boolean.FALSE)); + return new ArraySchema().items(new IntegerSchema().format("int64")); } } @@ -157,14 +157,14 @@ public Schema createSchema() { private class PNumberType implements KnownType { @Override public Schema createSchema() { - return new NumberSchema().nullable(Boolean.FALSE); + return new NumberSchema(); } } private class PNumberArrayType implements KnownType { @Override public Schema createSchema() { - return new ArraySchema().items(new NumberSchema().nullable(Boolean.FALSE)); + return new ArraySchema().items(new NumberSchema()); } } diff --git a/http-generator-core/src/main/java/io/avaje/http/generator/core/openapi/OpenAPISerializer.java b/http-generator-core/src/main/java/io/avaje/http/generator/core/openapi/OpenAPISerializer.java index 7b6055eb6..e86e8f7c6 100644 --- a/http-generator-core/src/main/java/io/avaje/http/generator/core/openapi/OpenAPISerializer.java +++ b/http-generator-core/src/main/java/io/avaje/http/generator/core/openapi/OpenAPISerializer.java @@ -1,10 +1,13 @@ package io.avaje.http.generator.core.openapi; import java.lang.reflect.Field; +import java.lang.reflect.Modifier; import java.util.Collection; import java.util.Map; import java.util.Set; +import io.swagger.v3.oas.models.media.Schema; + final class OpenAPISerializer { private static final Set IGNORED_FIELDS = Set.of( @@ -16,7 +19,6 @@ final class OpenAPISerializer { "EXPLICIT_OBJECT_SCHEMA_PROPERTY", "USE_ARBITRARY_SCHEMA_PROPERTY", "exampleSetFlag", - "types", "specVersion"); private OpenAPISerializer() {} @@ -76,6 +78,9 @@ static String serialize(Object obj) throws IllegalAccessException { var firstField = true; for (final Field field : fields) { + if (Modifier.isStatic(field.getModifiers())) { + continue; + } // skip JsonIgnored fields if (IGNORED_FIELDS @@ -84,14 +89,24 @@ static String serialize(Object obj) throws IllegalAccessException { } field.setAccessible(true); - final var value = field.get(obj); + Object value = field.get(obj); + if (obj instanceof Schema) { + final Schema schema = (Schema) obj; + if ("type".equals(field.getName()) && hasSchemaTypes(schema.getTypes())) { + continue; + } + if ("types".equals(field.getName())) { + value = schemaTypeValue(schema); + } + } if (value != null) { if (!firstField) { sb.append(","); } + final var jsonFieldName = jsonFieldName(obj, field.getName()); sb.append("\""); - sb.append(field.getName().replace("_enum", "enum")); + sb.append(jsonFieldName); sb.append("\" : "); write(sb, value); firstField = false; @@ -127,6 +142,31 @@ static Field[] getAllFields(Class clazz) { } } + private static boolean hasSchemaTypes(Set types) { + return types != null && !types.isEmpty(); + } + + private static Object schemaTypeValue(Schema schema) { + final var types = schema.getTypes(); + if (!hasSchemaTypes(types)) { + return schema.getType(); + } + if (types.size() == 1) { + return types.iterator().next(); + } + return types; + } + + private static String jsonFieldName(Object container, String fieldName) { + if ("_enum".equals(fieldName)) { + return "enum"; + } + if (container instanceof Schema && "types".equals(fieldName)) { + return "type"; + } + return fieldName; + } + static boolean isPrimitiveWrapperType(Object value) { return value instanceof Boolean diff --git a/http-generator-core/src/main/java/io/avaje/http/generator/core/openapi/SchemaDocBuilder.java b/http-generator-core/src/main/java/io/avaje/http/generator/core/openapi/SchemaDocBuilder.java index 9468bed2d..54cd83811 100644 --- a/http-generator-core/src/main/java/io/avaje/http/generator/core/openapi/SchemaDocBuilder.java +++ b/http-generator-core/src/main/java/io/avaje/http/generator/core/openapi/SchemaDocBuilder.java @@ -5,6 +5,7 @@ import io.avaje.http.generator.core.javadoc.Javadoc; import io.swagger.v3.oas.models.media.StringSchema; import java.util.ArrayList; +import java.util.LinkedHashSet; import java.util.List; import java.util.Map; import java.util.Optional; @@ -137,11 +138,11 @@ private static TypeMirror typeArgument(TypeMirror type) { } Schema toSchema(Element element) { - final var schema = toSchema(element.asType()); + var schema = toSchema(element.asType()); setLengthMinMax(element, schema); setFormatFromValidation(element, schema); if (isNotNullable(element)) { - schema.setNullable(Boolean.FALSE); + schema = markNotNullable(schema); } return schema; } @@ -213,8 +214,7 @@ private Schema buildOptionalSchema(TypeMirror type) { } } // Since it's explicitly optional, we should explicitly say it's nullable - itemSchema.nullable(Boolean.TRUE); - return itemSchema; + return markNullable(itemSchema); } private Schema buildIterableSchema(TypeMirror type) { @@ -271,7 +271,7 @@ private void populateObjectSchema(TypeMirror objectType, Schema objectSch for (VariableElement field : allFields(element)) { Schema propSchema = toSchema(field.asType()); if (isNotNullable(field)) { - propSchema.setNullable(Boolean.FALSE); + propSchema = markNotNullable(propSchema); objectSchema.addRequiredItem(field.getSimpleName().toString()); } setDescription(field, propSchema); @@ -281,6 +281,43 @@ private void populateObjectSchema(TypeMirror objectType, Schema objectSch } } + private Schema markNullable(Schema schema) { + if (schema.get$ref() != null) { + final var oneOf = new ArrayList(); + oneOf.add(schema); + oneOf.add(new Schema<>().type("null")); + return new Schema<>().oneOf(oneOf); + } + final var schemaType = schema.getType(); + if (schemaType != null && !schemaType.isBlank()) { + final var union = new LinkedHashSet(); + union.add(schemaType); + union.add("null"); + schema.setType(null); + schema.setTypes(union); + schema.setNullable(null); + return schema; + } + final var union = new LinkedHashSet(); + if (schema.getTypes() != null) { + union.addAll(schema.getTypes()); + } + union.add("null"); + schema.setTypes(union); + schema.setNullable(null); + return schema; + } + + private Schema markNotNullable(Schema schema) { + if (schema.getTypes() != null && schema.getTypes().contains("null")) { + final var nonNullTypes = new LinkedHashSet(schema.getTypes()); + nonNullTypes.remove("null"); + schema.setTypes(nonNullTypes.isEmpty() ? null : nonNullTypes); + } + schema.setNullable(null); + return schema; + } + private void setFormatFromValidation(Element element, Schema propSchema) { if (EmailPrism.isPresent(element) || JavaxEmailPrism.isPresent(element)) { propSchema.setFormat("email"); diff --git a/http-generator-core/src/test/java/io/avaje/http/generator/core/openapi/OpenAPISerializerTest.java b/http-generator-core/src/test/java/io/avaje/http/generator/core/openapi/OpenAPISerializerTest.java new file mode 100644 index 000000000..51a8f0b85 --- /dev/null +++ b/http-generator-core/src/test/java/io/avaje/http/generator/core/openapi/OpenAPISerializerTest.java @@ -0,0 +1,33 @@ +package io.avaje.http.generator.core.openapi; + +import static org.assertj.core.api.Assertions.assertThat; + +import io.swagger.v3.oas.models.media.Schema; +import java.util.LinkedHashSet; +import java.util.Set; +import org.junit.jupiter.api.Test; + +class OpenAPISerializerTest { + + @Test + void serializesSchemaTypesAsTypeArray() throws IllegalAccessException { + final var schema = new Schema<>().types(new LinkedHashSet<>(Set.of("string", "null"))); + + final var json = OpenAPISerializer.serialize(schema); + + assertThat(json).contains("\"type\" : ["); + assertThat(json).contains("\"null\""); + assertThat(json).doesNotContain("\"types\""); + } + + @Test + void serializesSingleSchemaTypeAsScalar() throws IllegalAccessException { + final var schema = new Schema<>().types(new LinkedHashSet<>(Set.of("string"))); + + final var json = OpenAPISerializer.serialize(schema); + + assertThat(json).contains("\"type\" : \"string\""); + assertThat(json).doesNotContain("\"type\" : ["); + assertThat(json).doesNotContain("\"types\""); + } +} diff --git a/openapi-maven-plugin/src/main/java/io/avaje/http/maven/openapi/OpenAPIMergerUtil.java b/openapi-maven-plugin/src/main/java/io/avaje/http/maven/openapi/OpenAPIMergerUtil.java index 63dcd9f96..44d1688cc 100644 --- a/openapi-maven-plugin/src/main/java/io/avaje/http/maven/openapi/OpenAPIMergerUtil.java +++ b/openapi-maven-plugin/src/main/java/io/avaje/http/maven/openapi/OpenAPIMergerUtil.java @@ -7,6 +7,7 @@ import io.swagger.v3.oas.models.OpenAPI; import io.swagger.v3.oas.models.PathItem; import io.swagger.v3.oas.models.Paths; +import io.swagger.v3.oas.models.SpecVersion; import io.swagger.v3.oas.models.info.Contact; import io.swagger.v3.oas.models.info.Info; import io.swagger.v3.oas.models.info.License; @@ -22,8 +23,10 @@ import java.util.ArrayList; import java.util.HashMap; +import java.util.LinkedHashSet; import java.util.List; import java.util.Map; +import java.util.Set; import java.util.function.BiFunction; import java.util.function.Function; @@ -71,7 +74,7 @@ public static OpenAPI merge(final OpenAPI primary, final OpenAPI secondary) { return secondary; } else { final OpenAPI merged = new OpenAPI(); - merged.setOpenapi(firstNotBlank(primary.getOpenapi(), secondary.getOpenapi())); + merged.setOpenapi(preferredOpenApiVersion(primary.getOpenapi(), secondary.getOpenapi())); merged.setInfo(merge(primary.getInfo(), secondary.getInfo())); merged.setExternalDocs(merge(primary.getExternalDocs(), secondary.getExternalDocs())); merged.setServers(merge(primary.getServers(), secondary.getServers(), Server::getUrl)); @@ -81,11 +84,7 @@ public static OpenAPI merge(final OpenAPI primary, final OpenAPI secondary) { merged.setComponents(merge(primary.getComponents(), secondary.getComponents())); merged.setExtensions(merge(primary.getExtensions(), secondary.getExtensions())); merged.setJsonSchemaDialect(firstNotBlank(primary.getJsonSchemaDialect(), secondary.getJsonSchemaDialect())); - if (primary.getSpecVersion() != null) { - merged.setSpecVersion(primary.getSpecVersion()); - } else if (secondary.getSpecVersion() != null) { - merged.setSpecVersion(secondary.getSpecVersion()); - } + merged.setSpecVersion(preferredSpecVersion(primary.getSpecVersion(), secondary.getSpecVersion())); return merged; } } @@ -124,6 +123,124 @@ private static T firstNonNull(final T primary, final T secondary) { return primary; } + private static String preferredOpenApiVersion(final String primary, final String secondary) { + final var primaryVersion = firstNotBlank(primary, null); + final var secondaryVersion = firstNotBlank(secondary, null); + if (primaryVersion == null) { + return secondaryVersion; + } + if (secondaryVersion == null) { + return primaryVersion; + } + final int primaryRank = openApiVersionRank(primaryVersion); + final int secondaryRank = openApiVersionRank(secondaryVersion); + if (secondaryRank > primaryRank) { + return secondaryVersion; + } + return primaryVersion; + } + + private static int openApiVersionRank(final String version) { + final var normalized = version.trim(); + final var parts = normalized.split("\\."); + if (parts.length < 2) { + return 0; + } + try { + return Integer.parseInt(parts[0]) * 100 + Integer.parseInt(parts[1]); + } catch (NumberFormatException ignored) { + return 0; + } + } + + private static SpecVersion preferredSpecVersion(final SpecVersion primary, final SpecVersion secondary) { + if (primary == SpecVersion.V31 || secondary == SpecVersion.V31) { + return SpecVersion.V31; + } + if (primary == SpecVersion.V30 || secondary == SpecVersion.V30) { + return SpecVersion.V30; + } + return firstNonNull(primary, secondary); + } + + private static String preferredSchemaType(final Schema primary, final Schema secondary) { + final var explicitType = firstNotBlank(primary.getType(), secondary.getType()); + if (explicitType != null) { + return explicitType; + } + final var primaryType = firstSchemaType(primary.getTypes()); + final var secondaryType = firstSchemaType(secondary.getTypes()); + return firstNotBlank(primaryType, secondaryType); + } + + private static String firstSchemaType(final Set types) { + if (types == null || types.isEmpty()) { + return null; + } + if (types.size() == 1) { + return types.iterator().next(); + } + for (final String candidate : types) { + if (!"null".equals(candidate)) { + return candidate; + } + } + return null; + } + + private static Set mergedSchemaTypes( + final Schema primary, + final Schema secondary, + final String preferredType) { + + final boolean hasTypeSet = + (primary.getTypes() != null && !primary.getTypes().isEmpty()) + || (secondary.getTypes() != null && !secondary.getTypes().isEmpty()); + final Boolean nullable = firstNonNull(primary.getNullable(), secondary.getNullable()); + if (!hasTypeSet && !Boolean.TRUE.equals(nullable)) { + return null; + } + + final LinkedHashSet merged = new LinkedHashSet<>(); + if (preferredType != null && !preferredType.isBlank() && !"null".equals(preferredType)) { + merged.add(preferredType); + } + addSchemaTypes(merged, primary.getTypes()); + addSchemaTypes(merged, secondary.getTypes()); + addSchemaType(merged, primary.getType()); + addSchemaType(merged, secondary.getType()); + + boolean includeNull = + (primary.getTypes() != null && primary.getTypes().contains("null")) + || (secondary.getTypes() != null && secondary.getTypes().contains("null")); + if (Boolean.TRUE.equals(nullable)) { + includeNull = true; + } else if (Boolean.FALSE.equals(nullable)) { + includeNull = false; + } + if (includeNull) { + merged.add("null"); + } + + return merged.isEmpty() ? null : merged; + } + + private static void addSchemaTypes(final Set target, final Set source) { + if (source == null || source.isEmpty()) { + return; + } + for (final String candidate : source) { + addSchemaType(target, candidate); + } + } + + private static void addSchemaType(final Set target, final String candidate) { + if (candidate == null || candidate.isBlank() || "null".equals(candidate)) { + return; + } + target.add(candidate); + } + private static Map merge(final Map primary, final Map secondary) { if (secondary == null) { return primary; @@ -310,7 +427,7 @@ private static PathItem merge(final PathItem primary, final PathItem secondary) merged.setTrace(firstNonNull(primary.getTrace(), secondary.getTrace())); merged.setServers(merge(primary.getServers(), secondary.getServers(), Server::getUrl)); merged.setParameters(merge(primary.getParameters(), secondary.getParameters(), Parameter::getName)); - merged.set$ref(firstNotBlank(primary.get$ref(), primary.get$ref())); + merged.set$ref(firstNotBlank(primary.get$ref(), secondary.get$ref())); merged.setExtensions(merge(primary.getExtensions(), secondary.getExtensions())); return merged; } @@ -345,7 +462,8 @@ private static Schema merge(final Schema primary, final Schema secondary) { return secondary; } else { // We will alter "primary" to match - final String type = firstNotBlank(primary.getType(), secondary.getType()); + final String type = preferredSchemaType(primary, secondary); + final Set types = mergedSchemaTypes(primary, secondary, type); final String format = firstNotBlank(primary.getFormat(), secondary.getFormat()); return SchemaCustomAdaptor.createSchemaFromInformation(type, format) .type(type) @@ -356,7 +474,7 @@ private static Schema merge(final Schema primary, final Schema secondary) { .maximum(firstNonNull(primary.getMaximum(), secondary.getMaximum())) .exclusiveMaximum(firstNonNull(primary.getExclusiveMaximum(), secondary.getExclusiveMaximum())) .minimum(firstNonNull(primary.getMinimum(), secondary.getMinimum())) - .exclusiveMaximum(firstNonNull(primary.getExclusiveMinimum(), secondary.getExclusiveMinimum())) + .exclusiveMinimum(firstNonNull(primary.getExclusiveMinimum(), secondary.getExclusiveMinimum())) .maxLength(firstNonNull(primary.getMaxLength(), secondary.getMaxLength())) .minLength(firstNonNull(primary.getMinLength(), secondary.getMinLength())) .pattern(firstNotBlank(primary.getPattern(), secondary.getPattern())) @@ -371,7 +489,7 @@ private static Schema merge(final Schema primary, final Schema secondary) { .additionalProperties(firstNonNull(primary.getAdditionalProperties(), secondary.getAdditionalProperties())) .description(firstNotBlank(primary.getDescription(), secondary.getDescription())) .$ref(firstNotBlank(primary.get$ref(), secondary.get$ref())) - .nullable(firstNonNull(primary.getNullable(), secondary.getNullable())) + .nullable(null) .readOnly(firstNonNull(primary.getReadOnly(), secondary.getReadOnly())) .writeOnly(firstNonNull(primary.getWriteOnly(), secondary.getWriteOnly())) .externalDocs(merge(primary.getExternalDocs(), secondary.getExternalDocs())) @@ -384,7 +502,7 @@ private static Schema merge(final Schema primary, final Schema secondary) { .anyOf(merge(primary.getAnyOf(), secondary.getAnyOf())) .oneOf(merge(primary.getOneOf(), secondary.getOneOf())) .items(merge(primary.getItems(), secondary.getItems())) - .types(firstNonNull(primary.getTypes(), secondary.getTypes())) + .types(types) .patternProperties(merge(primary.getPatternProperties(), secondary.getPatternProperties(), OpenAPIMergerUtil::merge, Schema.class)) .exclusiveMaximumValue(firstNonNull(primary.getExclusiveMaximumValue(), secondary.getExclusiveMaximumValue())) .exclusiveMinimumValue(firstNonNull(primary.getExclusiveMinimumValue(), secondary.getExclusiveMinimumValue())) diff --git a/openapi-maven-plugin/src/main/java/io/avaje/http/maven/openapi/jsonb/SchemaCustomAdaptor.java b/openapi-maven-plugin/src/main/java/io/avaje/http/maven/openapi/jsonb/SchemaCustomAdaptor.java index 527725124..32b39a9e5 100644 --- a/openapi-maven-plugin/src/main/java/io/avaje/http/maven/openapi/jsonb/SchemaCustomAdaptor.java +++ b/openapi-maven-plugin/src/main/java/io/avaje/http/maven/openapi/jsonb/SchemaCustomAdaptor.java @@ -32,6 +32,7 @@ import java.util.ArrayList; import java.util.Date; import java.util.HashMap; +import java.util.LinkedHashSet; import java.util.List; import java.util.Map; import java.util.Set; @@ -200,7 +201,16 @@ public Schema fromJson(JsonReader reader) { final String fieldName = reader.nextField(); switch (fieldName) { case "type": - type = stringJsonAdapter.fromJson(reader); + switch (reader.currentToken()) { + case STRING: + type = stringJsonAdapter.fromJson(reader); + break; + case BEGIN_ARRAY: + types = setStringJsonAdaptor.fromJson(reader); + break; + default: + reader.skipValue(); + } break; case "format": format = stringJsonAdapter.fromJson(reader); @@ -421,7 +431,8 @@ public Schema fromJson(JsonReader reader) { } } reader.endObject(); - final Schema schema = createSchemaFromInformation(type, format); + final Set normalizedTypes = normalizeTypes(type, types, nullable); + final Schema schema = createSchemaFromInformation(schemaTypeForCreation(type, normalizedTypes), format); schema.name(name) .title(title) .multipleOf(multipleOf) @@ -443,7 +454,7 @@ public Schema fromJson(JsonReader reader) { .additionalProperties(additionalProperties) .description(description) .$ref($ref) - .nullable(nullable) + .nullable(null) .readOnly(readOnly) .writeOnly(writeOnly) .externalDocs(externalDocs) @@ -456,7 +467,7 @@ public Schema fromJson(JsonReader reader) { .anyOf(anyOf) .oneOf(oneOf) .items(items) - .types(types) + .types(normalizedTypes) .patternProperties(patternProperties) .exclusiveMaximumValue(exclusiveMaximumValue) .exclusiveMinimumValue(exclusiveMinimumValue) @@ -533,6 +544,69 @@ public static Schema createSchemaFromInformation(final String type, final String } } + private static String schemaTypeForCreation(final String type, final Set types) { + if (type != null && !type.isBlank()) { + return type; + } + if (types == null || types.isEmpty()) { + return null; + } + if (types.size() == 1) { + return types.iterator().next(); + } + for (final String candidate : types) { + if (!"null".equals(candidate)) { + return candidate; + } + } + return null; + } + + private static Set normalizeTypes( + final String type, + final Set types, + final Boolean nullable) { + + final boolean hasTypeSet = types != null && !types.isEmpty(); + final boolean hasNullableUnion = Boolean.TRUE.equals(nullable); + if (!hasTypeSet && !hasNullableUnion) { + return null; + } + + final LinkedHashSet normalized = new LinkedHashSet<>(); + if (hasTypeSet) { + for (final String candidate : types) { + if (candidate != null && !candidate.isBlank() && !"null".equals(candidate)) { + normalized.add(candidate); + } + } + } + if (type != null && !type.isBlank() && !"null".equals(type)) { + normalized.add(type); + } + + boolean includeNull = false; + if (hasTypeSet && types.contains("null")) { + includeNull = true; + } + if (Boolean.TRUE.equals(nullable)) { + includeNull = true; + } else if (Boolean.FALSE.equals(nullable)) { + includeNull = false; + } + if (includeNull) { + normalized.add("null"); + } + return normalized.isEmpty() ? null : normalized; + } + + private static String scalarSchemaType(final Set normalizedTypes) { + if (normalizedTypes == null || normalizedTypes.size() != 1) { + return null; + } + return normalizedTypes.iterator().next(); + } + private Schema readSelf(JsonReader reader) { if (reader.isNullValue()) { reader.skipValue(); @@ -577,7 +651,15 @@ private List readListSelf(JsonReader reader) { private void toJsonImpl(JsonWriter writer, Schema value) { writer.name(0); - stringJsonAdapter.toJson(writer, value.getType()); + final Set serializedTypes = normalizeTypes(value.getType(), value.getTypes(), value.getNullable()); + final String scalarType = scalarSchemaType(serializedTypes); + if (scalarType != null) { + stringJsonAdapter.toJson(writer, scalarType); + } else if (serializedTypes != null && !serializedTypes.isEmpty()) { + setStringJsonAdaptor.toJson(writer, serializedTypes); + } else { + stringJsonAdapter.toJson(writer, value.getType()); + } writer.name(1); stringJsonAdapter.toJson(writer, value.getFormat()); writer.name(2); @@ -631,7 +713,7 @@ private void toJsonImpl(JsonWriter writer, Schema value) { writer.name(22); stringJsonAdapter.toJson(writer, value.get$ref()); writer.name(23); - booleanJsonAdapter.toJson(writer, value.getNullable()); + writer.nullValue(); writer.name(24); booleanJsonAdapter.toJson(writer, value.getReadOnly()); writer.name(25); @@ -666,7 +748,7 @@ private void toJsonImpl(JsonWriter writer, Schema value) { // specVersion - ignored writer.nullValue(); writer.name(38); - setStringJsonAdaptor.toJson(writer, value.getTypes()); + writer.nullValue(); writer.name(39); writeMapSelf(value.getPatternProperties(), writer); writer.name(40); diff --git a/openapi-maven-plugin/src/test/java/io/avaje/http/maven/openapi/OpenAPIMergerUtilTest.java b/openapi-maven-plugin/src/test/java/io/avaje/http/maven/openapi/OpenAPIMergerUtilTest.java new file mode 100644 index 000000000..d1d6ec3bd --- /dev/null +++ b/openapi-maven-plugin/src/test/java/io/avaje/http/maven/openapi/OpenAPIMergerUtilTest.java @@ -0,0 +1,69 @@ +package io.avaje.http.maven.openapi; + +import static org.assertj.core.api.Assertions.assertThat; + +import io.swagger.v3.oas.models.Components; +import io.swagger.v3.oas.models.OpenAPI; +import io.swagger.v3.oas.models.media.Schema; +import io.swagger.v3.oas.models.media.StringSchema; +import java.util.LinkedHashSet; +import java.util.List; +import org.junit.jupiter.api.Test; + +class OpenAPIMergerUtilTest { + + @Test + void mergeSchemaConvertsNullableToTypeUnion() { + final Schema primarySchema = new StringSchema().nullable(true); + final Schema secondarySchema = new StringSchema(); + + final OpenAPI primary = + new OpenAPI() + .openapi("3.1.2") + .components(new Components().addSchemas("Thing", primarySchema)); + final OpenAPI secondary = + new OpenAPI() + .openapi("3.1.2") + .components(new Components().addSchemas("Thing", secondarySchema)); + + final OpenAPI merged = OpenAPIMergerUtil.merge(primary, secondary); + final Schema schema = merged.getComponents().getSchemas().get("Thing"); + + assertThat(schema.getNullable()).isNull(); + assertThat(schema.getTypes()).containsExactly("string", "null"); + } + + @Test + void mergeSchemaDropsNullFromTypeUnionWhenNullableFalse() { + final Schema primarySchema = + new StringSchema().types(new LinkedHashSet<>(List.of("string", "null"))); + final Schema secondarySchema = new StringSchema().nullable(false); + + final OpenAPI primary = + new OpenAPI() + .openapi("3.1.2") + .components(new Components().addSchemas("Thing", primarySchema)); + final OpenAPI secondary = + new OpenAPI() + .openapi("3.1.2") + .components(new Components().addSchemas("Thing", secondarySchema)); + + final OpenAPI merged = OpenAPIMergerUtil.merge(primary, secondary); + final Schema schema = merged.getComponents().getSchemas().get("Thing"); + + assertThat(schema.getNullable()).isNull(); + assertThat(schema.getTypes()).containsExactly("string"); + } + + @Test + void mergeOpenApiVersionPrefersHighestMinor() { + final var primary = + new OpenAPI().openapi("3.1.2"); + final var secondary = + new OpenAPI().openapi("3.2.0"); + + final OpenAPI merged = OpenAPIMergerUtil.merge(primary, secondary); + + assertThat(merged.getOpenapi()).isEqualTo("3.2.0"); + } +} diff --git a/openapi-maven-plugin/src/test/java/io/avaje/http/maven/openapi/jsonb/SchemaCustomAdaptorTest.java b/openapi-maven-plugin/src/test/java/io/avaje/http/maven/openapi/jsonb/SchemaCustomAdaptorTest.java new file mode 100644 index 000000000..5cb6530f4 --- /dev/null +++ b/openapi-maven-plugin/src/test/java/io/avaje/http/maven/openapi/jsonb/SchemaCustomAdaptorTest.java @@ -0,0 +1,78 @@ +package io.avaje.http.maven.openapi.jsonb; + +import static org.assertj.core.api.Assertions.assertThat; + +import io.avaje.jsonb.JsonType; +import io.avaje.jsonb.Jsonb; +import io.swagger.v3.oas.models.media.Schema; +import org.junit.jupiter.api.Test; + +class SchemaCustomAdaptorTest { + + @Test + void parseAndWriteTypeArray() { + final Jsonb jsonb = + Jsonb.builder().serializeEmpty(true).serializeNulls(false).failOnUnknown(false).build(); + final JsonType schemaType = jsonb.type(Schema.class); + + final Schema schema = schemaType.fromJson("{\"type\":[\"string\",\"null\"]}"); + + assertThat(schema.getType()).isEqualTo("string"); + assertThat(schema.getTypes()).isNotNull(); + assertThat(schema.getTypes()).contains("string", "null"); + + final String json = schemaType.toJson(schema); + assertThat(json).contains("\"type\""); + assertThat(json).doesNotContain("\"types\""); + } + + @Test + void parseLegacyTypesAliasAndWriteAsType() { + final Jsonb jsonb = + Jsonb.builder().serializeEmpty(true).serializeNulls(false).failOnUnknown(false).build(); + final JsonType schemaType = jsonb.type(Schema.class); + + final Schema schema = schemaType.fromJson("{\"types\":[\"integer\",\"null\"]}"); + + assertThat(schema.getType()).isEqualTo("integer"); + assertThat(schema.getTypes()).isNotNull(); + assertThat(schema.getTypes()).contains("integer", "null"); + + final String json = schemaType.toJson(schema); + assertThat(json).contains("\"type\""); + assertThat(json).doesNotContain("\"types\""); + } + + @Test + void parseNullableTrueWritesTypeUnion() { + final Jsonb jsonb = + Jsonb.builder().serializeEmpty(true).serializeNulls(false).failOnUnknown(false).build(); + final JsonType schemaType = jsonb.type(Schema.class); + + final Schema schema = schemaType.fromJson("{\"type\":\"string\",\"nullable\":true}"); + + assertThat(schema.getNullable()).isNull(); + assertThat(schema.getTypes()).containsExactly("string", "null"); + + final String json = schemaType.toJson(schema); + assertThat(json).contains("\"type\":[\"string\",\"null\"]"); + assertThat(json).doesNotContain("\"nullable\""); + } + + @Test + void parseNullableFalseRemovesNullFromTypeUnion() { + final Jsonb jsonb = + Jsonb.builder().serializeEmpty(true).serializeNulls(false).failOnUnknown(false).build(); + final JsonType schemaType = jsonb.type(Schema.class); + + final Schema schema = + schemaType.fromJson("{\"type\":[\"string\",\"null\"],\"nullable\":false}"); + + assertThat(schema.getNullable()).isNull(); + assertThat(schema.getTypes()).containsExactly("string"); + + final String json = schemaType.toJson(schema); + assertThat(json).contains("\"type\":\"string\""); + assertThat(json).doesNotContain("\"nullable\""); + } +} diff --git a/tests/test-javalin-jsonb/src/main/resources/moar-openapi.json b/tests/test-javalin-jsonb/src/main/resources/moar-openapi.json index 2b488aac5..8bee34196 100644 --- a/tests/test-javalin-jsonb/src/main/resources/moar-openapi.json +++ b/tests/test-javalin-jsonb/src/main/resources/moar-openapi.json @@ -8,7 +8,6 @@ "id": { "type": "integer", "format": "int64", - "nullable": false, "description": "This is a number ... I think?" }, "name": { diff --git a/tests/test-javalin-jsonb/src/main/resources/public/openapi.json b/tests/test-javalin-jsonb/src/main/resources/public/openapi.json index a60c1867c..7f739b6c7 100644 --- a/tests/test-javalin-jsonb/src/main/resources/public/openapi.json +++ b/tests/test-javalin-jsonb/src/main/resources/public/openapi.json @@ -1,5 +1,5 @@ { - "openapi": "3.0.1", + "openapi": "3.1.2", "info": { "title": "Example service", "description": "Example Javalin controllers with Java and Maven", @@ -88,8 +88,7 @@ "required": true, "schema": { "type": "integer", - "format": "int64", - "nullable": false + "format": "int64" } } ], @@ -175,8 +174,7 @@ "required": true, "schema": { "type": "integer", - "format": "int32", - "nullable": false + "format": "int32" } },{ "name": "p1", @@ -511,8 +509,7 @@ "type": "object", "properties": { "name": { - "type": "string", - "nullable": false + "type": "string" }, "email": { "type": "string" @@ -586,8 +583,7 @@ "type": "object", "properties": { "name": { - "type": "string", - "nullable": false + "type": "string" }, "email": { "type": "string" @@ -729,8 +725,7 @@ "required": true, "schema": { "type": "integer", - "format": "int32", - "nullable": false + "format": "int32" } },{ "name": "author", @@ -786,8 +781,7 @@ "name": "name", "in": "query", "schema": { - "type": "string", - "nullable": false + "type": "string" } },{ "name": "email", @@ -850,8 +844,7 @@ "required": true, "schema": { "type": "integer", - "format": "int32", - "nullable": false + "format": "int32" } } ], @@ -875,8 +868,7 @@ "required": true, "schema": { "type": "integer", - "format": "int32", - "nullable": false + "format": "int32" } },{ "name": "date", @@ -1948,8 +1940,7 @@ "application/json": { "schema": { "type": "integer", - "format": "int32", - "nullable": false + "format": "int32" }, "exampleSetFlag": false } @@ -1971,8 +1962,7 @@ "application/json": { "schema": { "type": "integer", - "format": "int64", - "nullable": false + "format": "int64" }, "exampleSetFlag": false } @@ -2293,8 +2283,7 @@ "id": { "type": "integer", "format": "int64", - "description": "This is a number ... I think?", - "nullable": false + "description": "This is a number ... I think?" } }, "description": "But oh, what a description this schema has!" @@ -2326,8 +2315,7 @@ }, "id": { "type": "integer", - "format": "int32", - "nullable": false + "format": "int32" } } }, @@ -2361,8 +2349,7 @@ }, "id": { "type": "integer", - "format": "int64", - "nullable": false + "format": "int64" } } }, @@ -2387,5 +2374,6 @@ } } }, - "specVersion": "V30" + "jsonSchemaDialect": "https://spec.openapis.org/oas/3.1/dialect/base", + "specVersion": "V31" } \ No newline at end of file diff --git a/tests/test-javalin-jsonb/src/test/java/io/avaje/http/generator/JavalinProcessorTest.java b/tests/test-javalin-jsonb/src/test/java/io/avaje/http/generator/JavalinProcessorTest.java index d181ff8fe..4d249d98b 100644 --- a/tests/test-javalin-jsonb/src/test/java/io/avaje/http/generator/JavalinProcessorTest.java +++ b/tests/test-javalin-jsonb/src/test/java/io/avaje/http/generator/JavalinProcessorTest.java @@ -172,10 +172,9 @@ public void testOpenAPIGeneration() throws Exception { final var mapper = new ObjectMapper(); final var expectedOpenApiJson = mapper.readTree(new File("src/test/resources/expectedOpenApi.json")); - File file = new File("openapi.json"); - // Files.copy(file.toPath(), Paths.get("other.json")); - final var generatedOpenApi = mapper.readTree(file); + final var generatedOpenApi = mapper.readTree(new File("openapi.json")); + assertOpenApi31(generatedOpenApi.toString()); assertThat(generatedOpenApi).isEqualTo(expectedOpenApiJson); } @@ -210,7 +209,8 @@ public void testInheritableOpenAPIGeneration() throws Exception { mapper.readTree(new File("src/test/resources/expectedInheritedOpenApi.json")); final var generatedOpenApi = mapper.readTree(new File("openapi.json")); - assert expectedOpenApiJson.equals(generatedOpenApi); + assertOpenApi31(generatedOpenApi.toString()); + assertThat(generatedOpenApi).isEqualTo(expectedOpenApiJson); } private Iterable getSourceFiles(String source) throws Exception { @@ -222,4 +222,11 @@ private Iterable getSourceFiles(String source) throws Exception final Set fileKinds = Collections.singleton(Kind.SOURCE); return files.list(StandardLocation.SOURCE_PATH, "", fileKinds, true); } + + private static void assertOpenApi31(String json) { + assertThat(json).contains("\"openapi\":\"3.1.2\""); + assertThat(json).contains("\"jsonSchemaDialect\":\"https://spec.openapis.org/oas/3.1/dialect/base\""); + assertThat(json).doesNotContain("\"nullable\""); + assertThat(json).doesNotContain("\"types\":"); + } } diff --git a/tests/test-javalin-jsonb/src/test/resources/expectedInheritedOpenApi.json b/tests/test-javalin-jsonb/src/test/resources/expectedInheritedOpenApi.json index a0e6983f6..d0433be5a 100644 --- a/tests/test-javalin-jsonb/src/test/resources/expectedInheritedOpenApi.json +++ b/tests/test-javalin-jsonb/src/test/resources/expectedInheritedOpenApi.json @@ -1,5 +1,5 @@ { - "openapi" : "3.0.1", + "openapi" : "3.1.2", "info" : { "title" : "Example service showing off the Path extension method of controller", "version" : "" @@ -66,5 +66,6 @@ } } } - } + }, + "jsonSchemaDialect" : "https://spec.openapis.org/oas/3.1/dialect/base" } diff --git a/tests/test-javalin-jsonb/src/test/resources/expectedOpenApi.json b/tests/test-javalin-jsonb/src/test/resources/expectedOpenApi.json index cf28b5c05..ace3a1468 100644 --- a/tests/test-javalin-jsonb/src/test/resources/expectedOpenApi.json +++ b/tests/test-javalin-jsonb/src/test/resources/expectedOpenApi.json @@ -1,5 +1,5 @@ { - "openapi": "3.0.1", + "openapi": "3.1.2", "info": { "title": "Example service", "description": "Example Javalin controllers with Java and Maven", @@ -293,8 +293,7 @@ "properties": { "id": { "type": "integer", - "format": "int64", - "nullable": false + "format": "int64" }, "name": { "type": "string" @@ -310,5 +309,6 @@ "in": "query" } } - } + }, + "jsonSchemaDialect": "https://spec.openapis.org/oas/3.1/dialect/base" } diff --git a/tests/test-javalin/src/main/resources/public/openapi.json b/tests/test-javalin/src/main/resources/public/openapi.json index 28081a573..50b281213 100644 --- a/tests/test-javalin/src/main/resources/public/openapi.json +++ b/tests/test-javalin/src/main/resources/public/openapi.json @@ -1,5 +1,5 @@ { - "openapi" : "3.0.1", + "openapi" : "3.1.2", "info" : { "title" : "Example service", "description" : "Example Javalin controllers with Java and Maven", @@ -81,8 +81,7 @@ "required" : true, "schema" : { "type" : "integer", - "format" : "int64", - "nullable" : false + "format" : "int64" } } ], @@ -168,8 +167,7 @@ "required" : true, "schema" : { "type" : "integer", - "format" : "int32", - "nullable" : false + "format" : "int32" } }, { @@ -493,8 +491,7 @@ "type" : "object", "properties" : { "name" : { - "type" : "string", - "nullable" : false + "type" : "string" }, "email" : { "type" : "string" @@ -568,8 +565,7 @@ "type" : "object", "properties" : { "name" : { - "type" : "string", - "nullable" : false + "type" : "string" }, "email" : { "type" : "string" @@ -662,8 +658,7 @@ "required" : true, "schema" : { "type" : "integer", - "format" : "int32", - "nullable" : false + "format" : "int32" } }, { @@ -724,8 +719,7 @@ "name" : "name", "in" : "query", "schema" : { - "type" : "string", - "nullable" : false + "type" : "string" } }, { @@ -764,8 +758,7 @@ "required" : true, "schema" : { "type" : "integer", - "format" : "int32", - "nullable" : false + "format" : "int32" } } ], @@ -792,8 +785,7 @@ "required" : true, "schema" : { "type" : "integer", - "format" : "int32", - "nullable" : false + "format" : "int32" } }, { @@ -859,8 +851,7 @@ "properties" : { "id" : { "type" : "integer", - "format" : "int64", - "nullable" : false + "format" : "int64" }, "name" : { "type" : "string" @@ -888,8 +879,7 @@ "properties" : { "id" : { "type" : "integer", - "format" : "int32", - "nullable" : false + "format" : "int32" }, "name" : { "type" : "string" @@ -908,5 +898,6 @@ } } } - } -} \ No newline at end of file + }, + "jsonSchemaDialect" : "https://spec.openapis.org/oas/3.1/dialect/base" +} diff --git a/tests/test-jex/src/main/resources/public/openapi.json b/tests/test-jex/src/main/resources/public/openapi.json index 20233ece5..4f0af6ac4 100644 --- a/tests/test-jex/src/main/resources/public/openapi.json +++ b/tests/test-jex/src/main/resources/public/openapi.json @@ -1,5 +1,5 @@ { - "openapi" : "3.0.1", + "openapi" : "3.1.2", "info" : { "title" : "", "version" : "" @@ -157,8 +157,7 @@ "required" : true, "schema" : { "type" : "integer", - "format" : "int64", - "nullable" : false + "format" : "int64" } } ], @@ -244,8 +243,7 @@ "required" : true, "schema" : { "type" : "integer", - "format" : "int32", - "nullable" : false + "format" : "int32" } }, { @@ -596,8 +594,7 @@ "type" : "object", "properties" : { "name" : { - "type" : "string", - "nullable" : false + "type" : "string" }, "email" : { "type" : "string" @@ -671,8 +668,7 @@ "type" : "object", "properties" : { "name" : { - "type" : "string", - "nullable" : false + "type" : "string" }, "email" : { "type" : "string" @@ -796,12 +792,14 @@ "parameters" : [ { "name" : "myOptional", - "in" : "query", - "schema" : { - "type" : "integer", - "format" : "int64", - "nullable" : true - } + "in" : "query", + "schema" : { + "type" : [ + "integer", + "null" + ], + "format" : "int64" + } } ], "responses" : { @@ -830,8 +828,10 @@ "name" : "myOptional", "in" : "query", "schema" : { - "type" : "string", - "nullable" : true, + "type" : [ + "string", + "null" + ], "enum" : [ "A", "B", @@ -866,8 +866,10 @@ "name" : "myOptional", "in" : "query", "schema" : { - "type" : "string", - "nullable" : true + "type" : [ + "string", + "null" + ] } } ], @@ -899,8 +901,7 @@ "required" : true, "schema" : { "type" : "integer", - "format" : "int32", - "nullable" : false + "format" : "int32" } }, { @@ -961,8 +962,7 @@ "name" : "name", "in" : "query", "schema" : { - "type" : "string", - "nullable" : false + "type" : "string" } }, { @@ -1033,8 +1033,7 @@ "required" : true, "schema" : { "type" : "integer", - "format" : "int32", - "nullable" : false + "format" : "int32" } } ], @@ -1060,8 +1059,7 @@ "required" : true, "schema" : { "type" : "integer", - "format" : "int32", - "nullable" : false + "format" : "int32" } }, { @@ -1628,8 +1626,7 @@ "properties" : { "id" : { "type" : "integer", - "format" : "int64", - "nullable" : false + "format" : "int64" }, "name" : { "type" : "string" @@ -1660,12 +1657,10 @@ "properties" : { "id" : { "type" : "integer", - "format" : "int32", - "nullable" : false + "format" : "int32" }, "name" : { - "type" : "string", - "nullable" : false + "type" : "string" }, "serverType" : { "type" : "string", @@ -1741,8 +1736,7 @@ "properties" : { "id" : { "type" : "integer", - "format" : "int32", - "nullable" : false + "format" : "int32" }, "name" : { "type" : "string", @@ -1763,5 +1757,6 @@ } } } - } -} \ No newline at end of file + }, + "jsonSchemaDialect" : "https://spec.openapis.org/oas/3.1/dialect/base" +} diff --git a/tests/test-nima-jsonb/src/test/java/io/avaje/http/generator/NimaProcessorTest.java b/tests/test-nima-jsonb/src/test/java/io/avaje/http/generator/NimaProcessorTest.java index ad58a6faf..fa03b82e3 100644 --- a/tests/test-nima-jsonb/src/test/java/io/avaje/http/generator/NimaProcessorTest.java +++ b/tests/test-nima-jsonb/src/test/java/io/avaje/http/generator/NimaProcessorTest.java @@ -111,6 +111,11 @@ public void testOpenAPIGeneration() throws Exception { mapper.readTree(new File("src/test/resources/expectedOpenApi.json")); final var generatedOpenApi = mapper.readTree(new File("openapi.json")); - assert expectedOpenApiJson.equals(generatedOpenApi); + final var json = generatedOpenApi.toString(); + assertThat(json).contains("\"openapi\":\"3.1.2\""); + assertThat(json).contains("\"jsonSchemaDialect\":\"https://spec.openapis.org/oas/3.1/dialect/base\""); + assertThat(json).doesNotContain("\"nullable\""); + assertThat(json).doesNotContain("\"types\":"); + assertThat(generatedOpenApi).isEqualTo(expectedOpenApiJson); } } diff --git a/tests/test-nima-jsonb/src/test/resources/expectedOpenApi.json b/tests/test-nima-jsonb/src/test/resources/expectedOpenApi.json index 38fc5b6e6..13ba2a852 100644 --- a/tests/test-nima-jsonb/src/test/resources/expectedOpenApi.json +++ b/tests/test-nima-jsonb/src/test/resources/expectedOpenApi.json @@ -1,5 +1,5 @@ { - "openapi" : "3.0.1", + "openapi" : "3.1.2", "info" : { "title" : "Example service", "description" : "Example Helidon controllers with Java and Maven", @@ -238,8 +238,7 @@ "properties" : { "id" : { "type" : "integer", - "format" : "int64", - "nullable" : false + "format" : "int64" }, "name" : { "type" : "string" @@ -255,5 +254,6 @@ "in" : "query" } } - } + }, + "jsonSchemaDialect" : "https://spec.openapis.org/oas/3.1/dialect/base" } diff --git a/tests/test-sigma/src/main/resources/public/openapi.json b/tests/test-sigma/src/main/resources/public/openapi.json index a00259230..4d4ed2235 100644 --- a/tests/test-sigma/src/main/resources/public/openapi.json +++ b/tests/test-sigma/src/main/resources/public/openapi.json @@ -1,5 +1,5 @@ { - "openapi" : "3.0.1", + "openapi" : "3.1.2", "info" : { "title" : "Example service", "description" : "Example Javalin controllers with Java and Maven", @@ -87,8 +87,7 @@ "required" : true, "schema" : { "type" : "integer", - "format" : "int64", - "nullable" : false + "format" : "int64" } } ], @@ -174,8 +173,7 @@ "required" : true, "schema" : { "type" : "integer", - "format" : "int32", - "nullable" : false + "format" : "int32" } }, { @@ -519,8 +517,7 @@ "type" : "object", "properties" : { "name" : { - "type" : "string", - "nullable" : false + "type" : "string" }, "email" : { "type" : "string" @@ -594,8 +591,7 @@ "type" : "object", "properties" : { "name" : { - "type" : "string", - "nullable" : false + "type" : "string" }, "email" : { "type" : "string" @@ -723,8 +719,7 @@ "required" : true, "schema" : { "type" : "integer", - "format" : "int32", - "nullable" : false + "format" : "int32" } }, { @@ -785,8 +780,7 @@ "name" : "name", "in" : "query", "schema" : { - "type" : "string", - "nullable" : false + "type" : "string" } }, { @@ -857,8 +851,7 @@ "required" : true, "schema" : { "type" : "integer", - "format" : "int32", - "nullable" : false + "format" : "int32" } } ], @@ -884,8 +877,7 @@ "required" : true, "schema" : { "type" : "integer", - "format" : "int32", - "nullable" : false + "format" : "int32" } }, { @@ -1679,8 +1671,7 @@ "application/json" : { "schema" : { "type" : "integer", - "format" : "int32", - "nullable" : false + "format" : "int32" } } } @@ -1702,8 +1693,7 @@ "application/json" : { "schema" : { "type" : "integer", - "format" : "int64", - "nullable" : false + "format" : "int64" } } } @@ -2004,8 +1994,7 @@ "properties" : { "id" : { "type" : "integer", - "format" : "int64", - "nullable" : false + "format" : "int64" }, "name" : { "type" : "string" @@ -2044,8 +2033,7 @@ "properties" : { "id" : { "type" : "integer", - "format" : "int32", - "nullable" : false + "format" : "int32" }, "name" : { "type" : "string", @@ -2114,5 +2102,6 @@ "in" : "query" } } - } -} \ No newline at end of file + }, + "jsonSchemaDialect" : "https://spec.openapis.org/oas/3.1/dialect/base" +} diff --git a/tests/test-vertx-jsonb/src/test/resources/expectedOpenApi.json b/tests/test-vertx-jsonb/src/test/resources/expectedOpenApi.json index 66c840d9a..01795e036 100644 --- a/tests/test-vertx-jsonb/src/test/resources/expectedOpenApi.json +++ b/tests/test-vertx-jsonb/src/test/resources/expectedOpenApi.json @@ -1,5 +1,5 @@ { - "openapi" : "3.0.1", + "openapi" : "3.1.2", "info" : { "title" : "", "version" : "" @@ -45,11 +45,10 @@ "in" : "path", "required" : true, "schema" : { - "type" : "integer", - "format" : "int64", - "nullable" : false - } - }, + "type" : "integer", + "format" : "int64", + } + }, { "name" : "q", "in" : "query", @@ -185,5 +184,6 @@ } } } - } -} \ No newline at end of file + }, + "jsonSchemaDialect" : "https://spec.openapis.org/oas/3.1/dialect/base" +} diff --git a/tests/test-vertx/src/test/resources/expectedOpenApi.json b/tests/test-vertx/src/test/resources/expectedOpenApi.json index 66c840d9a..01795e036 100644 --- a/tests/test-vertx/src/test/resources/expectedOpenApi.json +++ b/tests/test-vertx/src/test/resources/expectedOpenApi.json @@ -1,5 +1,5 @@ { - "openapi" : "3.0.1", + "openapi" : "3.1.2", "info" : { "title" : "", "version" : "" @@ -45,11 +45,10 @@ "in" : "path", "required" : true, "schema" : { - "type" : "integer", - "format" : "int64", - "nullable" : false - } - }, + "type" : "integer", + "format" : "int64", + } + }, { "name" : "q", "in" : "query", @@ -185,5 +184,6 @@ } } } - } -} \ No newline at end of file + }, + "jsonSchemaDialect" : "https://spec.openapis.org/oas/3.1/dialect/base" +} From d57bae81d0d0bacf29b65eddae739be6cce04fa4 Mon Sep 17 00:00:00 2001 From: Harry Chan <38070640+re-thc@users.noreply.github.com> Date: Fri, 20 Feb 2026 18:42:26 +0800 Subject: [PATCH 2/8] fix 3.1.2 From e67443406c570afda7d3e0653429a555343f1475 Mon Sep 17 00:00:00 2001 From: Harry Chan <38070640+re-thc@users.noreply.github.com> Date: Sat, 21 Feb 2026 02:45:01 +0800 Subject: [PATCH 3/8] fix vertx openapi test fixture --- tests/test-vertx/src/test/resources/expectedOpenApi.json | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/tests/test-vertx/src/test/resources/expectedOpenApi.json b/tests/test-vertx/src/test/resources/expectedOpenApi.json index 01795e036..cad2259db 100644 --- a/tests/test-vertx/src/test/resources/expectedOpenApi.json +++ b/tests/test-vertx/src/test/resources/expectedOpenApi.json @@ -45,10 +45,10 @@ "in" : "path", "required" : true, "schema" : { - "type" : "integer", - "format" : "int64", - } - }, + "type" : "integer", + "format" : "int64" + } + }, { "name" : "q", "in" : "query", From e64e100568e5d9a9ad549951f8d3d6f83b3a30ca Mon Sep 17 00:00:00 2001 From: Rob Bygrave Date: Sat, 2 May 2026 14:29:38 +1200 Subject: [PATCH 4/8] Fix conflict resolution introduced issues - remove types from IGNORED_FIELDS - Remove types from IGNORED_FIELDS - Schema propSchema, needs to be final for use in lambda --- .../avaje/http/generator/core/openapi/OpenAPISerializer.java | 1 - .../avaje/http/generator/core/openapi/SchemaDocBuilder.java | 4 ++-- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/http-generator-core/src/main/java/io/avaje/http/generator/core/openapi/OpenAPISerializer.java b/http-generator-core/src/main/java/io/avaje/http/generator/core/openapi/OpenAPISerializer.java index d4f1e26e9..3c2d21953 100644 --- a/http-generator-core/src/main/java/io/avaje/http/generator/core/openapi/OpenAPISerializer.java +++ b/http-generator-core/src/main/java/io/avaje/http/generator/core/openapi/OpenAPISerializer.java @@ -20,7 +20,6 @@ final class OpenAPISerializer { "USE_ARBITRARY_SCHEMA_PROPERTY", "exampleSetFlag", "defaultSetFlag", - "types", "specVersion"); private OpenAPISerializer() {} diff --git a/http-generator-core/src/main/java/io/avaje/http/generator/core/openapi/SchemaDocBuilder.java b/http-generator-core/src/main/java/io/avaje/http/generator/core/openapi/SchemaDocBuilder.java index 38c0317c6..a2b4eb6e8 100644 --- a/http-generator-core/src/main/java/io/avaje/http/generator/core/openapi/SchemaDocBuilder.java +++ b/http-generator-core/src/main/java/io/avaje/http/generator/core/openapi/SchemaDocBuilder.java @@ -309,12 +309,12 @@ private void populateObjectSchema(TypeMirror objectType, Schema objectSch TypeMirror fieldType = objectType instanceof DeclaredType ? types.asMemberOf((DeclaredType) objectType, field) : field.asType(); - Schema propSchema = SchemaPrism.getOptionalOn(field) + final Schema propSchema = SchemaPrism.getOptionalOn(field) .flatMap(SchemaPrismHelper::implementation) .map(this::toSchema) .orElseGet(() -> (Schema) toSchema(fieldType)); if (isNotNullable(field)) { - propSchema = markNotNullable(propSchema); + markNotNullable(propSchema); objectSchema.addRequiredItem(field.getSimpleName().toString()); } setDescription(field, propSchema); From 6160484c6137cd7178ffa493d82f511674729210 Mon Sep 17 00:00:00 2001 From: Rob Bygrave Date: Sat, 2 May 2026 14:47:54 +1200 Subject: [PATCH 5/8] Update SchemaDocBuilder to use markNotNullable instead of setNullable(Boolean.FALSE) --- .../core/openapi/SchemaDocBuilder.java | 4 +- .../io.avaje.jsonb.spi.JsonbExtension | 3 +- .../src/main/resources/public/openapi.json | 47 +-- .../src/main/resources/public/openapi.json | 198 ++++++------ .../src/main/resources/public/openapi.json | 262 ++++++++-------- .../src/test/resources/expectedOpenApi.json | 6 +- .../src/main/resources/public/openapi.json | 296 +++++++++--------- 7 files changed, 383 insertions(+), 433 deletions(-) diff --git a/http-generator-core/src/main/java/io/avaje/http/generator/core/openapi/SchemaDocBuilder.java b/http-generator-core/src/main/java/io/avaje/http/generator/core/openapi/SchemaDocBuilder.java index a2b4eb6e8..17b133153 100644 --- a/http-generator-core/src/main/java/io/avaje/http/generator/core/openapi/SchemaDocBuilder.java +++ b/http-generator-core/src/main/java/io/avaje/http/generator/core/openapi/SchemaDocBuilder.java @@ -241,7 +241,7 @@ private Schema buildIterableSchema(TypeMirror type) { TypeMirror typeMirror = typeArguments.get(0); itemSchema = toSchema(typeMirror); if (isNotNullable(typeMirror)) { - itemSchema.setNullable(Boolean.FALSE); + itemSchema = markNotNullable(itemSchema); } } } @@ -270,7 +270,7 @@ private Schema buildMapSchema(TypeMirror type) { TypeMirror valueType = typeArguments.get(1); valueSchema = toSchema(valueType); if (isNotNullable(valueType)) { - valueSchema.setNullable(Boolean.FALSE); + valueSchema = markNotNullable(valueSchema); } } } diff --git a/tests/test-javalin-jsonb/io.avaje.jsonb.spi.JsonbExtension b/tests/test-javalin-jsonb/io.avaje.jsonb.spi.JsonbExtension index 05a41ae93..60abdf8d0 100644 --- a/tests/test-javalin-jsonb/io.avaje.jsonb.spi.JsonbExtension +++ b/tests/test-javalin-jsonb/io.avaje.jsonb.spi.JsonbExtension @@ -1 +1,2 @@ -org.example.myapp.web.jsonb.GeneratedJsonComponent \ No newline at end of file +org.example.myapp.web.jsonb.GeneratedJsonComponent +org.example.myapp.web.test.TestJsonComponent \ No newline at end of file diff --git a/tests/test-javalin-jsonb/src/main/resources/public/openapi.json b/tests/test-javalin-jsonb/src/main/resources/public/openapi.json index 65964a107..06b8f2a84 100644 --- a/tests/test-javalin-jsonb/src/main/resources/public/openapi.json +++ b/tests/test-javalin-jsonb/src/main/resources/public/openapi.json @@ -66,8 +66,7 @@ "schema": { "type": "array", "items": { - "$ref": "#/components/schemas/Bar", - "nullable": false + "$ref": "#/components/schemas/Bar" } }, "exampleSetFlag": false @@ -122,8 +121,7 @@ "schema": { "type": "array", "items": { - "$ref": "#/components/schemas/Baz", - "nullable": false + "$ref": "#/components/schemas/Baz" } }, "exampleSetFlag": false @@ -249,8 +247,7 @@ "schema": { "type": "array", "items": { - "$ref": "#/components/schemas/Baz", - "nullable": false + "$ref": "#/components/schemas/Baz" } }, "exampleSetFlag": false @@ -358,8 +355,7 @@ "schema": { "type": "array", "items": { - "$ref": "#/components/schemas/HelloDto", - "nullable": false + "$ref": "#/components/schemas/HelloDto" } }, "exampleSetFlag": false @@ -420,8 +416,7 @@ "schema": { "type": "array", "items": { - "$ref": "#/components/schemas/HelloDto", - "nullable": false + "$ref": "#/components/schemas/HelloDto" } }, "exampleSetFlag": false @@ -821,8 +816,7 @@ "schema": { "type": "array", "items": { - "type": "string", - "nullable": false + "type": "string" } } },{ @@ -838,7 +832,6 @@ "type": "array", "items": { "type": "string", - "nullable": false, "enum": ["PROXY","HIDE_N_SEEK","FFA" ] } @@ -1024,10 +1017,8 @@ "type": "object", "additionalProperties": { "type": "array", - "nullable": false, "items": { - "type": "string", - "nullable": false + "type": "string" } } }, @@ -1256,8 +1247,7 @@ "schema": { "type": "array", "items": { - "$ref": "#/components/schemas/Person", - "nullable": false + "$ref": "#/components/schemas/Person" } }, "exampleSetFlag": false @@ -1662,7 +1652,6 @@ "type": "array", "items": { "type": "string", - "nullable": false, "enum": ["PROXY","HIDE_N_SEEK","FFA" ] } @@ -1851,8 +1840,7 @@ "strings": { "type": "array", "items": { - "type": "string", - "nullable": false + "type": "string" } } } @@ -2069,8 +2057,7 @@ "schema": { "type": "array", "items": { - "$ref": "#/components/schemas/Person", - "nullable": false + "$ref": "#/components/schemas/Person" } }, "exampleSetFlag": false @@ -2146,8 +2133,7 @@ "schema": { "type": "array", "items": { - "$ref": "#/components/schemas/Person", - "nullable": false + "$ref": "#/components/schemas/Person" } }, "exampleSetFlag": false @@ -2180,8 +2166,7 @@ "schema": { "type": "object", "additionalProperties": { - "$ref": "#/components/schemas/Person", - "nullable": false + "$ref": "#/components/schemas/Person" } }, "exampleSetFlag": false @@ -2214,8 +2199,7 @@ "schema": { "type": "array", "items": { - "$ref": "#/components/schemas/Person", - "nullable": false + "$ref": "#/components/schemas/Person" } }, "exampleSetFlag": false @@ -2396,8 +2380,7 @@ "data": { "type": "array", "items": { - "$ref": "#/components/schemas/Bar", - "nullable": false + "$ref": "#/components/schemas/Bar" } } } @@ -2424,5 +2407,5 @@ } }, "jsonSchemaDialect": "https://spec.openapis.org/oas/3.1/dialect/base", - "specVersion": "V31" + "specVersion": "V30" } \ No newline at end of file diff --git a/tests/test-javalin/src/main/resources/public/openapi.json b/tests/test-javalin/src/main/resources/public/openapi.json index 65aada7ed..dd589614a 100644 --- a/tests/test-javalin/src/main/resources/public/openapi.json +++ b/tests/test-javalin/src/main/resources/public/openapi.json @@ -56,11 +56,10 @@ "content" : { "application/json" : { "schema" : { - "type" : "array", "items" : { - "$ref" : "#/components/schemas/Bar", - "nullable" : false - } + "$ref" : "#/components/schemas/Bar" + }, + "type" : "array" } } } @@ -81,8 +80,8 @@ "in" : "path", "required" : true, "schema" : { - "type" : "integer", - "format" : "int64" + "format" : "int64", + "type" : "integer" } } ], @@ -113,11 +112,10 @@ "content" : { "application/json" : { "schema" : { - "type" : "array", "items" : { - "$ref" : "#/components/schemas/Baz", - "nullable" : false - } + "$ref" : "#/components/schemas/Baz" + }, + "type" : "array" } } } @@ -146,8 +144,8 @@ "content" : { "application/json" : { "schema" : { - "type" : "integer", - "format" : "int64" + "format" : "int64", + "type" : "integer" } } } @@ -168,8 +166,8 @@ "in" : "path", "required" : true, "schema" : { - "type" : "integer", - "format" : "int32" + "format" : "int32", + "type" : "integer" } }, { @@ -190,8 +188,8 @@ "name" : "p3", "in" : "query", "schema" : { - "type" : "integer", - "format" : "int32" + "format" : "int32", + "type" : "integer" } }, { @@ -246,11 +244,10 @@ "content" : { "application/json" : { "schema" : { - "type" : "array", "items" : { - "$ref" : "#/components/schemas/Baz", - "nullable" : false - } + "$ref" : "#/components/schemas/Baz" + }, + "type" : "array" } } } @@ -271,8 +268,8 @@ "in" : "path", "required" : true, "schema" : { - "type" : "integer", - "format" : "int64" + "format" : "int64", + "type" : "integer" } } ], @@ -334,11 +331,10 @@ "content" : { "application/json" : { "schema" : { - "type" : "array", "items" : { - "$ref" : "#/components/schemas/HelloDto", - "nullable" : false - } + "$ref" : "#/components/schemas/HelloDto" + }, + "type" : "array" } } } @@ -378,11 +374,10 @@ "content" : { "application/json" : { "schema" : { - "type" : "array", "items" : { - "$ref" : "#/components/schemas/HelloDto", - "nullable" : false - } + "$ref" : "#/components/schemas/HelloDto" + }, + "type" : "array" } } } @@ -433,7 +428,8 @@ "url" : { "type" : "string" } - } + }, + "type" : "object" } } }, @@ -505,10 +501,11 @@ "type" : "string" }, "startDate" : { - "type" : "string", - "format" : "date" + "format" : "date", + "type" : "string" } - } + }, + "type" : "object" } } }, @@ -543,7 +540,8 @@ "url" : { "type" : "string" } - } + }, + "type" : "object" } } }, @@ -579,10 +577,11 @@ "type" : "string" }, "startDate" : { - "type" : "string", - "format" : "date" + "format" : "date", + "type" : "string" } - } + }, + "type" : "object" } } }, @@ -662,8 +661,8 @@ "in" : "path", "required" : true, "schema" : { - "type" : "integer", - "format" : "int32" + "format" : "int32", + "type" : "integer" } }, { @@ -762,8 +761,8 @@ "in" : "path", "required" : true, "schema" : { - "type" : "integer", - "format" : "int32" + "format" : "int32", + "type" : "integer" } } ], @@ -789,8 +788,8 @@ "description" : "The hello Id.", "required" : true, "schema" : { - "type" : "integer", - "format" : "int32" + "format" : "int32", + "type" : "integer" } }, { @@ -799,8 +798,8 @@ "description" : "The name of the hello", "required" : true, "schema" : { - "type" : "string", - "format" : "date" + "format" : "date", + "type" : "string" } }, { @@ -857,8 +856,7 @@ "content" : { "application/json" : { "schema" : { - "$ref" : "#/components/schemas/NullMarkedClassDTO", - "nullable" : false + "$ref" : "#/components/schemas/NullMarkedClassDTO" } } }, @@ -917,39 +915,38 @@ "components" : { "schemas" : { "Bar" : { - "type" : "object", "properties" : { "id" : { - "type" : "integer", - "format" : "int64" + "format" : "int64", + "type" : "integer" }, "name" : { "type" : "string" } - } + }, + "type" : "object" }, "Baz" : { - "type" : "object", "properties" : { "id" : { - "type" : "integer", - "format" : "int64" + "format" : "int64", + "type" : "integer" }, "name" : { "type" : "string" }, "startDate" : { - "type" : "string", - "format" : "date" + "format" : "date", + "type" : "string" } - } + }, + "type" : "object" }, "HelloDto" : { - "type" : "object", "properties" : { "id" : { - "type" : "integer", - "format" : "int32" + "format" : "int32", + "type" : "integer" }, "name" : { "type" : "string" @@ -958,14 +955,15 @@ "type" : "string" }, "gid" : { - "type" : "string", - "format" : "uuid" + "format" : "uuid", + "type" : "string" }, "whenAction" : { - "type" : "string", - "format" : "date-time" + "format" : "date-time", + "type" : "string" } - } + }, + "type" : "object" }, "NullMarkedClassDTO" : { "required" : [ @@ -978,99 +976,85 @@ "stringArray1", "stringArray2" ], - "type" : "object", "properties" : { "string1" : { - "type" : "string", - "nullable" : false + "type" : "string" }, "string2" : { "type" : "string" }, "stringArray1" : { - "type" : "array", - "nullable" : false, "items" : { "type" : "string" - } + }, + "type" : "array" }, "stringArray2" : { - "type" : "array", - "nullable" : false, "items" : { "type" : "string" - } + }, + "type" : "array" }, "set1" : { - "type" : "array", - "nullable" : false, "items" : { - "type" : "string", - "nullable" : false - } + "type" : "string" + }, + "type" : "array" }, "set2" : { - "type" : "array", - "nullable" : false, "items" : { "type" : "string" - } + }, + "type" : "array" }, "set3" : { - "type" : "array", "items" : { - "type" : "string", - "nullable" : false - } + "type" : "string" + }, + "type" : "array" }, "map1" : { - "type" : "object", "additionalProperties" : { - "type" : "string", - "nullable" : false + "type" : "string" }, - "nullable" : false + "type" : "object" }, "map2" : { - "type" : "object", "additionalProperties" : { - "type" : "string", - "nullable" : false + "type" : "string" }, - "nullable" : false + "type" : "object" }, "map3" : { - "type" : "object", "additionalProperties" : { "type" : "string" }, - "nullable" : false + "type" : "object" }, "map4" : { - "type" : "object", "additionalProperties" : { - "type" : "string", - "nullable" : false - } + "type" : "string" + }, + "type" : "object" } - } + }, + "type" : "object" }, "NullMarkedRecordDTO" : { "required" : [ "notNullable" ], - "type" : "object", "properties" : { "notNullable" : { - "type" : "string", - "nullable" : false + "type" : "string" }, "nullable" : { "type" : "string" } - } + }, + "type" : "object" } } }, "jsonSchemaDialect" : "https://spec.openapis.org/oas/3.1/dialect/base" -} +} \ No newline at end of file diff --git a/tests/test-jex/src/main/resources/public/openapi.json b/tests/test-jex/src/main/resources/public/openapi.json index 58d2b1335..9d7f99fa6 100644 --- a/tests/test-jex/src/main/resources/public/openapi.json +++ b/tests/test-jex/src/main/resources/public/openapi.json @@ -98,11 +98,10 @@ "content" : { "application/json" : { "schema" : { - "type" : "array", "items" : { - "$ref" : "#/components/schemas/Bar", - "nullable" : false - } + "$ref" : "#/components/schemas/Bar" + }, + "type" : "array" } } } @@ -133,11 +132,10 @@ "content" : { "application/json" : { "schema" : { - "type" : "array", "items" : { - "$ref" : "#/components/schemas/Bar", - "nullable" : false - } + "$ref" : "#/components/schemas/Bar" + }, + "type" : "array" } } } @@ -158,8 +156,8 @@ "in" : "path", "required" : true, "schema" : { - "type" : "integer", - "format" : "int64" + "format" : "int64", + "type" : "integer" } } ], @@ -190,11 +188,10 @@ "content" : { "application/json" : { "schema" : { - "type" : "array", "items" : { - "$ref" : "#/components/schemas/Baz", - "nullable" : false - } + "$ref" : "#/components/schemas/Baz" + }, + "type" : "array" } } } @@ -223,8 +220,8 @@ "content" : { "application/json" : { "schema" : { - "type" : "integer", - "format" : "int64" + "format" : "int64", + "type" : "integer" } } } @@ -245,8 +242,8 @@ "in" : "path", "required" : true, "schema" : { - "type" : "integer", - "format" : "int32" + "format" : "int32", + "type" : "integer" } }, { @@ -267,8 +264,8 @@ "name" : "p3", "in" : "query", "schema" : { - "type" : "integer", - "format" : "int32" + "format" : "int32", + "type" : "integer" } }, { @@ -323,11 +320,10 @@ "content" : { "application/json" : { "schema" : { - "type" : "array", "items" : { - "$ref" : "#/components/schemas/Baz", - "nullable" : false - } + "$ref" : "#/components/schemas/Baz" + }, + "type" : "array" } } } @@ -348,8 +344,8 @@ "in" : "path", "required" : true, "schema" : { - "type" : "integer", - "format" : "int64" + "format" : "int64", + "type" : "integer" } } ], @@ -481,11 +477,10 @@ "content" : { "application/json" : { "schema" : { - "type" : "array", "items" : { - "$ref" : "#/components/schemas/WebHelloDto", - "nullable" : false - } + "$ref" : "#/components/schemas/WebHelloDto" + }, + "type" : "array" } } } @@ -536,7 +531,8 @@ "url" : { "type" : "string" } - } + }, + "type" : "object" } } }, @@ -608,10 +604,11 @@ "type" : "string" }, "startDate" : { - "type" : "string", - "format" : "date" + "format" : "date", + "type" : "string" } - } + }, + "type" : "object" } } }, @@ -646,7 +643,8 @@ "url" : { "type" : "string" } - } + }, + "type" : "object" } } }, @@ -682,10 +680,11 @@ "type" : "string" }, "startDate" : { - "type" : "string", - "format" : "date" + "format" : "date", + "type" : "string" } - } + }, + "type" : "object" } } }, @@ -764,12 +763,12 @@ "name" : "myEnum", "in" : "query", "schema" : { - "type" : "string", "enum" : [ "A", "B", "C" - ] + ], + "type" : "string" } } ], @@ -797,14 +796,14 @@ "parameters" : [ { "name" : "myOptional", - "in" : "query", - "schema" : { - "type" : [ - "integer", - "null" - ], - "format" : "int64" - } + "in" : "query", + "schema" : { + "format" : "int64", + "type" : [ + "integer", + "null" + ] + } } ], "responses" : { @@ -833,14 +832,14 @@ "name" : "myOptional", "in" : "query", "schema" : { - "type" : [ - "string", - "null" - ], "enum" : [ "A", "B", "C" + ], + "type" : [ + "string", + "null" ] } } @@ -905,8 +904,8 @@ "in" : "path", "required" : true, "schema" : { - "type" : "integer", - "format" : "int32" + "format" : "int32", + "type" : "integer" } }, { @@ -981,11 +980,10 @@ "name" : "addresses", "in" : "query", "schema" : { - "type" : "array", "items" : { - "type" : "string", - "nullable" : false - } + "type" : "string" + }, + "type" : "array" } }, { @@ -999,16 +997,15 @@ "name" : "type", "in" : "query", "schema" : { - "type" : "array", "items" : { - "type" : "string", - "nullable" : false, "enum" : [ "PROXY", "HIDE_N_SEEK", "FFA" - ] - } + ], + "type" : "string" + }, + "type" : "array" } } ], @@ -1039,8 +1036,8 @@ "in" : "path", "required" : true, "schema" : { - "type" : "integer", - "format" : "int32" + "format" : "int32", + "type" : "integer" } } ], @@ -1065,8 +1062,8 @@ "description" : "The hello Id.", "required" : true, "schema" : { - "type" : "integer", - "format" : "int32" + "format" : "int32", + "type" : "integer" } }, { @@ -1075,8 +1072,8 @@ "description" : "The name of the hello", "required" : true, "schema" : { - "type" : "string", - "format" : "date" + "format" : "date", + "type" : "string" } }, { @@ -1386,11 +1383,10 @@ "content" : { "application/json" : { "schema" : { - "type" : "array", "items" : { - "$ref" : "#/components/schemas/HelloDto", - "nullable" : false - } + "$ref" : "#/components/schemas/HelloDto" + }, + "type" : "array" } } } @@ -1431,12 +1427,12 @@ "name" : "type", "in" : "query", "schema" : { - "type" : "string", "enum" : [ "PROXY", "HIDE_N_SEEK", "FFA" - ] + ], + "type" : "string" } } ], @@ -1466,16 +1462,15 @@ "name" : "type", "in" : "query", "schema" : { - "type" : "array", "items" : { - "type" : "string", - "nullable" : false, "enum" : [ "PROXY", "HIDE_N_SEEK", "FFA" - ] - } + ], + "type" : "string" + }, + "type" : "array" } } ], @@ -1512,12 +1507,12 @@ "name" : "type", "in" : "query", "schema" : { - "type" : "string", "enum" : [ "PROXY", "HIDE_N_SEEK", "FFA" - ] + ], + "type" : "string" } } ], @@ -1547,11 +1542,10 @@ "name" : "strings", "in" : "query", "schema" : { - "type" : "array", "items" : { - "type" : "string", - "nullable" : false - } + "type" : "string" + }, + "type" : "array" } } ], @@ -1632,143 +1626,141 @@ "components" : { "schemas" : { "Bar" : { - "type" : "object", "properties" : { "id" : { - "type" : "integer", - "format" : "int64" + "format" : "int64", + "type" : "integer" }, "name" : { "type" : "string" } - } + }, + "type" : "object" }, "Baz" : { - "type" : "object", "properties" : { "id" : { - "type" : "integer", - "format" : "int64" + "format" : "int64", + "type" : "integer" }, "name" : { "type" : "string" }, "startDate" : { - "type" : "string", - "format" : "date" + "format" : "date", + "type" : "string" } - } + }, + "type" : "object" }, "HelloDto" : { "required" : [ "name" ], - "type" : "object", "properties" : { "id" : { - "type" : "integer", - "format" : "int32" + "format" : "int32", + "type" : "integer" }, "name" : { "type" : "string" }, "serverType" : { - "type" : "string", "enum" : [ "PROXY", "HIDE_N_SEEK", "FFA" - ] + ], + "type" : "string" } - } + }, + "type" : "object" }, "HelloWorld" : { - "type" : "object", "properties" : { "message" : { "type" : "string" }, "people" : { - "type" : "array", "items" : { - "$ref" : "#/components/schemas/Person", - "nullable" : false - } + "$ref" : "#/components/schemas/Person" + }, + "type" : "array" } - } + }, + "type" : "object" }, "HelloWorldZeroDependency" : { - "type" : "object", "properties" : { "message" : { "type" : "string" }, "people" : { - "type" : "array", "items" : { - "$ref" : "#/components/schemas/Person", - "nullable" : false - } + "$ref" : "#/components/schemas/Person" + }, + "type" : "array" } - } + }, + "type" : "object" }, "Person" : { - "type" : "object", "properties" : { "name" : { "type" : "string" }, "birthday" : { - "type" : "string", - "format" : "date" + "format" : "date", + "type" : "string" } - } + }, + "type" : "object" }, "StreamingOutput" : { "type" : "object" }, "ViewHome" : { - "type" : "object", "properties" : { "name" : { "type" : "string" } - } + }, + "type" : "object" }, "ViewPartial" : { - "type" : "object", "properties" : { "name" : { "type" : "string" } - } + }, + "type" : "object" }, "WebHelloDto" : { - "type" : "object", "properties" : { "id" : { - "type" : "integer", - "format" : "int32" + "format" : "int32", + "type" : "integer" }, "name" : { - "type" : "string", - "description" : "This is a comment" + "description" : "This is a comment", + "type" : "string" }, "otherParam" : { - "type" : "string", - "description" : "This is a comment" + "description" : "This is a comment", + "type" : "string" }, "gid" : { - "type" : "string", - "format" : "uuid" + "format" : "uuid", + "type" : "string" }, "whenAction" : { - "type" : "string", - "format" : "date-time" + "format" : "date-time", + "type" : "string" } - } + }, + "type" : "object" } } }, "jsonSchemaDialect" : "https://spec.openapis.org/oas/3.1/dialect/base" -} +} \ No newline at end of file diff --git a/tests/test-nima-jsonb/src/test/resources/expectedOpenApi.json b/tests/test-nima-jsonb/src/test/resources/expectedOpenApi.json index 522821a36..073678b16 100644 --- a/tests/test-nima-jsonb/src/test/resources/expectedOpenApi.json +++ b/tests/test-nima-jsonb/src/test/resources/expectedOpenApi.json @@ -147,8 +147,7 @@ "schema": { "type": "array", "items": { - "$ref": "#/components/schemas/Person", - "nullable": false + "$ref": "#/components/schemas/Person" } } } @@ -231,8 +230,7 @@ "properties": { "id": { "type": "integer", - "format": "int64", - "nullable": false + "format": "int64" }, "name": { "type": "string" diff --git a/tests/test-sigma/src/main/resources/public/openapi.json b/tests/test-sigma/src/main/resources/public/openapi.json index a2f607a77..08c5432eb 100644 --- a/tests/test-sigma/src/main/resources/public/openapi.json +++ b/tests/test-sigma/src/main/resources/public/openapi.json @@ -62,11 +62,10 @@ "content" : { "application/json" : { "schema" : { - "type" : "array", "items" : { - "$ref" : "#/components/schemas/Bar", - "nullable" : false - } + "$ref" : "#/components/schemas/Bar" + }, + "type" : "array" } } } @@ -87,8 +86,8 @@ "in" : "path", "required" : true, "schema" : { - "type" : "integer", - "format" : "int64" + "format" : "int64", + "type" : "integer" } } ], @@ -119,11 +118,10 @@ "content" : { "application/json" : { "schema" : { - "type" : "array", "items" : { - "$ref" : "#/components/schemas/Baz", - "nullable" : false - } + "$ref" : "#/components/schemas/Baz" + }, + "type" : "array" } } } @@ -152,8 +150,8 @@ "content" : { "application/json" : { "schema" : { - "type" : "integer", - "format" : "int64" + "format" : "int64", + "type" : "integer" } } } @@ -174,8 +172,8 @@ "in" : "path", "required" : true, "schema" : { - "type" : "integer", - "format" : "int32" + "format" : "int32", + "type" : "integer" } }, { @@ -196,8 +194,8 @@ "name" : "p3", "in" : "query", "schema" : { - "type" : "integer", - "format" : "int32" + "format" : "int32", + "type" : "integer" } }, { @@ -252,11 +250,10 @@ "content" : { "application/json" : { "schema" : { - "type" : "array", "items" : { - "$ref" : "#/components/schemas/Baz", - "nullable" : false - } + "$ref" : "#/components/schemas/Baz" + }, + "type" : "array" } } } @@ -277,8 +274,8 @@ "in" : "path", "required" : true, "schema" : { - "type" : "integer", - "format" : "int64" + "format" : "int64", + "type" : "integer" } } ], @@ -361,11 +358,10 @@ "content" : { "application/json" : { "schema" : { - "type" : "array", "items" : { - "$ref" : "#/components/schemas/HelloDto", - "nullable" : false - } + "$ref" : "#/components/schemas/HelloDto" + }, + "type" : "array" } } } @@ -425,11 +421,10 @@ "content" : { "application/json" : { "schema" : { - "type" : "array", "items" : { - "$ref" : "#/components/schemas/HelloDto", - "nullable" : false - } + "$ref" : "#/components/schemas/HelloDto" + }, + "type" : "array" } } } @@ -480,7 +475,8 @@ "url" : { "type" : "string" } - } + }, + "type" : "object" } } }, @@ -552,10 +548,11 @@ "type" : "string" }, "startDate" : { - "type" : "string", - "format" : "date" + "format" : "date", + "type" : "string" } - } + }, + "type" : "object" } } }, @@ -590,7 +587,8 @@ "url" : { "type" : "string" } - } + }, + "type" : "object" } } }, @@ -626,10 +624,11 @@ "type" : "string" }, "startDate" : { - "type" : "string", - "format" : "date" + "format" : "date", + "type" : "string" } - } + }, + "type" : "object" } } }, @@ -708,12 +707,12 @@ "name" : "myEnum", "in" : "query", "schema" : { - "type" : "string", "enum" : [ "A", "B", "C" - ] + ], + "type" : "string" } } ], @@ -744,8 +743,8 @@ "in" : "path", "required" : true, "schema" : { - "type" : "integer", - "format" : "int32" + "format" : "int32", + "type" : "integer" } }, { @@ -820,11 +819,10 @@ "name" : "addresses", "in" : "query", "schema" : { - "type" : "array", "items" : { - "type" : "string", - "nullable" : false - } + "type" : "string" + }, + "type" : "array" } }, { @@ -838,16 +836,15 @@ "name" : "type", "in" : "query", "schema" : { - "type" : "array", "items" : { - "type" : "string", - "nullable" : false, "enum" : [ "PROXY", "HIDE_N_SEEK", "FFA" - ] - } + ], + "type" : "string" + }, + "type" : "array" } } ], @@ -878,8 +875,8 @@ "in" : "path", "required" : true, "schema" : { - "type" : "integer", - "format" : "int32" + "format" : "int32", + "type" : "integer" } } ], @@ -904,8 +901,8 @@ "description" : "The hello Id.", "required" : true, "schema" : { - "type" : "integer", - "format" : "int32" + "format" : "int32", + "type" : "integer" } }, { @@ -914,8 +911,8 @@ "description" : "The name of the hello", "required" : true, "schema" : { - "type" : "string", - "format" : "date" + "format" : "date", + "type" : "string" } }, { @@ -1106,11 +1103,10 @@ "content" : { "application/json" : { "schema" : { - "type" : "array", "items" : { - "$ref" : "#/components/schemas/Person", - "nullable" : false - } + "$ref" : "#/components/schemas/Person" + }, + "type" : "array" } } }, @@ -1297,14 +1293,15 @@ "type" : "string" }, "type" : { - "type" : "string", "enum" : [ "PROXY", "HIDE_N_SEEK", "FFA" - ] + ], + "type" : "string" } - } + }, + "type" : "object" } } }, @@ -1334,14 +1331,15 @@ "type" : "string" }, "type" : { - "type" : "string", "enum" : [ "PROXY", "HIDE_N_SEEK", "FFA" - ] + ], + "type" : "string" } - } + }, + "type" : "object" } } }, @@ -1374,12 +1372,12 @@ "in" : "path", "required" : true, "schema" : { - "type" : "string", "enum" : [ "PROXY", "HIDE_N_SEEK", "FFA" - ] + ], + "type" : "string" } } ], @@ -1409,12 +1407,12 @@ "name" : "type", "in" : "query", "schema" : { - "type" : "string", "enum" : [ "PROXY", "HIDE_N_SEEK", "FFA" - ] + ], + "type" : "string" } } ], @@ -1444,16 +1442,15 @@ "name" : "type", "in" : "query", "schema" : { - "type" : "array", "items" : { - "type" : "string", - "nullable" : false, "enum" : [ "PROXY", "HIDE_N_SEEK", "FFA" - ] - } + ], + "type" : "string" + }, + "type" : "array" } } ], @@ -1490,12 +1487,12 @@ "name" : "type", "in" : "query", "schema" : { - "type" : "string", "enum" : [ "PROXY", "HIDE_N_SEEK", "FFA" - ] + ], + "type" : "string" } } ], @@ -1535,7 +1532,8 @@ "url" : { "type" : "string" } - } + }, + "type" : "object" } } }, @@ -1577,7 +1575,8 @@ "url" : { "type" : "string" } - } + }, + "type" : "object" } } }, @@ -1611,13 +1610,13 @@ "type" : "object", "properties" : { "strings" : { - "type" : "array", "items" : { - "type" : "string", - "nullable" : false - } + "type" : "string" + }, + "type" : "array" } - } + }, + "type" : "object" } } }, @@ -1701,8 +1700,8 @@ "content" : { "application/json" : { "schema" : { - "type" : "integer", - "format" : "int32" + "format" : "int32", + "type" : "integer" } } } @@ -1723,8 +1722,8 @@ "content" : { "application/json" : { "schema" : { - "type" : "integer", - "format" : "int64" + "format" : "int64", + "type" : "integer" } } } @@ -1774,11 +1773,10 @@ "content" : { "application/json" : { "schema" : { - "type" : "array", "items" : { - "$ref" : "#/components/schemas/Person", - "nullable" : false - } + "$ref" : "#/components/schemas/Person" + }, + "type" : "array" } } }, @@ -1852,11 +1850,10 @@ "content" : { "application/json" : { "schema" : { - "type" : "array", "items" : { - "$ref" : "#/components/schemas/Person", - "nullable" : false - } + "$ref" : "#/components/schemas/Person" + }, + "type" : "array" } } } @@ -1887,11 +1884,10 @@ "content" : { "application/json" : { "schema" : { - "type" : "object", "additionalProperties" : { - "$ref" : "#/components/schemas/Person", - "nullable" : false - } + "$ref" : "#/components/schemas/Person" + }, + "type" : "object" } } } @@ -1922,11 +1918,10 @@ "content" : { "application/json" : { "schema" : { - "type" : "array", "items" : { - "$ref" : "#/components/schemas/Person", - "nullable" : false - } + "$ref" : "#/components/schemas/Person" + }, + "type" : "array" } } } @@ -2025,47 +2020,45 @@ "components" : { "schemas" : { "Bar" : { - "type" : "object", "properties" : { "id" : { - "type" : "integer", - "format" : "int64" + "format" : "int64", + "type" : "integer" }, "name" : { "type" : "string" } - } + }, + "type" : "object" }, "Baz" : { - "type" : "object", "properties" : { "id" : { - "type" : "integer", - "format" : "int64" + "format" : "int64", + "type" : "integer" }, "name" : { "type" : "string" }, "startDate" : { - "type" : "string", - "format" : "date" + "format" : "date", + "type" : "string" } - } + }, + "type" : "object" }, "Data_Bar" : { - "type" : "object", "properties" : { "data" : { - "type" : "array", "items" : { - "$ref" : "#/components/schemas/Bar", - "nullable" : false - } + "$ref" : "#/components/schemas/Bar" + }, + "type" : "array" } - } + }, + "type" : "object" }, "ErrorResponse" : { - "type" : "object", "properties" : { "id" : { "type" : "string" @@ -2073,74 +2066,73 @@ "text" : { "type" : "string" } - } + }, + "type" : "object" }, "HelloDto" : { - "type" : "object", "properties" : { "id" : { - "type" : "integer", - "format" : "int32" + "format" : "int32", + "type" : "integer" }, "name" : { - "type" : "string", - "description" : "This is a comment" + "description" : "This is a comment", + "type" : "string" }, "otherParam" : { - "type" : "string", - "description" : "This is a comment" + "description" : "This is a comment", + "type" : "string" }, "gid" : { - "type" : "string", - "format" : "uuid" + "format" : "uuid", + "type" : "string" }, "whenAction" : { - "type" : "string", - "format" : "date-time" + "format" : "date-time", + "type" : "string" } - } + }, + "type" : "object" }, "HelloWorld" : { - "type" : "object", "properties" : { "message" : { "type" : "string" }, "people" : { - "type" : "array", "items" : { - "$ref" : "#/components/schemas/Person", - "nullable" : false - } + "$ref" : "#/components/schemas/Person" + }, + "type" : "array" } - } + }, + "type" : "object" }, "HelloWorldZeroDependency" : { - "type" : "object", "properties" : { "message" : { "type" : "string" }, "people" : { - "type" : "array", "items" : { - "$ref" : "#/components/schemas/Person", - "nullable" : false - } + "$ref" : "#/components/schemas/Person" + }, + "type" : "array" } - } + }, + "type" : "object" }, "Person" : { - "type" : "object", "properties" : { "name" : { "type" : "string" }, "birthday" : { - "type" : "string", - "format" : "date" + "format" : "date", + "type" : "string" } - } + }, + "type" : "object" } }, "securitySchemes" : { @@ -2153,4 +2145,4 @@ } }, "jsonSchemaDialect" : "https://spec.openapis.org/oas/3.1/dialect/base" -} +} \ No newline at end of file From 651b6e9627c8aece952395f06282fc5673924697 Mon Sep 17 00:00:00 2001 From: Rob Bygrave Date: Sat, 2 May 2026 14:52:12 +1200 Subject: [PATCH 6/8] Update test expectations for NullMarkedControllerTest and expectedOpenApi.json --- .../myapp/NullMarkedControllerTest.java | 22 +- .../src/test/resources/expectedOpenApi.json | 448 ++++++++++-------- 2 files changed, 272 insertions(+), 198 deletions(-) diff --git a/tests/test-javalin/src/test/java/org/example/myapp/NullMarkedControllerTest.java b/tests/test-javalin/src/test/java/org/example/myapp/NullMarkedControllerTest.java index d67c50de0..1a9b852bd 100644 --- a/tests/test-javalin/src/test/java/org/example/myapp/NullMarkedControllerTest.java +++ b/tests/test-javalin/src/test/java/org/example/myapp/NullMarkedControllerTest.java @@ -22,20 +22,20 @@ public void testClass() throws IOException { JsonNode nullMarkedClassDTO = schemas.get("NullMarkedClassDTO"); assertEquals("[\"map1\",\"map2\",\"map3\",\"set1\",\"set2\",\"string1\",\"stringArray1\",\"stringArray2\"]", nullMarkedClassDTO.get("required").toString()); - assertEquals("{\"type\":\"string\",\"nullable\":false}", nullMarkedClassDTO.get("properties").get("string1").toString()); + assertEquals("{\"type\":\"string\"}", nullMarkedClassDTO.get("properties").get("string1").toString()); assertEquals("{\"type\":\"string\"}", nullMarkedClassDTO.get("properties").get("string2").toString()); - assertEquals("{\"type\":\"array\",\"nullable\":false,\"items\":{\"type\":\"string\"}}", nullMarkedClassDTO.get("properties").get("stringArray1").toString()); - assertEquals("{\"type\":\"array\",\"nullable\":false,\"items\":{\"type\":\"string\"}}", nullMarkedClassDTO.get("properties").get("stringArray2").toString()); + assertEquals("{\"items\":{\"type\":\"string\"},\"type\":\"array\"}", nullMarkedClassDTO.get("properties").get("stringArray1").toString()); + assertEquals("{\"items\":{\"type\":\"string\"},\"type\":\"array\"}", nullMarkedClassDTO.get("properties").get("stringArray2").toString()); - assertEquals("{\"type\":\"array\",\"nullable\":false,\"items\":{\"type\":\"string\",\"nullable\":false}}", nullMarkedClassDTO.get("properties").get("set1").toString()); - assertEquals("{\"type\":\"array\",\"nullable\":false,\"items\":{\"type\":\"string\"}}", nullMarkedClassDTO.get("properties").get("set2").toString()); - assertEquals("{\"type\":\"array\",\"items\":{\"type\":\"string\",\"nullable\":false}}", nullMarkedClassDTO.get("properties").get("set3").toString()); + assertEquals("{\"items\":{\"type\":\"string\"},\"type\":\"array\"}", nullMarkedClassDTO.get("properties").get("set1").toString()); + assertEquals("{\"items\":{\"type\":\"string\"},\"type\":\"array\"}", nullMarkedClassDTO.get("properties").get("set2").toString()); + assertEquals("{\"items\":{\"type\":\"string\"},\"type\":\"array\"}", nullMarkedClassDTO.get("properties").get("set3").toString()); - assertEquals("{\"type\":\"object\",\"additionalProperties\":{\"type\":\"string\",\"nullable\":false},\"nullable\":false}", nullMarkedClassDTO.get("properties").get("map1").toString()); - assertEquals("{\"type\":\"object\",\"additionalProperties\":{\"type\":\"string\",\"nullable\":false},\"nullable\":false}", nullMarkedClassDTO.get("properties").get("map2").toString()); - assertEquals("{\"type\":\"object\",\"additionalProperties\":{\"type\":\"string\"},\"nullable\":false}", nullMarkedClassDTO.get("properties").get("map3").toString()); - assertEquals("{\"type\":\"object\",\"additionalProperties\":{\"type\":\"string\",\"nullable\":false}}", nullMarkedClassDTO.get("properties").get("map4").toString()); + assertEquals("{\"additionalProperties\":{\"type\":\"string\"},\"type\":\"object\"}", nullMarkedClassDTO.get("properties").get("map1").toString()); + assertEquals("{\"additionalProperties\":{\"type\":\"string\"},\"type\":\"object\"}", nullMarkedClassDTO.get("properties").get("map2").toString()); + assertEquals("{\"additionalProperties\":{\"type\":\"string\"},\"type\":\"object\"}", nullMarkedClassDTO.get("properties").get("map3").toString()); + assertEquals("{\"additionalProperties\":{\"type\":\"string\"},\"type\":\"object\"}", nullMarkedClassDTO.get("properties").get("map4").toString()); } } @@ -49,7 +49,7 @@ public void testRecord() throws IOException { JsonNode nullMarkedRecordDTO = schemas.get("NullMarkedRecordDTO"); assertEquals("[\"notNullable\"]", nullMarkedRecordDTO.get("required").toString()); - assertEquals("{\"type\":\"string\",\"nullable\":false}", nullMarkedRecordDTO.get("properties").get("notNullable").toString()); + assertEquals("{\"type\":\"string\"}", nullMarkedRecordDTO.get("properties").get("notNullable").toString()); assertEquals("{\"type\":\"string\"}", nullMarkedRecordDTO.get("properties").get("nullable").toString()); } } diff --git a/tests/test-vertx/src/test/resources/expectedOpenApi.json b/tests/test-vertx/src/test/resources/expectedOpenApi.json index cad2259db..37675c056 100644 --- a/tests/test-vertx/src/test/resources/expectedOpenApi.json +++ b/tests/test-vertx/src/test/resources/expectedOpenApi.json @@ -1,189 +1,263 @@ { - "openapi" : "3.1.2", - "info" : { - "title" : "", - "version" : "" - }, - "servers" : [ - { - "url" : "localhost:8080", - "description" : "local testing" - } - ], - "paths" : { - "/hello" : { - "get" : { - "tags" : [ - - ], - "summary" : "", - "description" : "", - "responses" : { - "200" : { - "description" : "", - "content" : { - "text/plain" : { - "schema" : { - "type" : "string" - } - } - } - } - } - } - }, - "/hello/with-params/{id}" : { - "get" : { - "tags" : [ - - ], - "summary" : "", - "description" : "", - "parameters" : [ - { - "name" : "id", - "in" : "path", - "required" : true, - "schema" : { - "type" : "integer", - "format" : "int64" - } - }, - { - "name" : "q", - "in" : "query", - "schema" : { - "type" : "string" - } - }, - { - "name" : "X-Trace", - "in" : "header", - "schema" : { - "type" : "string" - } - } - ], - "responses" : { - "200" : { - "description" : "", - "content" : { - "text/plain" : { - "schema" : { - "type" : "string" - } - } - } - } - } - } - }, - "/roles-test/blocking" : { - "get" : { - "tags" : [ - - ], - "summary" : "", - "description" : "", - "responses" : { - "200" : { - "description" : "", - "content" : { - "application/json" : { - "schema" : { - "type" : "string" - } - } - } - } - } - } - }, - "/roles-test/explicit" : { - "get" : { - "tags" : [ - - ], - "summary" : "", - "description" : "", - "responses" : { - "200" : { - "description" : "", - "content" : { - "application/json" : { - "schema" : { - "type" : "string" - } - } - } - } - } - } - }, - "/roles-test/inherited" : { - "get" : { - "tags" : [ - - ], - "summary" : "", - "description" : "", - "responses" : { - "200" : { - "description" : "", - "content" : { - "application/json" : { - "schema" : { - "type" : "string" - } - } - } - } - } - } - }, - "/roles-test/payload" : { - "post" : { - "tags" : [ - - ], - "summary" : "", - "description" : "", - "requestBody" : { - "content" : { - "application/json" : { - "schema" : { - "$ref" : "#/components/schemas/Payload" - } - } - }, - "required" : true - }, - "responses" : { - "201" : { - "description" : "", - "content" : { - "application/json" : { - "schema" : { - "$ref" : "#/components/schemas/Payload" - } - } - } - } - } - } - } - }, - "components" : { - "schemas" : { - "Payload" : { - "type" : "object", - "properties" : { - "name" : { - "type" : "string" - } - } - } - } - }, - "jsonSchemaDialect" : "https://spec.openapis.org/oas/3.1/dialect/base" + "openapi": "3.1.2", + "info": { + "title": "", + "version": "" + }, + "servers": [ + { + "url": "localhost:8080", + "description": "local testing" + } + ], + "paths": { + "/a": { + "get": { + "tags": [], + "summary": "", + "description": "", + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/StandardRecordWithComments" + } + } + } + } + } + } + }, + "/b": { + "get": { + "tags": [], + "summary": "", + "description": "", + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "description": "I'm overriding the description", + "$ref": "#/components/schemas/RecordWithSchemaDescriptions", + "example": "{\"item\": \"Hi example\"}" + } + } + } + } + } + } + }, + "/c": { + "post": { + "tags": [], + "summary": "", + "description": "", + "requestBody": { + "content": { + "application/text": { + "schema": { + "type": "string" + } + } + }, + "required": true + }, + "responses": { + "201": { + "description": "No content" + } + } + } + }, + "/hello": { + "get": { + "tags": [], + "summary": "", + "description": "", + "responses": { + "200": { + "description": "", + "content": { + "text/plain": { + "schema": { + "type": "string" + } + } + } + } + } + } + }, + "/hello/with-params/{id}": { + "get": { + "tags": [], + "summary": "", + "description": "", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "format": "int64", + "type": "integer" + } + }, + { + "name": "q", + "in": "query", + "schema": { + "type": "string" + } + }, + { + "name": "X-Trace", + "in": "header", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "", + "content": { + "text/plain": { + "schema": { + "type": "string" + } + } + } + } + } + } + }, + "/roles-test/blocking": { + "get": { + "tags": [], + "summary": "", + "description": "", + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "type": "string" + } + } + } + } + } + } + }, + "/roles-test/explicit": { + "get": { + "tags": [], + "summary": "", + "description": "", + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "type": "string" + } + } + } + } + } + } + }, + "/roles-test/inherited": { + "get": { + "tags": [], + "summary": "", + "description": "", + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "type": "string" + } + } + } + } + } + } + }, + "/roles-test/payload": { + "post": { + "tags": [], + "summary": "", + "description": "", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Payload" + } + } + }, + "required": true + }, + "responses": { + "201": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Payload" + } + } + } + } + } + } + } + }, + "components": { + "schemas": { + "Payload": { + "properties": { + "name": { + "type": "string" + } + }, + "type": "object" + }, + "RecordWithSchemaDescriptions": { + "properties": { + "item": { + "_default": "This is a default value", + "description": "Overridden", + "type": "string" + } + }, + "type": "object" + }, + "StandardRecordWithComments": { + "properties": { + "blah": { + "description": "The first param", + "type": "string" + }, + "anotherBlah": { + "description": "The second param", + "format": "int32", + "type": "integer" + } + }, + "type": "object" + } + } + }, + "jsonSchemaDialect": "https://spec.openapis.org/oas/3.1/dialect/base" } From f04d56024c43777711e12e1afafa715b57b2789e Mon Sep 17 00:00:00 2001 From: Harry Chan <38070640+re-thc@users.noreply.github.com> Date: Fri, 8 May 2026 23:33:17 +0800 Subject: [PATCH 7/8] Fix OpenAPI 3.1 schema normalization --- http-generator-core/pom.xml | 6 + .../core/openapi/OpenAPISerializer.java | 40 +- .../core/openapi/SchemaDocBuilder.java | 131 +++--- .../src/main/java/module-info.java | 1 + .../core/openapi/OpenAPISerializerTest.java | 43 ++ openapi-core/pom.xml | 26 ++ .../http/openapi/OpenApiSchemaNormalizer.java | 412 ++++++++++++++++++ openapi-core/src/main/java/module-info.java | 6 + .../openapi/OpenApiSchemaNormalizerTest.java | 91 ++++ openapi-maven-plugin/pom.xml | 6 + .../http/maven/openapi/OpenAPIMergerUtil.java | 115 ++--- .../openapi/jsonb/SchemaCustomAdaptor.java | 156 ++----- .../maven/openapi/OpenAPIMergerUtilTest.java | 52 ++- .../jsonb/SchemaCustomAdaptorTest.java | 42 ++ pom.xml | 1 + .../src/main/resources/public/openapi.json | 35 +- .../myapp/NullMarkedControllerTest.java | 50 ++- 17 files changed, 919 insertions(+), 294 deletions(-) create mode 100644 openapi-core/pom.xml create mode 100644 openapi-core/src/main/java/io/avaje/http/openapi/OpenApiSchemaNormalizer.java create mode 100644 openapi-core/src/main/java/module-info.java create mode 100644 openapi-core/src/test/java/io/avaje/http/openapi/OpenApiSchemaNormalizerTest.java diff --git a/http-generator-core/pom.xml b/http-generator-core/pom.xml index 00bcda182..6a5a14e4c 100644 --- a/http-generator-core/pom.xml +++ b/http-generator-core/pom.xml @@ -35,6 +35,12 @@ provided + + io.avaje + avaje-http-openapi-core + ${project.version} + + io.swagger.core.v3 swagger-models diff --git a/http-generator-core/src/main/java/io/avaje/http/generator/core/openapi/OpenAPISerializer.java b/http-generator-core/src/main/java/io/avaje/http/generator/core/openapi/OpenAPISerializer.java index 3c2d21953..4b280208f 100644 --- a/http-generator-core/src/main/java/io/avaje/http/generator/core/openapi/OpenAPISerializer.java +++ b/http-generator-core/src/main/java/io/avaje/http/generator/core/openapi/OpenAPISerializer.java @@ -6,6 +6,7 @@ import java.util.Map; import java.util.Set; +import io.avaje.http.openapi.OpenApiSchemaNormalizer; import io.swagger.v3.oas.models.media.Schema; final class OpenAPISerializer { @@ -20,6 +21,7 @@ final class OpenAPISerializer { "USE_ARBITRARY_SCHEMA_PROPERTY", "exampleSetFlag", "defaultSetFlag", + "nullable", "specVersion"); private OpenAPISerializer() {} @@ -93,11 +95,18 @@ static String serialize(Object obj) throws IllegalAccessException { Object value = field.get(obj); if (obj instanceof Schema) { final Schema schema = (Schema) obj; - if ("type".equals(field.getName()) && hasSchemaTypes(schema.getTypes())) { + if ("type".equals(field.getName()) && OpenApiSchemaNormalizer.hasTypeSet(schema)) { + continue; + } + if (skipSchemaField(schema, field.getName())) { continue; } if ("types".equals(field.getName())) { - value = schemaTypeValue(schema); + value = OpenApiSchemaNormalizer.schemaTypeValue(schema); + } else if ("exclusiveMaximumValue".equals(field.getName())) { + value = OpenApiSchemaNormalizer.exclusiveMaximumValue(schema); + } else if ("exclusiveMinimumValue".equals(field.getName())) { + value = OpenApiSchemaNormalizer.exclusiveMinimumValue(schema); } } if (value != null) { @@ -143,19 +152,14 @@ static Field[] getAllFields(Class clazz) { } } - private static boolean hasSchemaTypes(Set types) { - return types != null && !types.isEmpty(); - } - - private static Object schemaTypeValue(Schema schema) { - final var types = schema.getTypes(); - if (!hasSchemaTypes(types)) { - return schema.getType(); + private static boolean skipSchemaField(Schema schema, String fieldName) { + if ("exclusiveMaximum".equals(fieldName) || "exclusiveMinimum".equals(fieldName)) { + return true; } - if (types.size() == 1) { - return types.iterator().next(); + if ("maximum".equals(fieldName) && OpenApiSchemaNormalizer.omitMaximum(schema)) { + return true; } - return types; + return "minimum".equals(fieldName) && OpenApiSchemaNormalizer.omitMinimum(schema); } private static String jsonFieldName(Object container, String fieldName) { @@ -165,6 +169,12 @@ private static String jsonFieldName(Object container, String fieldName) { if (container instanceof Schema && "types".equals(fieldName)) { return "type"; } + if (container instanceof Schema && "exclusiveMaximumValue".equals(fieldName)) { + return "exclusiveMaximum"; + } + if (container instanceof Schema && "exclusiveMinimumValue".equals(fieldName)) { + return "exclusiveMinimum"; + } return fieldName; } @@ -218,7 +228,9 @@ static Object extractPrimitiveValue(Object object) { static void write(StringBuilder sb, Object value) throws IllegalAccessException { final var isPrimitiveWrapper = isPrimitiveWrapperType(value); // Append primitive or string value as is - if (value.getClass().isPrimitive() || value instanceof String || isPrimitiveWrapper) { + if (value instanceof Number) { + sb.append(value); + } else if (value.getClass().isPrimitive() || value instanceof String || isPrimitiveWrapper) { if (isPrimitiveWrapper) { sb.append(extractPrimitiveValue(value)); } else { diff --git a/http-generator-core/src/main/java/io/avaje/http/generator/core/openapi/SchemaDocBuilder.java b/http-generator-core/src/main/java/io/avaje/http/generator/core/openapi/SchemaDocBuilder.java index 17b133153..7ac81ec8f 100644 --- a/http-generator-core/src/main/java/io/avaje/http/generator/core/openapi/SchemaDocBuilder.java +++ b/http-generator-core/src/main/java/io/avaje/http/generator/core/openapi/SchemaDocBuilder.java @@ -4,9 +4,9 @@ import io.avaje.http.generator.core.SchemaPrism; import io.avaje.http.generator.core.javadoc.Javadoc; +import io.avaje.http.openapi.OpenApiSchemaNormalizer; import io.swagger.v3.oas.models.media.StringSchema; import java.util.ArrayList; -import java.util.LinkedHashSet; import java.util.List; import java.util.Map; import java.util.Optional; @@ -143,8 +143,10 @@ Schema toSchema(Element element) { var schema = toSchema(element.asType()); setLengthMinMax(element, schema); setFormatFromValidation(element, schema); - if (isNotNullable(element)) { - schema = markNotNullable(schema); + if (isNullable(element)) { + schema = OpenApiSchemaNormalizer.nullable(schema); + } else if (isNotNullable(element) && !isOptionalType(element.asType())) { + schema = OpenApiSchemaNormalizer.notNullable(schema); } return schema; } @@ -152,15 +154,17 @@ Schema toSchema(Element element) { Schema toSchema(TypeMirror type) { final Optional prism = Optional.ofNullable(APContext.asTypeElement(type)) .flatMap(SchemaPrism::getOptionalOn); + final Schema schema; if (prism.isPresent()) { final SchemaPrism schemaPrism = prism.get(); - final Schema schema = SchemaPrismHelper.implementation(schemaPrism) + schema = SchemaPrismHelper.implementation(schemaPrism) .map(this::toSchemaImpl) .orElseGet(() -> (Schema) toSchemaImpl(type)); SchemaPrismHelper.overwriteFromPrism(schema, schemaPrism); - return schema; + } else { + schema = toSchemaImpl(type); } - return toSchemaImpl(type); + return isNullable(type) ? OpenApiSchemaNormalizer.nullable(schema) : schema; } private Schema toSchemaImpl(TypeMirror type) { @@ -230,7 +234,7 @@ private Schema buildOptionalSchema(TypeMirror type) { } } // Since it's explicitly optional, we should explicitly say it's nullable - return markNullable(itemSchema); + return OpenApiSchemaNormalizer.nullable(itemSchema); } private Schema buildIterableSchema(TypeMirror type) { @@ -240,8 +244,10 @@ private Schema buildIterableSchema(TypeMirror type) { if (typeArguments.size() == 1) { TypeMirror typeMirror = typeArguments.get(0); itemSchema = toSchema(typeMirror); - if (isNotNullable(typeMirror)) { - itemSchema = markNotNullable(itemSchema); + if (isNullable(typeMirror)) { + itemSchema = OpenApiSchemaNormalizer.nullable(itemSchema); + } else if (isNotNullable(typeMirror)) { + itemSchema = OpenApiSchemaNormalizer.notNullable(itemSchema); } } } @@ -253,7 +259,11 @@ private Schema buildIterableSchema(TypeMirror type) { private Schema buildArraySchema(TypeMirror type) { ArrayType arrayType = (ArrayType) type; - Schema itemSchema = toSchema(arrayType.getComponentType()); + final var componentType = arrayType.getComponentType(); + Schema itemSchema = toSchema(componentType); + if (isNullable(componentType)) { + itemSchema = OpenApiSchemaNormalizer.nullable(itemSchema); + } ArraySchema arraySchema = new ArraySchema(); arraySchema.setItems(itemSchema); @@ -269,8 +279,10 @@ private Schema buildMapSchema(TypeMirror type) { if (typeArguments.size() == 2) { TypeMirror valueType = typeArguments.get(1); valueSchema = toSchema(valueType); - if (isNotNullable(valueType)) { - valueSchema = markNotNullable(valueSchema); + if (isNullable(valueType)) { + valueSchema = OpenApiSchemaNormalizer.nullable(valueSchema); + } else if (isNotNullable(valueType)) { + valueSchema = OpenApiSchemaNormalizer.notNullable(valueSchema); } } } @@ -309,60 +321,26 @@ private void populateObjectSchema(TypeMirror objectType, Schema objectSch TypeMirror fieldType = objectType instanceof DeclaredType ? types.asMemberOf((DeclaredType) objectType, field) : field.asType(); - final Schema propSchema = SchemaPrism.getOptionalOn(field) + Schema propSchema = SchemaPrism.getOptionalOn(field) .flatMap(SchemaPrismHelper::implementation) .map(this::toSchema) .orElseGet(() -> (Schema) toSchema(fieldType)); - if (isNotNullable(field)) { - markNotNullable(propSchema); + if (isNullable(field)) { + propSchema = OpenApiSchemaNormalizer.nullable(propSchema); + } else if (isNotNullable(field) && !isOptionalType(fieldType)) { + propSchema = OpenApiSchemaNormalizer.notNullable(propSchema); objectSchema.addRequiredItem(field.getSimpleName().toString()); } setDescription(field, propSchema); setLengthMinMax(field, propSchema); setFormatFromValidation(field, propSchema); + final Schema finalPropSchema = propSchema; SchemaPrism.getOptionalOn(field) - .ifPresent(schemaPrism -> SchemaPrismHelper.overwriteFromPrism(propSchema, schemaPrism)); + .ifPresent(schemaPrism -> SchemaPrismHelper.overwriteFromPrism(finalPropSchema, schemaPrism)); objectSchema.addProperties(field.getSimpleName().toString(), propSchema); } } - private Schema markNullable(Schema schema) { - if (schema.get$ref() != null) { - final var oneOf = new ArrayList(); - oneOf.add(schema); - oneOf.add(new Schema<>().type("null")); - return new Schema<>().oneOf(oneOf); - } - final var schemaType = schema.getType(); - if (schemaType != null && !schemaType.isBlank()) { - final var union = new LinkedHashSet(); - union.add(schemaType); - union.add("null"); - schema.setType(null); - schema.setTypes(union); - schema.setNullable(null); - return schema; - } - final var union = new LinkedHashSet(); - if (schema.getTypes() != null) { - union.addAll(schema.getTypes()); - } - union.add("null"); - schema.setTypes(union); - schema.setNullable(null); - return schema; - } - - private Schema markNotNullable(Schema schema) { - if (schema.getTypes() != null && schema.getTypes().contains("null")) { - final var nonNullTypes = new LinkedHashSet(schema.getTypes()); - nonNullTypes.remove("null"); - schema.setTypes(nonNullTypes.isEmpty() ? null : nonNullTypes); - } - schema.setNullable(null); - return schema; - } - private void setFormatFromValidation(Element element, Schema propSchema) { if (EmailPrism.isPresent(element) || JavaxEmailPrism.isPresent(element)) { propSchema.setFormat("email"); @@ -414,19 +392,10 @@ private void setLengthMinMax(Element element, Schema propSchema) { } private boolean isNotNullable(Element element) { - List annotationMirrors = new ArrayList<>(); - if (element instanceof VariableElement) { - annotationMirrors.addAll(element.asType().getAnnotationMirrors()); - } else { - annotationMirrors.addAll(element.getAnnotationMirrors()); - } - + final var annotationMirrors = annotationMirrors(element); if (Util.nullMarked(element)) { - for (var mirror : annotationMirrors) { - if ("Nullable" - .equals(APContext.asTypeElement(mirror.getAnnotationType()).getSimpleName().toString())) { - return false; - } + if (annotationMirrors.stream().anyMatch(this::isNullableAnnotation)) { + return false; } return true; } @@ -438,15 +407,33 @@ private boolean isNotNullable(Element element) { ); } - private boolean isNotNullable(TypeMirror type) { - List annotationMirrors = type.getAnnotationMirrors(); + private boolean isNullable(Element element) { + return annotationMirrors(element).stream().anyMatch(this::isNullableAnnotation); + } - for (AnnotationMirror annotationMirror : annotationMirrors) { - if ("org.jspecify.annotations.Nullable".equals(annotationMirror.getAnnotationType().asElement().toString())) { - return false; - } + private List annotationMirrors(Element element) { + List annotationMirrors = new ArrayList<>(); + annotationMirrors.addAll(element.getAnnotationMirrors()); + if (element instanceof VariableElement) { + annotationMirrors.addAll(element.asType().getAnnotationMirrors()); } - return true; + return annotationMirrors; + } + + private boolean isNullableAnnotation(AnnotationMirror mirror) { + return "Nullable".equals(mirror.getAnnotationType().asElement().getSimpleName().toString()); + } + + private boolean isOptionalType(TypeMirror type) { + return types.isAssignable(type, optionalType); + } + + private boolean isNotNullable(TypeMirror type) { + return !isNullable(type); + } + + private boolean isNullable(TypeMirror type) { + return type.getAnnotationMirrors().stream().anyMatch(this::isNullableAnnotation); } /** diff --git a/http-generator-core/src/main/java/module-info.java b/http-generator-core/src/main/java/module-info.java index ba600ade0..3d612306c 100644 --- a/http-generator-core/src/main/java/module-info.java +++ b/http-generator-core/src/main/java/module-info.java @@ -8,6 +8,7 @@ requires java.compiler; // SHADED: All content after this line will be removed at package time + requires io.avaje.http.openapi; requires static io.avaje.prism; requires static io.avaje.http.api; requires static io.avaje.htmx.api; diff --git a/http-generator-core/src/test/java/io/avaje/http/generator/core/openapi/OpenAPISerializerTest.java b/http-generator-core/src/test/java/io/avaje/http/generator/core/openapi/OpenAPISerializerTest.java index 51a8f0b85..a2ab2da62 100644 --- a/http-generator-core/src/test/java/io/avaje/http/generator/core/openapi/OpenAPISerializerTest.java +++ b/http-generator-core/src/test/java/io/avaje/http/generator/core/openapi/OpenAPISerializerTest.java @@ -3,7 +3,9 @@ import static org.assertj.core.api.Assertions.assertThat; import io.swagger.v3.oas.models.media.Schema; +import java.math.BigDecimal; import java.util.LinkedHashSet; +import java.util.Map; import java.util.Set; import org.junit.jupiter.api.Test; @@ -30,4 +32,45 @@ void serializesSingleSchemaTypeAsScalar() throws IllegalAccessException { assertThat(json).doesNotContain("\"type\" : ["); assertThat(json).doesNotContain("\"types\""); } + + @Test + void serializesNumericExclusiveBounds() throws IllegalAccessException { + final var schema = new Schema<>() + .type("number") + .exclusiveMinimumValue(new BigDecimal("1.5")) + .exclusiveMaximumValue(new BigDecimal("9.5")); + + final var json = OpenAPISerializer.serialize(schema); + + assertThat(json).contains("\"exclusiveMinimum\" : 1.5"); + assertThat(json).contains("\"exclusiveMaximum\" : 9.5"); + assertThat(json).doesNotContain("\"exclusiveMinimumValue\""); + assertThat(json).doesNotContain("\"exclusiveMaximumValue\""); + } + + @Test + void serializesLegacyBooleanExclusiveBoundsAsNumericBounds() throws IllegalAccessException { + final var schema = new Schema<>() + .type("integer") + .minimum(BigDecimal.ONE) + .exclusiveMinimum(true) + .maximum(BigDecimal.TEN) + .exclusiveMaximum(true); + + final var json = OpenAPISerializer.serialize(schema); + + assertThat(json).contains("\"exclusiveMinimum\" : 1"); + assertThat(json).contains("\"exclusiveMaximum\" : 10"); + assertThat(json).doesNotContain("\"minimum\" : 1"); + assertThat(json).doesNotContain("\"maximum\" : 10"); + assertThat(json).doesNotContain("\"exclusiveMinimum\" : true"); + assertThat(json).doesNotContain("\"exclusiveMaximum\" : true"); + } + + @Test + void serializesBigDecimalAsJsonNumber() throws IllegalAccessException { + final var json = OpenAPISerializer.serialize(Map.of("value", new BigDecimal("12.34"))); + + assertThat(json).isEqualTo("{\"value\" : 12.34}"); + } } diff --git a/openapi-core/pom.xml b/openapi-core/pom.xml new file mode 100644 index 000000000..2dbd1f2b9 --- /dev/null +++ b/openapi-core/pom.xml @@ -0,0 +1,26 @@ + + + 4.0.0 + + io.avaje + avaje-http-parent + 3.9-RC2 + + + avaje-http-openapi-core + avaje-http-openapi-core + + + + io.swagger.core.v3 + swagger-models + ${swagger.version} + + + com.fasterxml.jackson.core + jackson-annotations + + + + + diff --git a/openapi-core/src/main/java/io/avaje/http/openapi/OpenApiSchemaNormalizer.java b/openapi-core/src/main/java/io/avaje/http/openapi/OpenApiSchemaNormalizer.java new file mode 100644 index 000000000..e6b6d0ed6 --- /dev/null +++ b/openapi-core/src/main/java/io/avaje/http/openapi/OpenApiSchemaNormalizer.java @@ -0,0 +1,412 @@ +package io.avaje.http.openapi; + +import io.swagger.v3.oas.models.media.ArbitrarySchema; +import io.swagger.v3.oas.models.media.ArraySchema; +import io.swagger.v3.oas.models.media.BinarySchema; +import io.swagger.v3.oas.models.media.BooleanSchema; +import io.swagger.v3.oas.models.media.ByteArraySchema; +import io.swagger.v3.oas.models.media.DateSchema; +import io.swagger.v3.oas.models.media.DateTimeSchema; +import io.swagger.v3.oas.models.media.EmailSchema; +import io.swagger.v3.oas.models.media.IntegerSchema; +import io.swagger.v3.oas.models.media.NumberSchema; +import io.swagger.v3.oas.models.media.ObjectSchema; +import io.swagger.v3.oas.models.media.PasswordSchema; +import io.swagger.v3.oas.models.media.Schema; +import io.swagger.v3.oas.models.media.StringSchema; +import io.swagger.v3.oas.models.media.UUIDSchema; +import java.math.BigDecimal; +import java.util.ArrayList; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; + +/** Shared OpenAPI 3.1 schema normalization helpers. */ +public final class OpenApiSchemaNormalizer { + + private static final String NULL_TYPE = "null"; + + private OpenApiSchemaNormalizer() {} + + /** Mark a schema as allowing JSON null using OpenAPI 3.1 / JSON Schema syntax. */ + public static Schema nullable(Schema schema) { + if (schema == null) { + return null; + } + if (allowsNull(schema)) { + schema.setNullable(null); + return schema; + } + if (isReferenceOrComposite(schema)) { + return nullableWrapper(schema); + } + final var types = effectiveTypes(schema); + if (types.isEmpty()) { + return nullableWrapper(schema); + } + types.add(NULL_TYPE); + applyTypes(schema, types); + schema.setNullable(null); + return schema; + } + + /** Mark a schema as not allowing JSON null. */ + public static Schema notNullable(Schema schema) { + if (schema == null) { + return null; + } + schema.setNullable(null); + if (schema.getTypes() != null && schema.getTypes().contains(NULL_TYPE)) { + final var types = new LinkedHashSet(schema.getTypes()); + types.remove(NULL_TYPE); + applyTypes(schema, types); + } + schema.setAnyOf(withoutNullSchema(schema.getAnyOf())); + schema.setOneOf(withoutNullSchema(schema.getOneOf())); + return unwrapSingleAnyOfOrOneOf(schema); + } + + /** Normalize legacy nullable fields and nested schemas to OpenAPI 3.1 form. */ + public static Schema normalize(Schema schema) { + if (schema == null) { + return null; + } + normalizeNested(schema); + final Boolean nullable = schema.getNullable(); + if (Boolean.TRUE.equals(nullable)) { + return nullable(schema); + } + if (Boolean.FALSE.equals(nullable)) { + return notNullable(schema); + } + normalizeTypeFields(schema); + return schema; + } + + /** Effective non-null first type from a schema. */ + public static String firstNonNullType(Schema schema) { + if (schema == null) { + return null; + } + final var type = schema.getType(); + if (isType(type)) { + return type; + } + if (schema.getTypes() != null) { + for (final String candidate : schema.getTypes()) { + if (isType(candidate)) { + return candidate; + } + } + } + return null; + } + + /** Normalize schema type data, including legacy nullable, to a type set. */ + public static Set normalizeTypes( + final String type, final Set types, final Boolean nullable) { + + final var normalized = effectiveTypes(type, types); + if (Boolean.TRUE.equals(nullable)) { + normalized.add(NULL_TYPE); + } else if (Boolean.FALSE.equals(nullable)) { + normalized.remove(NULL_TYPE); + } + return normalized.isEmpty() ? null : normalized; + } + + /** Type to use when picking a concrete Swagger schema subclass. */ + public static String schemaTypeForCreation(final String type, final Set types) { + if (isType(type)) { + return type; + } + if (types == null || types.isEmpty()) { + return null; + } + for (final String candidate : types) { + if (isType(candidate)) { + return candidate; + } + } + return null; + } + + /** Return the scalar type when the normalized set contains exactly one type. */ + public static String scalarSchemaType(final Set normalizedTypes) { + if (normalizedTypes == null || normalizedTypes.size() != 1) { + return null; + } + return normalizedTypes.iterator().next(); + } + + /** Return the JSON value for a Schema type keyword. */ + public static Object schemaTypeValue(Schema schema) { + final var types = normalizeTypes(schema.getType(), schema.getTypes(), schema.getNullable()); + if (types == null || types.isEmpty()) { + return null; + } + if (types.size() == 1) { + return types.iterator().next(); + } + return types; + } + + /** True when schema has an OpenAPI/JSON Schema type set. */ + public static boolean hasTypeSet(Schema schema) { + return schema != null && schema.getTypes() != null && !schema.getTypes().isEmpty(); + } + + /** Whether the schema has explicit type/nullability information for merge precedence. */ + public static boolean hasTypeInformation(Schema schema) { + return schema != null + && (isType(schema.getType()) + || (schema.getTypes() != null && !schema.getTypes().isEmpty()) + || schema.getNullable() != null); + } + + /** Effective type set for merging. Includes scalar type values. */ + public static Set effectiveTypesForMerge(Schema schema) { + if (schema == null) { + return null; + } + final var normalized = normalizeTypes(schema.getType(), schema.getTypes(), schema.getNullable()); + return normalized == null || normalized.isEmpty() ? null : normalized; + } + + /** Create the Swagger schema subclass matching an OpenAPI type and format. */ + public static Schema createSchemaFromInformation(final String type, final String format) { + if (type == null) { + return new ArbitrarySchema(); + } + switch (type) { + case "array": + return new ArraySchema().format(format); + case "boolean": + return new BooleanSchema().format(format); + case "integer": + return new IntegerSchema().format(format); + case "object": + return new ObjectSchema().format(format); + case "number": + return new NumberSchema().format(format); + case "string": + return stringSchema(format); + default: + return new ArbitrarySchema().format(format).type(type); + } + } + + /** Numeric exclusiveMaximum value for OpenAPI 3.1 output. */ + public static BigDecimal exclusiveMaximumValue(Schema schema) { + if (schema == null) { + return null; + } + final var value = schema.getExclusiveMaximumValue(); + if (value != null) { + return value; + } + return Boolean.TRUE.equals(schema.getExclusiveMaximum()) ? schema.getMaximum() : null; + } + + /** Numeric exclusiveMinimum value for OpenAPI 3.1 output. */ + public static BigDecimal exclusiveMinimumValue(Schema schema) { + if (schema == null) { + return null; + } + final var value = schema.getExclusiveMinimumValue(); + if (value != null) { + return value; + } + return Boolean.TRUE.equals(schema.getExclusiveMinimum()) ? schema.getMinimum() : null; + } + + /** Maximum should be omitted when a legacy exclusiveMaximum boolean consumed it. */ + public static boolean omitMaximum(Schema schema) { + return schema != null + && schema.getExclusiveMaximumValue() == null + && Boolean.TRUE.equals(schema.getExclusiveMaximum()) + && schema.getMaximum() != null; + } + + /** Minimum should be omitted when a legacy exclusiveMinimum boolean consumed it. */ + public static boolean omitMinimum(Schema schema) { + return schema != null + && schema.getExclusiveMinimumValue() == null + && Boolean.TRUE.equals(schema.getExclusiveMinimum()) + && schema.getMinimum() != null; + } + + private static Schema stringSchema(String format) { + if (format == null) { + return new StringSchema(); + } + switch (format) { + case "binary": + return new BinarySchema(); + case "byte": + return new ByteArraySchema(); + case "date": + return new DateSchema(); + case "date-time": + return new DateTimeSchema(); + case "email": + return new EmailSchema(); + case "password": + return new PasswordSchema(); + case "uuid": + return new UUIDSchema(); + default: + return new StringSchema().format(format); + } + } + + private static void normalizeNested(Schema schema) { + schema.setNot(normalize(schema.getNot())); + schema.setItems(normalize(schema.getItems())); + schema.setContains(normalize(schema.getContains())); + schema.setContentSchema(normalize(schema.getContentSchema())); + schema.setPropertyNames(normalize(schema.getPropertyNames())); + schema.setUnevaluatedProperties(normalize(schema.getUnevaluatedProperties())); + schema.setAdditionalItems(normalize(schema.getAdditionalItems())); + schema.setUnevaluatedItems(normalize(schema.getUnevaluatedItems())); + schema.setIf(normalize(schema.getIf())); + schema.setElse(normalize(schema.getElse())); + schema.setThen(normalize(schema.getThen())); + schema.setProperties(normalizeMap(schema.getProperties())); + schema.setPatternProperties(normalizeMap(schema.getPatternProperties())); + schema.setDependentSchemas(normalizeMap(schema.getDependentSchemas())); + schema.setAdditionalProperties(normalizeAdditionalProperties(schema.getAdditionalProperties())); + schema.setPrefixItems(normalizeList(schema.getPrefixItems())); + schema.setAllOf(normalizeList(schema.getAllOf())); + schema.setAnyOf(normalizeList(schema.getAnyOf())); + schema.setOneOf(normalizeList(schema.getOneOf())); + } + + private static Map normalizeMap(Map schemas) { + if (schemas == null) { + return null; + } + schemas.replaceAll((key, schema) -> (Schema) normalize(schema)); + return schemas; + } + + private static Object normalizeAdditionalProperties(Object additionalProperties) { + if (additionalProperties instanceof Schema) { + return normalize((Schema) additionalProperties); + } + return additionalProperties; + } + + private static List normalizeList(List schemas) { + if (schemas == null) { + return null; + } + for (int i = 0; i < schemas.size(); i++) { + schemas.set(i, (Schema) normalize(schemas.get(i))); + } + return schemas; + } + + private static void normalizeTypeFields(Schema schema) { + final var types = normalizeTypes(schema.getType(), schema.getTypes(), null); + if (types != null && !types.isEmpty()) { + applyTypes(schema, types); + } + schema.setNullable(null); + } + + private static LinkedHashSet effectiveTypes(Schema schema) { + return effectiveTypes(schema.getType(), schema.getTypes()); + } + + private static LinkedHashSet effectiveTypes(String type, Set types) { + final var normalized = new LinkedHashSet(); + if (isType(type)) { + normalized.add(type); + } else if (NULL_TYPE.equals(type)) { + normalized.add(NULL_TYPE); + } + if (types != null) { + for (final String candidate : types) { + if (isType(candidate) || NULL_TYPE.equals(candidate)) { + normalized.add(candidate); + } + } + } + return normalized; + } + + private static void applyTypes(Schema schema, Set types) { + if (types == null || types.isEmpty()) { + schema.setTypes(null); + return; + } + schema.setType(null); + schema.setTypes(new LinkedHashSet<>(types)); + } + + private static boolean isType(String type) { + return type != null && !type.isBlank() && !NULL_TYPE.equals(type); + } + + private static boolean isReferenceOrComposite(Schema schema) { + return schema.get$ref() != null + || notEmpty(schema.getAllOf()) + || notEmpty(schema.getAnyOf()) + || notEmpty(schema.getOneOf()) + || schema.getNot() != null; + } + + private static boolean notEmpty(List value) { + return value != null && !value.isEmpty(); + } + + private static boolean allowsNull(Schema schema) { + return NULL_TYPE.equals(schema.getType()) + || (schema.getTypes() != null && schema.getTypes().contains(NULL_TYPE)) + || hasNullSchema(schema.getAnyOf()) + || hasNullSchema(schema.getOneOf()); + } + + private static Schema nullableWrapper(Schema schema) { + final var anyOf = new ArrayList(); + schema.setNullable(null); + anyOf.add(schema); + anyOf.add(new Schema<>().type(NULL_TYPE)); + return new Schema<>().anyOf(anyOf); + } + + private static boolean hasNullSchema(List schemas) { + return schemas != null && schemas.stream().anyMatch(OpenApiSchemaNormalizer::isNullOnlySchema); + } + + private static List withoutNullSchema(List schemas) { + if (schemas == null) { + return null; + } + schemas.removeIf(OpenApiSchemaNormalizer::isNullOnlySchema); + return schemas.isEmpty() ? null : schemas; + } + + private static boolean isNullOnlySchema(Schema schema) { + if (schema == null) { + return false; + } + if (NULL_TYPE.equals(schema.getType())) { + return true; + } + final var types = schema.getTypes(); + return types != null && types.size() == 1 && types.contains(NULL_TYPE); + } + + private static Schema unwrapSingleAnyOfOrOneOf(Schema schema) { + if (schema.getAnyOf() != null && schema.getAnyOf().size() == 1 && schema.getOneOf() == null) { + return schema.getAnyOf().get(0); + } + if (schema.getOneOf() != null && schema.getOneOf().size() == 1 && schema.getAnyOf() == null) { + return schema.getOneOf().get(0); + } + return schema; + } +} diff --git a/openapi-core/src/main/java/module-info.java b/openapi-core/src/main/java/module-info.java new file mode 100644 index 000000000..8546346df --- /dev/null +++ b/openapi-core/src/main/java/module-info.java @@ -0,0 +1,6 @@ +module io.avaje.http.openapi { + + exports io.avaje.http.openapi; + + requires io.swagger.v3.oas.models; +} diff --git a/openapi-core/src/test/java/io/avaje/http/openapi/OpenApiSchemaNormalizerTest.java b/openapi-core/src/test/java/io/avaje/http/openapi/OpenApiSchemaNormalizerTest.java new file mode 100644 index 000000000..215689039 --- /dev/null +++ b/openapi-core/src/test/java/io/avaje/http/openapi/OpenApiSchemaNormalizerTest.java @@ -0,0 +1,91 @@ +package io.avaje.http.openapi; + +import static org.assertj.core.api.Assertions.assertThat; + +import io.swagger.v3.oas.models.media.NumberSchema; +import io.swagger.v3.oas.models.media.Schema; +import io.swagger.v3.oas.models.media.StringSchema; +import java.math.BigDecimal; +import java.util.LinkedHashSet; +import java.util.List; +import org.junit.jupiter.api.Test; + +class OpenApiSchemaNormalizerTest { + + @Test + void nullableScalarUsesTypeUnion() { + final var schema = OpenApiSchemaNormalizer.nullable(new StringSchema()); + + assertThat(schema.getType()).isNull(); + assertThat(schema.getTypes()).containsExactly("string", "null"); + assertThat(schema.getNullable()).isNull(); + } + + @Test + void nullableReferenceUsesAnyOfWrapper() { + final var reference = new Schema<>().$ref("#/components/schemas/Foo").nullable(true); + + final var schema = OpenApiSchemaNormalizer.nullable(reference); + + assertThat(schema).isNotSameAs(reference); + assertThat(reference.getNullable()).isNull(); + assertThat(schema.getAnyOf()).hasSize(2); + assertThat(schema.getAnyOf().get(0).get$ref()).isEqualTo("#/components/schemas/Foo"); + assertThat(schema.getAnyOf().get(1).getType()).isEqualTo("null"); + } + + @Test + void nullableWrapperIsIdempotent() { + final var reference = new Schema<>().$ref("#/components/schemas/Foo"); + + final var schema = OpenApiSchemaNormalizer.nullable(OpenApiSchemaNormalizer.nullable(reference)); + + assertThat(schema.getAnyOf()).hasSize(2); + assertThat(schema.getAnyOf().get(0).get$ref()).isEqualTo("#/components/schemas/Foo"); + assertThat(schema.getAnyOf().get(1).getType()).isEqualTo("null"); + } + + @Test + void notNullableRemovesNullFromTypeUnion() { + final var schema = new Schema<>().types(new LinkedHashSet<>(List.of("string", "null"))); + + final var result = OpenApiSchemaNormalizer.notNullable(schema); + + assertThat(result).isSameAs(schema); + assertThat(schema.getType()).isNull(); + assertThat(schema.getTypes()).containsExactly("string"); + assertThat(schema.getNullable()).isNull(); + } + + @Test + void notNullableUnwrapsNullableReferenceWrapper() { + final var reference = new Schema<>().$ref("#/components/schemas/Foo"); + final var wrapper = OpenApiSchemaNormalizer.nullable(reference); + + final var schema = OpenApiSchemaNormalizer.notNullable(wrapper); + + assertThat(schema.get$ref()).isEqualTo("#/components/schemas/Foo"); + } + + @Test + void legacyExclusiveBoundsConvertToNumericBounds() { + final var schema = new NumberSchema() + .minimum(new BigDecimal("1.5")) + .exclusiveMinimum(true) + .maximum(new BigDecimal("9.5")) + .exclusiveMaximum(true); + + assertThat(OpenApiSchemaNormalizer.exclusiveMinimumValue(schema)).isEqualByComparingTo("1.5"); + assertThat(OpenApiSchemaNormalizer.exclusiveMaximumValue(schema)).isEqualByComparingTo("9.5"); + assertThat(OpenApiSchemaNormalizer.omitMinimum(schema)).isTrue(); + assertThat(OpenApiSchemaNormalizer.omitMaximum(schema)).isTrue(); + } + + @Test + void normalizeTypesKeepsConcreteTypeBeforeNull() { + final var types = OpenApiSchemaNormalizer.normalizeTypes( + "string", new LinkedHashSet<>(List.of("null")), true); + + assertThat(types).containsExactly("string", "null"); + } +} diff --git a/openapi-maven-plugin/pom.xml b/openapi-maven-plugin/pom.xml index b3cbb361e..7a3f27aa5 100644 --- a/openapi-maven-plugin/pom.xml +++ b/openapi-maven-plugin/pom.xml @@ -31,6 +31,12 @@ + + io.avaje + avaje-http-openapi-core + ${project.version} + + org.apache.maven maven-plugin-api diff --git a/openapi-maven-plugin/src/main/java/io/avaje/http/maven/openapi/OpenAPIMergerUtil.java b/openapi-maven-plugin/src/main/java/io/avaje/http/maven/openapi/OpenAPIMergerUtil.java index 44d1688cc..536173b43 100644 --- a/openapi-maven-plugin/src/main/java/io/avaje/http/maven/openapi/OpenAPIMergerUtil.java +++ b/openapi-maven-plugin/src/main/java/io/avaje/http/maven/openapi/OpenAPIMergerUtil.java @@ -1,6 +1,6 @@ package io.avaje.http.maven.openapi; -import io.avaje.http.maven.openapi.jsonb.SchemaCustomAdaptor; +import io.avaje.http.openapi.OpenApiSchemaNormalizer; import io.avaje.jsonb.Json; import io.swagger.v3.oas.models.Components; import io.swagger.v3.oas.models.ExternalDocumentation; @@ -23,7 +23,6 @@ import java.util.ArrayList; import java.util.HashMap; -import java.util.LinkedHashSet; import java.util.List; import java.util.Map; import java.util.Set; @@ -164,28 +163,9 @@ private static SpecVersion preferredSpecVersion(final SpecVersion primary, final } private static String preferredSchemaType(final Schema primary, final Schema secondary) { - final var explicitType = firstNotBlank(primary.getType(), secondary.getType()); - if (explicitType != null) { - return explicitType; - } - final var primaryType = firstSchemaType(primary.getTypes()); - final var secondaryType = firstSchemaType(secondary.getTypes()); - return firstNotBlank(primaryType, secondaryType); - } - - private static String firstSchemaType(final Set types) { - if (types == null || types.isEmpty()) { - return null; - } - if (types.size() == 1) { - return types.iterator().next(); - } - for (final String candidate : types) { - if (!"null".equals(candidate)) { - return candidate; - } - } - return null; + return firstNotBlank( + OpenApiSchemaNormalizer.firstNonNullType(primary), + OpenApiSchemaNormalizer.firstNonNullType(secondary)); } private static Set mergedSchemaTypes( @@ -193,52 +173,21 @@ private static Set mergedSchemaTypes( final Schema secondary, final String preferredType) { - final boolean hasTypeSet = - (primary.getTypes() != null && !primary.getTypes().isEmpty()) - || (secondary.getTypes() != null && !secondary.getTypes().isEmpty()); - final Boolean nullable = firstNonNull(primary.getNullable(), secondary.getNullable()); - if (!hasTypeSet && !Boolean.TRUE.equals(nullable)) { - return null; - } - - final LinkedHashSet merged = new LinkedHashSet<>(); - if (preferredType != null && !preferredType.isBlank() && !"null".equals(preferredType)) { - merged.add(preferredType); - } - addSchemaTypes(merged, primary.getTypes()); - addSchemaTypes(merged, secondary.getTypes()); - addSchemaType(merged, primary.getType()); - addSchemaType(merged, secondary.getType()); - - boolean includeNull = - (primary.getTypes() != null && primary.getTypes().contains("null")) - || (secondary.getTypes() != null && secondary.getTypes().contains("null")); - if (Boolean.TRUE.equals(nullable)) { - includeNull = true; - } else if (Boolean.FALSE.equals(nullable)) { - includeNull = false; + if (OpenApiSchemaNormalizer.hasTypeInformation(primary)) { + return schemaTypesForMerge(primary, preferredType); } - if (includeNull) { - merged.add("null"); - } - - return merged.isEmpty() ? null : merged; - } - - private static void addSchemaTypes(final Set target, final Set source) { - if (source == null || source.isEmpty()) { - return; - } - for (final String candidate : source) { - addSchemaType(target, candidate); + if (OpenApiSchemaNormalizer.hasTypeInformation(secondary)) { + return schemaTypesForMerge(secondary, preferredType); } + return null; } - private static void addSchemaType(final Set target, final String candidate) { - if (candidate == null || candidate.isBlank() || "null".equals(candidate)) { - return; + private static Set schemaTypesForMerge(final Schema schema, final String preferredType) { + final Set types = OpenApiSchemaNormalizer.effectiveTypesForMerge(schema); + if (types != null && types.stream().anyMatch(type -> !"null".equals(type))) { + return types; } - target.add(candidate); + return OpenApiSchemaNormalizer.normalizeTypes(preferredType, types, schema.getNullable()); } private static Map merge(final Map primary, final Map secondary) { @@ -465,16 +414,24 @@ private static Schema merge(final Schema primary, final Schema secondary) { final String type = preferredSchemaType(primary, secondary); final Set types = mergedSchemaTypes(primary, secondary, type); final String format = firstNotBlank(primary.getFormat(), secondary.getFormat()); - return SchemaCustomAdaptor.createSchemaFromInformation(type, format) + final var exclusiveMaximumValue = firstNonNull( + OpenApiSchemaNormalizer.exclusiveMaximumValue(primary), + OpenApiSchemaNormalizer.exclusiveMaximumValue(secondary)); + final var exclusiveMinimumValue = firstNonNull( + OpenApiSchemaNormalizer.exclusiveMinimumValue(primary), + OpenApiSchemaNormalizer.exclusiveMinimumValue(secondary)); + final var maximum = omitMaximum(primary, secondary) ? null : firstNonNull(primary.getMaximum(), secondary.getMaximum()); + final var minimum = omitMinimum(primary, secondary) ? null : firstNonNull(primary.getMinimum(), secondary.getMinimum()); + return OpenApiSchemaNormalizer.createSchemaFromInformation(type, format) .type(type) .format(format) .name(firstNotBlank(primary.getName(), secondary.getName())) .title(firstNotBlank(primary.getTitle(), secondary.getTitle())) .multipleOf(firstNonNull(primary.getMultipleOf(), secondary.getMultipleOf())) - .maximum(firstNonNull(primary.getMaximum(), secondary.getMaximum())) - .exclusiveMaximum(firstNonNull(primary.getExclusiveMaximum(), secondary.getExclusiveMaximum())) - .minimum(firstNonNull(primary.getMinimum(), secondary.getMinimum())) - .exclusiveMinimum(firstNonNull(primary.getExclusiveMinimum(), secondary.getExclusiveMinimum())) + .maximum(maximum) + .exclusiveMaximum(null) + .minimum(minimum) + .exclusiveMinimum(null) .maxLength(firstNonNull(primary.getMaxLength(), secondary.getMaxLength())) .minLength(firstNonNull(primary.getMinLength(), secondary.getMinLength())) .pattern(firstNotBlank(primary.getPattern(), secondary.getPattern())) @@ -504,8 +461,8 @@ private static Schema merge(final Schema primary, final Schema secondary) { .items(merge(primary.getItems(), secondary.getItems())) .types(types) .patternProperties(merge(primary.getPatternProperties(), secondary.getPatternProperties(), OpenAPIMergerUtil::merge, Schema.class)) - .exclusiveMaximumValue(firstNonNull(primary.getExclusiveMaximumValue(), secondary.getExclusiveMaximumValue())) - .exclusiveMinimumValue(firstNonNull(primary.getExclusiveMinimumValue(), secondary.getExclusiveMinimumValue())) + .exclusiveMaximumValue(exclusiveMaximumValue) + .exclusiveMinimumValue(exclusiveMinimumValue) .contains(merge(primary.getContains(), secondary.getContains())) .$id(firstNotBlank(primary.get$id(), secondary.get$id())) .$schema(firstNotBlank(primary.get$schema(), secondary.get$schema())) @@ -537,6 +494,20 @@ private static Schema merge(final Schema primary, final Schema secondary) { } } + private static boolean omitMaximum(final Schema primary, final Schema secondary) { + if (OpenApiSchemaNormalizer.omitMaximum(primary)) { + return true; + } + return primary.getMaximum() == null && OpenApiSchemaNormalizer.omitMaximum(secondary); + } + + private static boolean omitMinimum(final Schema primary, final Schema secondary) { + if (OpenApiSchemaNormalizer.omitMinimum(primary)) { + return true; + } + return primary.getMinimum() == null && OpenApiSchemaNormalizer.omitMinimum(secondary); + } + private static Tag merge(final Tag primary, final Tag secondary) { if (secondary == null) { return primary; diff --git a/openapi-maven-plugin/src/main/java/io/avaje/http/maven/openapi/jsonb/SchemaCustomAdaptor.java b/openapi-maven-plugin/src/main/java/io/avaje/http/maven/openapi/jsonb/SchemaCustomAdaptor.java index 32b39a9e5..907ea5e99 100644 --- a/openapi-maven-plugin/src/main/java/io/avaje/http/maven/openapi/jsonb/SchemaCustomAdaptor.java +++ b/openapi-maven-plugin/src/main/java/io/avaje/http/maven/openapi/jsonb/SchemaCustomAdaptor.java @@ -7,6 +7,7 @@ import io.avaje.jsonb.CustomAdapter; import io.avaje.jsonb.Jsonb; import io.avaje.jsonb.Types; +import io.avaje.http.openapi.OpenApiSchemaNormalizer; import io.swagger.v3.oas.models.ExternalDocumentation; import io.swagger.v3.oas.models.media.ArbitrarySchema; import io.swagger.v3.oas.models.media.ArraySchema; @@ -32,7 +33,6 @@ import java.util.ArrayList; import java.util.Date; import java.util.HashMap; -import java.util.LinkedHashSet; import java.util.List; import java.util.Map; import java.util.Set; @@ -228,13 +228,31 @@ public Schema fromJson(JsonReader reader) { maximum = bigDecimalJsonAdapter.fromJson(reader); break; case "exclusiveMaximum": - exclusiveMaximum = booleanJsonAdapter.fromJson(reader); + switch (reader.currentToken()) { + case NUMBER: + exclusiveMaximumValue = bigDecimalJsonAdapter.fromJson(reader); + break; + case BOOLEAN: + exclusiveMaximum = booleanJsonAdapter.fromJson(reader); + break; + default: + reader.skipValue(); + } break; case "minimum": minimum = bigDecimalJsonAdapter.fromJson(reader); break; case "exclusiveMinimum": - exclusiveMinimum = booleanJsonAdapter.fromJson(reader); + switch (reader.currentToken()) { + case NUMBER: + exclusiveMinimumValue = bigDecimalJsonAdapter.fromJson(reader); + break; + case BOOLEAN: + exclusiveMinimum = booleanJsonAdapter.fromJson(reader); + break; + default: + reader.skipValue(); + } break; case "maxLength": maxLength = integerJsonAdapter.fromJson(reader); @@ -431,8 +449,9 @@ public Schema fromJson(JsonReader reader) { } } reader.endObject(); - final Set normalizedTypes = normalizeTypes(type, types, nullable); - final Schema schema = createSchemaFromInformation(schemaTypeForCreation(type, normalizedTypes), format); + final Set normalizedTypes = OpenApiSchemaNormalizer.normalizeTypes(type, types, nullable); + final Schema schema = (Schema) OpenApiSchemaNormalizer.createSchemaFromInformation( + OpenApiSchemaNormalizer.schemaTypeForCreation(type, normalizedTypes), format); schema.name(name) .title(title) .multipleOf(multipleOf) @@ -503,108 +522,7 @@ public Schema fromJson(JsonReader reader) { } public static Schema createSchemaFromInformation(final String type, final String format) { - if (type == null) { - return new ArbitrarySchema(); - } - switch (type) { - case "array": - return new ArraySchema().format(format); - case "boolean": - return new BooleanSchema().format(format); - case "integer": - return new IntegerSchema().format(format); - case "object": - return new ObjectSchema().format(format); - case "number": - return new NumberSchema().format(format); - case "string": - if (format == null) { - return new StringSchema(); - } - switch (format) { - case "binary": - return new BinarySchema(); - case "byte": - return new ByteArraySchema(); - case "date": - return new DateSchema(); - case "date-time": - return new DateTimeSchema(); - case "email": - return new EmailSchema(); - case "password": - return new PasswordSchema(); - case "uuid": - return new UUIDSchema(); - default: - return new StringSchema().format(format); - } - default: - return new ArbitrarySchema().format(format).type(type); - } - } - - private static String schemaTypeForCreation(final String type, final Set types) { - if (type != null && !type.isBlank()) { - return type; - } - if (types == null || types.isEmpty()) { - return null; - } - if (types.size() == 1) { - return types.iterator().next(); - } - for (final String candidate : types) { - if (!"null".equals(candidate)) { - return candidate; - } - } - return null; - } - - private static Set normalizeTypes( - final String type, - final Set types, - final Boolean nullable) { - - final boolean hasTypeSet = types != null && !types.isEmpty(); - final boolean hasNullableUnion = Boolean.TRUE.equals(nullable); - if (!hasTypeSet && !hasNullableUnion) { - return null; - } - - final LinkedHashSet normalized = new LinkedHashSet<>(); - if (hasTypeSet) { - for (final String candidate : types) { - if (candidate != null && !candidate.isBlank() && !"null".equals(candidate)) { - normalized.add(candidate); - } - } - } - if (type != null && !type.isBlank() && !"null".equals(type)) { - normalized.add(type); - } - - boolean includeNull = false; - if (hasTypeSet && types.contains("null")) { - includeNull = true; - } - if (Boolean.TRUE.equals(nullable)) { - includeNull = true; - } else if (Boolean.FALSE.equals(nullable)) { - includeNull = false; - } - if (includeNull) { - normalized.add("null"); - } - return normalized.isEmpty() ? null : normalized; - } - - private static String scalarSchemaType(final Set normalizedTypes) { - if (normalizedTypes == null || normalizedTypes.size() != 1) { - return null; - } - return normalizedTypes.iterator().next(); + return (Schema) OpenApiSchemaNormalizer.createSchemaFromInformation(type, format); } private Schema readSelf(JsonReader reader) { @@ -651,8 +569,8 @@ private List readListSelf(JsonReader reader) { private void toJsonImpl(JsonWriter writer, Schema value) { writer.name(0); - final Set serializedTypes = normalizeTypes(value.getType(), value.getTypes(), value.getNullable()); - final String scalarType = scalarSchemaType(serializedTypes); + final Set serializedTypes = OpenApiSchemaNormalizer.normalizeTypes(value.getType(), value.getTypes(), value.getNullable()); + final String scalarType = OpenApiSchemaNormalizer.scalarSchemaType(serializedTypes); if (scalarType != null) { stringJsonAdapter.toJson(writer, scalarType); } else if (serializedTypes != null && !serializedTypes.isEmpty()) { @@ -669,13 +587,21 @@ private void toJsonImpl(JsonWriter writer, Schema value) { writer.name(4); bigDecimalJsonAdapter.toJson(writer, value.getMultipleOf()); writer.name(5); - bigDecimalJsonAdapter.toJson(writer, value.getMaximum()); + if (OpenApiSchemaNormalizer.omitMaximum(value)) { + writer.nullValue(); + } else { + bigDecimalJsonAdapter.toJson(writer, value.getMaximum()); + } writer.name(6); - booleanJsonAdapter.toJson(writer, value.getExclusiveMaximum()); + bigDecimalJsonAdapter.toJson(writer, OpenApiSchemaNormalizer.exclusiveMaximumValue(value)); writer.name(7); - bigDecimalJsonAdapter.toJson(writer, value.getMinimum()); + if (OpenApiSchemaNormalizer.omitMinimum(value)) { + writer.nullValue(); + } else { + bigDecimalJsonAdapter.toJson(writer, value.getMinimum()); + } writer.name(8); - booleanJsonAdapter.toJson(writer, value.getExclusiveMinimum()); + bigDecimalJsonAdapter.toJson(writer, OpenApiSchemaNormalizer.exclusiveMinimumValue(value)); writer.name(9); integerJsonAdapter.toJson(writer, value.getMaxLength()); writer.name(10); @@ -752,9 +678,9 @@ private void toJsonImpl(JsonWriter writer, Schema value) { writer.name(39); writeMapSelf(value.getPatternProperties(), writer); writer.name(40); - bigDecimalJsonAdapter.toJson(writer, value.getExclusiveMaximumValue()); + writer.nullValue(); writer.name(41); - bigDecimalJsonAdapter.toJson(writer, value.getExclusiveMinimumValue()); + writer.nullValue(); writer.name(42); writeSelfNullSafe(value.getContains(), writer); writer.name(43); diff --git a/openapi-maven-plugin/src/test/java/io/avaje/http/maven/openapi/OpenAPIMergerUtilTest.java b/openapi-maven-plugin/src/test/java/io/avaje/http/maven/openapi/OpenAPIMergerUtilTest.java index d1d6ec3bd..888c47bb4 100644 --- a/openapi-maven-plugin/src/test/java/io/avaje/http/maven/openapi/OpenAPIMergerUtilTest.java +++ b/openapi-maven-plugin/src/test/java/io/avaje/http/maven/openapi/OpenAPIMergerUtilTest.java @@ -4,8 +4,10 @@ import io.swagger.v3.oas.models.Components; import io.swagger.v3.oas.models.OpenAPI; +import io.swagger.v3.oas.models.media.NumberSchema; import io.swagger.v3.oas.models.media.Schema; import io.swagger.v3.oas.models.media.StringSchema; +import java.math.BigDecimal; import java.util.LinkedHashSet; import java.util.List; import org.junit.jupiter.api.Test; @@ -34,7 +36,7 @@ void mergeSchemaConvertsNullableToTypeUnion() { } @Test - void mergeSchemaDropsNullFromTypeUnionWhenNullableFalse() { + void mergeSchemaKeepsPrimaryNullabilityWhenSecondaryNullableFalse() { final Schema primarySchema = new StringSchema().types(new LinkedHashSet<>(List.of("string", "null"))); final Schema secondarySchema = new StringSchema().nullable(false); @@ -51,6 +53,27 @@ void mergeSchemaDropsNullFromTypeUnionWhenNullableFalse() { final OpenAPI merged = OpenAPIMergerUtil.merge(primary, secondary); final Schema schema = merged.getComponents().getSchemas().get("Thing"); + assertThat(schema.getNullable()).isNull(); + assertThat(schema.getTypes()).containsExactly("string", "null"); + } + + @Test + void mergeSchemaUsesSecondaryNullabilityWhenPrimaryHasNoTypeInformation() { + final Schema primarySchema = new Schema<>(); + final Schema secondarySchema = new StringSchema().nullable(false); + + final OpenAPI primary = + new OpenAPI() + .openapi("3.1.2") + .components(new Components().addSchemas("Thing", primarySchema)); + final OpenAPI secondary = + new OpenAPI() + .openapi("3.1.2") + .components(new Components().addSchemas("Thing", secondarySchema)); + + final OpenAPI merged = OpenAPIMergerUtil.merge(primary, secondary); + final Schema schema = merged.getComponents().getSchemas().get("Thing"); + assertThat(schema.getNullable()).isNull(); assertThat(schema.getTypes()).containsExactly("string"); } @@ -66,4 +89,31 @@ void mergeOpenApiVersionPrefersHighestMinor() { assertThat(merged.getOpenapi()).isEqualTo("3.2.0"); } + + @Test + void mergeSchemaConvertsExclusiveBoundsToNumericBounds() { + final Schema primarySchema = + new NumberSchema().minimum(new BigDecimal("1.5")).exclusiveMinimum(true); + final Schema secondarySchema = + new NumberSchema().maximum(new BigDecimal("9.5")).exclusiveMaximum(true); + + final OpenAPI primary = + new OpenAPI() + .openapi("3.1.2") + .components(new Components().addSchemas("Thing", primarySchema)); + final OpenAPI secondary = + new OpenAPI() + .openapi("3.1.2") + .components(new Components().addSchemas("Thing", secondarySchema)); + + final OpenAPI merged = OpenAPIMergerUtil.merge(primary, secondary); + final Schema schema = merged.getComponents().getSchemas().get("Thing"); + + assertThat(schema.getMinimum()).isNull(); + assertThat(schema.getMaximum()).isNull(); + assertThat(schema.getExclusiveMinimum()).isNull(); + assertThat(schema.getExclusiveMaximum()).isNull(); + assertThat(schema.getExclusiveMinimumValue()).isEqualByComparingTo("1.5"); + assertThat(schema.getExclusiveMaximumValue()).isEqualByComparingTo("9.5"); + } } diff --git a/openapi-maven-plugin/src/test/java/io/avaje/http/maven/openapi/jsonb/SchemaCustomAdaptorTest.java b/openapi-maven-plugin/src/test/java/io/avaje/http/maven/openapi/jsonb/SchemaCustomAdaptorTest.java index 5cb6530f4..661ab863a 100644 --- a/openapi-maven-plugin/src/test/java/io/avaje/http/maven/openapi/jsonb/SchemaCustomAdaptorTest.java +++ b/openapi-maven-plugin/src/test/java/io/avaje/http/maven/openapi/jsonb/SchemaCustomAdaptorTest.java @@ -75,4 +75,46 @@ void parseNullableFalseRemovesNullFromTypeUnion() { assertThat(json).contains("\"type\":\"string\""); assertThat(json).doesNotContain("\"nullable\""); } + + @Test + void parseAndWriteNumericExclusiveBounds() { + final Jsonb jsonb = + Jsonb.builder().serializeEmpty(true).serializeNulls(false).failOnUnknown(false).build(); + final JsonType schemaType = jsonb.type(Schema.class); + + final Schema schema = + schemaType.fromJson("{\"type\":\"number\",\"exclusiveMinimum\":1.5,\"exclusiveMaximum\":9.5}"); + + assertThat(schema.getExclusiveMinimum()).isNull(); + assertThat(schema.getExclusiveMaximum()).isNull(); + assertThat(schema.getExclusiveMinimumValue()).isEqualByComparingTo("1.5"); + assertThat(schema.getExclusiveMaximumValue()).isEqualByComparingTo("9.5"); + + final String json = schemaType.toJson(schema); + assertThat(json).contains("\"exclusiveMinimum\":1.5"); + assertThat(json).contains("\"exclusiveMaximum\":9.5"); + assertThat(json).doesNotContain("\"exclusiveMinimumValue\""); + assertThat(json).doesNotContain("\"exclusiveMaximumValue\""); + } + + @Test + void parseLegacyBooleanExclusiveBoundsWritesNumericBounds() { + final Jsonb jsonb = + Jsonb.builder().serializeEmpty(true).serializeNulls(false).failOnUnknown(false).build(); + final JsonType schemaType = jsonb.type(Schema.class); + + final Schema schema = + schemaType.fromJson("{\"type\":\"integer\",\"minimum\":5,\"exclusiveMinimum\":true,\"maximum\":10,\"exclusiveMaximum\":true}"); + + assertThat(schema.getExclusiveMinimum()).isTrue(); + assertThat(schema.getExclusiveMaximum()).isTrue(); + + final String json = schemaType.toJson(schema); + assertThat(json).contains("\"exclusiveMinimum\":5"); + assertThat(json).contains("\"exclusiveMaximum\":10"); + assertThat(json).doesNotContain("\"minimum\":5"); + assertThat(json).doesNotContain("\"maximum\":10"); + assertThat(json).doesNotContain("\"exclusiveMinimumValue\""); + assertThat(json).doesNotContain("\"exclusiveMaximumValue\""); + } } diff --git a/pom.xml b/pom.xml index 6043ff62a..b7f567fc5 100644 --- a/pom.xml +++ b/pom.xml @@ -38,6 +38,7 @@ + openapi-core openapi-maven-plugin htmx-api http-api diff --git a/tests/test-javalin/src/main/resources/public/openapi.json b/tests/test-javalin/src/main/resources/public/openapi.json index dd589614a..c35f91efc 100644 --- a/tests/test-javalin/src/main/resources/public/openapi.json +++ b/tests/test-javalin/src/main/resources/public/openapi.json @@ -981,7 +981,10 @@ "type" : "string" }, "string2" : { - "type" : "string" + "type" : [ + "string", + "null" + ] }, "stringArray1" : { "items" : { @@ -991,7 +994,10 @@ }, "stringArray2" : { "items" : { - "type" : "string" + "type" : [ + "string", + "null" + ] }, "type" : "array" }, @@ -1003,7 +1009,10 @@ }, "set2" : { "items" : { - "type" : "string" + "type" : [ + "string", + "null" + ] }, "type" : "array" }, @@ -1011,7 +1020,10 @@ "items" : { "type" : "string" }, - "type" : "array" + "type" : [ + "array", + "null" + ] }, "map1" : { "additionalProperties" : { @@ -1027,7 +1039,10 @@ }, "map3" : { "additionalProperties" : { - "type" : "string" + "type" : [ + "string", + "null" + ] }, "type" : "object" }, @@ -1035,7 +1050,10 @@ "additionalProperties" : { "type" : "string" }, - "type" : "object" + "type" : [ + "object", + "null" + ] } }, "type" : "object" @@ -1049,7 +1067,10 @@ "type" : "string" }, "nullable" : { - "type" : "string" + "type" : [ + "string", + "null" + ] } }, "type" : "object" diff --git a/tests/test-javalin/src/test/java/org/example/myapp/NullMarkedControllerTest.java b/tests/test-javalin/src/test/java/org/example/myapp/NullMarkedControllerTest.java index 1a9b852bd..b5751c266 100644 --- a/tests/test-javalin/src/test/java/org/example/myapp/NullMarkedControllerTest.java +++ b/tests/test-javalin/src/test/java/org/example/myapp/NullMarkedControllerTest.java @@ -7,6 +7,9 @@ import java.io.IOException; import java.io.InputStream; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; import static org.junit.jupiter.api.Assertions.assertEquals; @@ -22,20 +25,30 @@ public void testClass() throws IOException { JsonNode nullMarkedClassDTO = schemas.get("NullMarkedClassDTO"); assertEquals("[\"map1\",\"map2\",\"map3\",\"set1\",\"set2\",\"string1\",\"stringArray1\",\"stringArray2\"]", nullMarkedClassDTO.get("required").toString()); - assertEquals("{\"type\":\"string\"}", nullMarkedClassDTO.get("properties").get("string1").toString()); - assertEquals("{\"type\":\"string\"}", nullMarkedClassDTO.get("properties").get("string2").toString()); + JsonNode properties = nullMarkedClassDTO.get("properties"); + assertType(properties.get("string1"), "string"); + assertType(properties.get("string2"), "string", "null"); - assertEquals("{\"items\":{\"type\":\"string\"},\"type\":\"array\"}", nullMarkedClassDTO.get("properties").get("stringArray1").toString()); - assertEquals("{\"items\":{\"type\":\"string\"},\"type\":\"array\"}", nullMarkedClassDTO.get("properties").get("stringArray2").toString()); + assertType(properties.get("stringArray1"), "array"); + assertType(properties.get("stringArray1").get("items"), "string"); + assertType(properties.get("stringArray2"), "array"); + assertType(properties.get("stringArray2").get("items"), "string", "null"); - assertEquals("{\"items\":{\"type\":\"string\"},\"type\":\"array\"}", nullMarkedClassDTO.get("properties").get("set1").toString()); - assertEquals("{\"items\":{\"type\":\"string\"},\"type\":\"array\"}", nullMarkedClassDTO.get("properties").get("set2").toString()); - assertEquals("{\"items\":{\"type\":\"string\"},\"type\":\"array\"}", nullMarkedClassDTO.get("properties").get("set3").toString()); + assertType(properties.get("set1"), "array"); + assertType(properties.get("set1").get("items"), "string"); + assertType(properties.get("set2"), "array"); + assertType(properties.get("set2").get("items"), "string", "null"); + assertType(properties.get("set3"), "array", "null"); + assertType(properties.get("set3").get("items"), "string"); - assertEquals("{\"additionalProperties\":{\"type\":\"string\"},\"type\":\"object\"}", nullMarkedClassDTO.get("properties").get("map1").toString()); - assertEquals("{\"additionalProperties\":{\"type\":\"string\"},\"type\":\"object\"}", nullMarkedClassDTO.get("properties").get("map2").toString()); - assertEquals("{\"additionalProperties\":{\"type\":\"string\"},\"type\":\"object\"}", nullMarkedClassDTO.get("properties").get("map3").toString()); - assertEquals("{\"additionalProperties\":{\"type\":\"string\"},\"type\":\"object\"}", nullMarkedClassDTO.get("properties").get("map4").toString()); + assertType(properties.get("map1"), "object"); + assertType(properties.get("map1").get("additionalProperties"), "string"); + assertType(properties.get("map2"), "object"); + assertType(properties.get("map2").get("additionalProperties"), "string"); + assertType(properties.get("map3"), "object"); + assertType(properties.get("map3").get("additionalProperties"), "string", "null"); + assertType(properties.get("map4"), "object", "null"); + assertType(properties.get("map4").get("additionalProperties"), "string"); } } @@ -49,8 +62,19 @@ public void testRecord() throws IOException { JsonNode nullMarkedRecordDTO = schemas.get("NullMarkedRecordDTO"); assertEquals("[\"notNullable\"]", nullMarkedRecordDTO.get("required").toString()); - assertEquals("{\"type\":\"string\"}", nullMarkedRecordDTO.get("properties").get("notNullable").toString()); - assertEquals("{\"type\":\"string\"}", nullMarkedRecordDTO.get("properties").get("nullable").toString()); + assertType(nullMarkedRecordDTO.get("properties").get("notNullable"), "string"); + assertType(nullMarkedRecordDTO.get("properties").get("nullable"), "string", "null"); } } + + private static void assertType(JsonNode schema, String... expectedTypes) { + JsonNode type = schema.get("type"); + if (expectedTypes.length == 1) { + assertEquals(expectedTypes[0], type.asText()); + return; + } + List actualTypes = new ArrayList<>(); + type.forEach(typeNode -> actualTypes.add(typeNode.asText())); + assertEquals(Arrays.asList(expectedTypes), actualTypes); + } } From 7ce5cd4ed9f32b0697b609196382e88bb5f04e88 Mon Sep 17 00:00:00 2001 From: Harry Chan <38070640+re-thc@users.noreply.github.com> Date: Fri, 8 May 2026 23:42:00 +0800 Subject: [PATCH 8/8] Fix OpenAPI schema normalization CI build --- http-generator-client/pom.xml | 4 ++++ openapi-core/pom.xml | 2 +- tests/test-jex/src/main/resources/public/openapi.json | 7 +++---- 3 files changed, 8 insertions(+), 5 deletions(-) diff --git a/http-generator-client/pom.xml b/http-generator-client/pom.xml index 38aa5f7a9..6fbec804c 100644 --- a/http-generator-client/pom.xml +++ b/http-generator-client/pom.xml @@ -63,6 +63,10 @@ io.avaje.http.generator.core io.avaje.http.client.generator.core + + io.avaje.http.openapi + io.avaje.http.client.generator.openapi + io.swagger.v3 io.avaje.http.client.generator.swagger.v3 diff --git a/openapi-core/pom.xml b/openapi-core/pom.xml index 2dbd1f2b9..a6814448a 100644 --- a/openapi-core/pom.xml +++ b/openapi-core/pom.xml @@ -4,7 +4,7 @@ io.avaje avaje-http-parent - 3.9-RC2 + 3.9-RC3 avaje-http-openapi-core diff --git a/tests/test-jex/src/main/resources/public/openapi.json b/tests/test-jex/src/main/resources/public/openapi.json index 3b95d1cc0..1e06c4753 100644 --- a/tests/test-jex/src/main/resources/public/openapi.json +++ b/tests/test-jex/src/main/resources/public/openapi.json @@ -1625,11 +1625,10 @@ "content" : { "application/json" : { "schema" : { - "type" : "array", "items" : { - "type" : "string", - "nullable" : false - } + "type" : "string" + }, + "type" : "array" } } },