diff --git a/README.md b/README.md index e0104c40e..d6936e371 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-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/http-generator-core/pom.xml b/http-generator-core/pom.xml index b4d05025d..4028ff4f4 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/DocContext.java b/http-generator-core/src/main/java/io/avaje/http/generator/core/openapi/DocContext.java index c82b411dd..218264c09 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 @@ -31,6 +31,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; @@ -65,6 +66,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 9536eb9c8..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 @@ -1,10 +1,14 @@ 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.avaje.http.openapi.OpenApiSchemaNormalizer; +import io.swagger.v3.oas.models.media.Schema; + final class OpenAPISerializer { private static final Set IGNORED_FIELDS = Set.of( @@ -17,7 +21,7 @@ final class OpenAPISerializer { "USE_ARBITRARY_SCHEMA_PROPERTY", "exampleSetFlag", "defaultSetFlag", - "types", + "nullable", "specVersion"); private OpenAPISerializer() {} @@ -77,6 +81,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 @@ -85,14 +92,31 @@ 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()) && OpenApiSchemaNormalizer.hasTypeSet(schema)) { + continue; + } + if (skipSchemaField(schema, field.getName())) { + continue; + } + if ("types".equals(field.getName())) { + 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) { 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; @@ -128,6 +152,32 @@ static Field[] getAllFields(Class clazz) { } } + private static boolean skipSchemaField(Schema schema, String fieldName) { + if ("exclusiveMaximum".equals(fieldName) || "exclusiveMinimum".equals(fieldName)) { + return true; + } + if ("maximum".equals(fieldName) && OpenApiSchemaNormalizer.omitMaximum(schema)) { + return true; + } + return "minimum".equals(fieldName) && OpenApiSchemaNormalizer.omitMinimum(schema); + } + + private static String jsonFieldName(Object container, String fieldName) { + if ("_enum".equals(fieldName)) { + return "enum"; + } + 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; + } + static boolean isPrimitiveWrapperType(Object value) { return value instanceof Boolean @@ -178,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 44cee7780..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,6 +4,7 @@ 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.List; @@ -139,11 +140,13 @@ 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); + if (isNullable(element)) { + schema = OpenApiSchemaNormalizer.nullable(schema); + } else if (isNotNullable(element) && !isOptionalType(element.asType())) { + schema = OpenApiSchemaNormalizer.notNullable(schema); } return schema; } @@ -151,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) { @@ -229,8 +234,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 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.setNullable(Boolean.FALSE); + 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.setNullable(Boolean.FALSE); + if (isNullable(valueType)) { + valueSchema = OpenApiSchemaNormalizer.nullable(valueSchema); + } else if (isNotNullable(valueType)) { + valueSchema = OpenApiSchemaNormalizer.notNullable(valueSchema); } } } @@ -313,15 +325,18 @@ private void populateObjectSchema(TypeMirror objectType, Schema objectSch .flatMap(SchemaPrismHelper::implementation) .map(this::toSchema) .orElseGet(() -> (Schema) toSchema(fieldType)); - if (isNotNullable(field)) { - propSchema.setNullable(Boolean.FALSE); + 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); } } @@ -377,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; } @@ -401,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 new file mode 100644 index 000000000..a2ab2da62 --- /dev/null +++ b/http-generator-core/src/test/java/io/avaje/http/generator/core/openapi/OpenAPISerializerTest.java @@ -0,0 +1,76 @@ +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.math.BigDecimal; +import java.util.LinkedHashSet; +import java.util.Map; +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\""); + } + + @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..a6814448a --- /dev/null +++ b/openapi-core/pom.xml @@ -0,0 +1,26 @@ + + + 4.0.0 + + io.avaje + avaje-http-parent + 3.9-RC3 + + + 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 f9f587d62..5119bf0d6 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 63dcd9f96..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,12 +1,13 @@ 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; 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; @@ -24,6 +25,7 @@ import java.util.HashMap; import java.util.List; import java.util.Map; +import java.util.Set; import java.util.function.BiFunction; import java.util.function.Function; @@ -71,7 +73,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 +83,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 +122,74 @@ 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) { + return firstNotBlank( + OpenApiSchemaNormalizer.firstNonNullType(primary), + OpenApiSchemaNormalizer.firstNonNullType(secondary)); + } + + private static Set mergedSchemaTypes( + final Schema primary, + final Schema secondary, + final String preferredType) { + + if (OpenApiSchemaNormalizer.hasTypeInformation(primary)) { + return schemaTypesForMerge(primary, preferredType); + } + if (OpenApiSchemaNormalizer.hasTypeInformation(secondary)) { + return schemaTypesForMerge(secondary, preferredType); + } + return null; + } + + 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; + } + return OpenApiSchemaNormalizer.normalizeTypes(preferredType, types, schema.getNullable()); + } + private static Map merge(final Map primary, final Map secondary) { if (secondary == null) { return primary; @@ -310,7 +376,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,18 +411,27 @@ 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) + 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())) - .exclusiveMaximum(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())) @@ -371,7 +446,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,10 +459,10 @@ 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())) + .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())) @@ -419,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 527725124..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; @@ -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); @@ -218,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); @@ -421,7 +449,9 @@ public Schema fromJson(JsonReader reader) { } } reader.endObject(); - final Schema schema = createSchemaFromInformation(type, 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) @@ -443,7 +473,7 @@ public Schema fromJson(JsonReader reader) { .additionalProperties(additionalProperties) .description(description) .$ref($ref) - .nullable(nullable) + .nullable(null) .readOnly(readOnly) .writeOnly(writeOnly) .externalDocs(externalDocs) @@ -456,7 +486,7 @@ public Schema fromJson(JsonReader reader) { .anyOf(anyOf) .oneOf(oneOf) .items(items) - .types(types) + .types(normalizedTypes) .patternProperties(patternProperties) .exclusiveMaximumValue(exclusiveMaximumValue) .exclusiveMinimumValue(exclusiveMinimumValue) @@ -492,45 +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); - } + return (Schema) OpenApiSchemaNormalizer.createSchemaFromInformation(type, format); } private Schema readSelf(JsonReader reader) { @@ -577,7 +569,15 @@ private List readListSelf(JsonReader reader) { private void toJsonImpl(JsonWriter writer, Schema value) { writer.name(0); - stringJsonAdapter.toJson(writer, value.getType()); + 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()) { + setStringJsonAdaptor.toJson(writer, serializedTypes); + } else { + stringJsonAdapter.toJson(writer, value.getType()); + } writer.name(1); stringJsonAdapter.toJson(writer, value.getFormat()); writer.name(2); @@ -587,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); @@ -631,7 +639,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,13 +674,13 @@ 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); - 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 new file mode 100644 index 000000000..888c47bb4 --- /dev/null +++ b/openapi-maven-plugin/src/test/java/io/avaje/http/maven/openapi/OpenAPIMergerUtilTest.java @@ -0,0 +1,119 @@ +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.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 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 mergeSchemaKeepsPrimaryNullabilityWhenSecondaryNullableFalse() { + 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", "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"); + } + + @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"); + } + + @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 new file mode 100644 index 000000000..661ab863a --- /dev/null +++ b/openapi-maven-plugin/src/test/java/io/avaje/http/maven/openapi/jsonb/SchemaCustomAdaptorTest.java @@ -0,0 +1,120 @@ +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\""); + } + + @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 1b4b1c0d4..e1a7c725b 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-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/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 ba5bc7996..06b8f2a84 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", @@ -66,8 +66,7 @@ "schema": { "type": "array", "items": { - "$ref": "#/components/schemas/Bar", - "nullable": false + "$ref": "#/components/schemas/Bar" } }, "exampleSetFlag": false @@ -89,8 +88,7 @@ "required": true, "schema": { "type": "integer", - "format": "int64", - "nullable": false + "format": "int64" } } ], @@ -123,8 +121,7 @@ "schema": { "type": "array", "items": { - "$ref": "#/components/schemas/Baz", - "nullable": false + "$ref": "#/components/schemas/Baz" } }, "exampleSetFlag": false @@ -177,8 +174,7 @@ "required": true, "schema": { "type": "integer", - "format": "int32", - "nullable": false + "format": "int32" } },{ "name": "p1", @@ -251,8 +247,7 @@ "schema": { "type": "array", "items": { - "$ref": "#/components/schemas/Baz", - "nullable": false + "$ref": "#/components/schemas/Baz" } }, "exampleSetFlag": false @@ -360,8 +355,7 @@ "schema": { "type": "array", "items": { - "$ref": "#/components/schemas/HelloDto", - "nullable": false + "$ref": "#/components/schemas/HelloDto" } }, "exampleSetFlag": false @@ -422,8 +416,7 @@ "schema": { "type": "array", "items": { - "$ref": "#/components/schemas/HelloDto", - "nullable": false + "$ref": "#/components/schemas/HelloDto" } }, "exampleSetFlag": false @@ -537,8 +530,7 @@ "type": "object", "properties": { "name": { - "type": "string", - "nullable": false + "type": "string" }, "email": { "type": "string" @@ -612,8 +604,7 @@ "type": "object", "properties": { "name": { - "type": "string", - "nullable": false + "type": "string" }, "email": { "type": "string" @@ -755,8 +746,7 @@ "required": true, "schema": { "type": "integer", - "format": "int32", - "nullable": false + "format": "int32" } },{ "name": "author", @@ -812,8 +802,7 @@ "name": "name", "in": "query", "schema": { - "type": "string", - "nullable": false + "type": "string" } },{ "name": "email", @@ -827,8 +816,7 @@ "schema": { "type": "array", "items": { - "type": "string", - "nullable": false + "type": "string" } } },{ @@ -844,7 +832,6 @@ "type": "array", "items": { "type": "string", - "nullable": false, "enum": ["PROXY","HIDE_N_SEEK","FFA" ] } @@ -878,8 +865,7 @@ "required": true, "schema": { "type": "integer", - "format": "int32", - "nullable": false + "format": "int32" } } ], @@ -903,8 +889,7 @@ "required": true, "schema": { "type": "integer", - "format": "int32", - "nullable": false + "format": "int32" } },{ "name": "date", @@ -1032,10 +1017,8 @@ "type": "object", "additionalProperties": { "type": "array", - "nullable": false, "items": { - "type": "string", - "nullable": false + "type": "string" } } }, @@ -1264,8 +1247,7 @@ "schema": { "type": "array", "items": { - "$ref": "#/components/schemas/Person", - "nullable": false + "$ref": "#/components/schemas/Person" } }, "exampleSetFlag": false @@ -1670,7 +1652,6 @@ "type": "array", "items": { "type": "string", - "nullable": false, "enum": ["PROXY","HIDE_N_SEEK","FFA" ] } @@ -1859,8 +1840,7 @@ "strings": { "type": "array", "items": { - "type": "string", - "nullable": false + "type": "string" } } } @@ -1981,8 +1961,7 @@ "application/json": { "schema": { "type": "integer", - "format": "int32", - "nullable": false + "format": "int32" }, "exampleSetFlag": false } @@ -2004,8 +1983,7 @@ "application/json": { "schema": { "type": "integer", - "format": "int64", - "nullable": false + "format": "int64" }, "exampleSetFlag": false } @@ -2079,8 +2057,7 @@ "schema": { "type": "array", "items": { - "$ref": "#/components/schemas/Person", - "nullable": false + "$ref": "#/components/schemas/Person" } }, "exampleSetFlag": false @@ -2156,8 +2133,7 @@ "schema": { "type": "array", "items": { - "$ref": "#/components/schemas/Person", - "nullable": false + "$ref": "#/components/schemas/Person" } }, "exampleSetFlag": false @@ -2190,8 +2166,7 @@ "schema": { "type": "object", "additionalProperties": { - "$ref": "#/components/schemas/Person", - "nullable": false + "$ref": "#/components/schemas/Person" } }, "exampleSetFlag": false @@ -2224,8 +2199,7 @@ "schema": { "type": "array", "items": { - "$ref": "#/components/schemas/Person", - "nullable": false + "$ref": "#/components/schemas/Person" } }, "exampleSetFlag": false @@ -2330,8 +2304,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!" @@ -2363,8 +2336,7 @@ }, "id": { "type": "integer", - "format": "int32", - "nullable": false + "format": "int32" } } }, @@ -2398,8 +2370,7 @@ }, "id": { "type": "integer", - "format": "int64", - "nullable": false + "format": "int64" } } }, @@ -2409,8 +2380,7 @@ "data": { "type": "array", "items": { - "$ref": "#/components/schemas/Bar", - "nullable": false + "$ref": "#/components/schemas/Bar" } } } @@ -2436,5 +2406,6 @@ } } }, + "jsonSchemaDialect": "https://spec.openapis.org/oas/3.1/dialect/base", "specVersion": "V30" } \ 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 721210eaf..ace3a1468 100644 --- a/tests/test-javalin-jsonb/src/test/resources/expectedOpenApi.json +++ b/tests/test-javalin-jsonb/src/test/resources/expectedOpenApi.json @@ -1,315 +1,314 @@ { - "openapi": "3.0.1", - "info": { - "title": "Example service", - "description": "Example Javalin controllers with Java and Maven", - "version": "" - }, - "servers": [ - { - "url": "localhost:8080", - "description": "local testing" - } - ], - "tags": [ - { - "name": "tag1", - "description": "this is added to openapi tags" - } - ], - "paths": { - "/openapi/delete/{type}": { - "delete": { - "tags": [], - "summary": "", - "description": "", - "parameters": [ - { - "name": "type", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - }, - { - "name": "lastName", - "in": "query", - "schema": { - "type": "string" - } - }, - { - "name": "q-2", - "in": "query", - "schema": { - "type": "string" - } - }, - { - "name": "Content-Length", - "in": "header", - "schema": { - "type": "string" - } - }, - { - "name": "x-oh", - "in": "header", - "schema": { - "type": "string" - } - } - ], - "responses": { - "200": { - "description": "", - "content": { - "application/json": { - "schema": { - "type": "string" - } - } - } - } - } - } - }, - "/openapi/form": { - "post": { - "tags": [], - "summary": "", - "description": "", - "parameters": [ - { - "name": "Head-String", - "in": "header", - "schema": { - "type": "string" - } - } - ], - "requestBody": { - "content": { - "application/x-www-form-urlencoded": { - "schema": { - "type": "object", - "properties": { - "name": { - "type": "string" - }, - "email": { - "type": "string" - }, - "url": { - "type": "string" - } - } - } - } - }, - "required": true - }, - "responses": { - "201": { - "description": "", - "content": { - "application/json": { - "schema": { - "type": "string" - } - } - } - } - } - } - }, - "/openapi/get": { - "get": { - "tags": [], - "summary": "Example of Open API Get (up to the first period is the summary)", - "description": "When using Javalin Context only This Javadoc description is added to the generated openapi.json", - "responses": { - "200": { - "description": "funny phrase (this part of the javadoc is added to the response desc)", - "content": { - "text/plain": { - "schema": { - "type": "string" - } - } - } - } - } - } - }, - "/openapi/post": { - "post": { - "tags": [ - "tag1" - ], - "summary": "Standard Post", - "description": "uses tag annotation to add tags to openapi json", - "requestBody": { - "description": "the body (this is used for generated request body desc)", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Person" - } - } - }, - "required": true - }, - "responses": { - "200": { - "description": "overrides @return javadoc description", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Person" - } - } - } - }, - "201": { - "description": "the response body (from javadoc)", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Person" - } - } - } - }, - "400": { - "description": "User not found (Will not have an associated response schema)" - }, - "500": { - "description": "Some other Error (Will have this error class as the response class)", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorResponse" - } - } - } - } - } - } - }, - "/openapi/post1": { - "post": { - "tags": [], - "summary": "Standard Post", - "description": "The Deprecated annotation adds \"deprecacted:true\" to the generated json", - "requestBody": { - "description": "the body", - "content": { - "application/json": { - "schema": { - "type": "array", - "items": { - "$ref": "#/components/schemas/Person", - "nullable": false - } - } - } - }, - "required": true - }, - "responses": { - "400": { - "description": "User not found" - }, - "500": { - "description": "Some other Error", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorResponse" - } - } - } - }, - "201": { - "description": "the response body (from javadoc)", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Person" - } - } - } - } - }, - "deprecated": true - } - }, - "/openapi/put": { - "put": { - "tags": [], - "summary": "", - "description": "", - "responses": { - "204": { - "description": "", - "content": { - "text/plain": { - "schema": { - "type": "string" - } - } - } - }, - "203": { - "description": "", - "content": { - "text/plain": { - "schema": { - "type": "string" - } - } - } - } - } - } - } - }, - "components": { - "schemas": { - "ErrorResponse": { - "type": "object", - "properties": { - "id": { - "type": "string" - }, - "text": { - "type": "string" - } - } - }, - "Person": { - "type": "object", - "properties": { - "id": { - "type": "integer", - "format": "int64", - "nullable": false - }, - "name": { - "type": "string" - } - } - } - }, - "securitySchemes": { - "JWT": { - "type": "apiKey", - "description": "JSON Web Tokens are an open, industry standard RFC 7519 method for representing claims securely between two parties.", - "name": "access_token", - "in": "query" - } - } - } -} \ No newline at end of file + "openapi": "3.1.2", + "info": { + "title": "Example service", + "description": "Example Javalin controllers with Java and Maven", + "version": "" + }, + "servers": [ + { + "url": "localhost:8080", + "description": "local testing" + } + ], + "tags": [ + { + "name": "tag1", + "description": "this is added to openapi tags" + } + ], + "paths": { + "/openapi/delete/{type}": { + "delete": { + "tags": [], + "summary": "", + "description": "", + "parameters": [ + { + "name": "type", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "lastName", + "in": "query", + "schema": { + "type": "string" + } + }, + { + "name": "q-2", + "in": "query", + "schema": { + "type": "string" + } + }, + { + "name": "Content-Length", + "in": "header", + "schema": { + "type": "string" + } + }, + { + "name": "x-oh", + "in": "header", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "type": "string" + } + } + } + } + } + } + }, + "/openapi/form": { + "post": { + "tags": [], + "summary": "", + "description": "", + "parameters": [ + { + "name": "Head-String", + "in": "header", + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/x-www-form-urlencoded": { + "schema": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "email": { + "type": "string" + }, + "url": { + "type": "string" + } + } + } + } + }, + "required": true + }, + "responses": { + "201": { + "description": "", + "content": { + "application/json": { + "schema": { + "type": "string" + } + } + } + } + } + } + }, + "/openapi/get": { + "get": { + "tags": [], + "summary": "Example of Open API Get (up to the first period is the summary)", + "description": "When using Javalin Context only This Javadoc description is added to the generated openapi.json", + "responses": { + "200": { + "description": "funny phrase (this part of the javadoc is added to the response desc)", + "content": { + "text/plain": { + "schema": { + "type": "string" + } + } + } + } + } + } + }, + "/openapi/post": { + "post": { + "tags": [ + "tag1" + ], + "summary": "Standard Post", + "description": "uses tag annotation to add tags to openapi json", + "requestBody": { + "description": "the body (this is used for generated request body desc)", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Person" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "overrides @return javadoc description", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Person" + } + } + } + }, + "201": { + "description": "the response body (from javadoc)", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Person" + } + } + } + }, + "400": { + "description": "User not found (Will not have an associated response schema)" + }, + "500": { + "description": "Some other Error (Will have this error class as the response class)", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + } + } + } + }, + "/openapi/post1": { + "post": { + "tags": [], + "summary": "Standard Post", + "description": "The Deprecated annotation adds \"deprecacted:true\" to the generated json", + "requestBody": { + "description": "the body", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Person" + } + } + } + }, + "required": true + }, + "responses": { + "201": { + "description": "the response body (from javadoc)", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Person" + } + } + } + }, + "400": { + "description": "User not found" + }, + "500": { + "description": "Some other Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + } + }, + "deprecated": true + } + }, + "/openapi/put": { + "put": { + "tags": [], + "summary": "", + "description": "", + "responses": { + "203": { + "description": "", + "content": { + "text/plain": { + "schema": { + "type": "string" + } + } + } + }, + "204": { + "description": "", + "content": { + "text/plain": { + "schema": { + "type": "string" + } + } + } + } + } + } + } + }, + "components": { + "schemas": { + "ErrorResponse": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "text": { + "type": "string" + } + } + }, + "Person": { + "type": "object", + "properties": { + "id": { + "type": "integer", + "format": "int64" + }, + "name": { + "type": "string" + } + } + } + }, + "securitySchemes": { + "JWT": { + "type": "apiKey", + "description": "JSON Web Tokens are an open, industry standard RFC 7519 method for representing claims securely between two parties.", + "name": "access_token", + "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 698092360..c35f91efc 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", @@ -56,11 +56,10 @@ "content" : { "application/json" : { "schema" : { - "type" : "array", "items" : { - "$ref" : "#/components/schemas/Bar", - "nullable" : false - } + "$ref" : "#/components/schemas/Bar" + }, + "type" : "array" } } } @@ -81,9 +80,8 @@ "in" : "path", "required" : true, "schema" : { - "type" : "integer", "format" : "int64", - "nullable" : false + "type" : "integer" } } ], @@ -114,11 +112,10 @@ "content" : { "application/json" : { "schema" : { - "type" : "array", "items" : { - "$ref" : "#/components/schemas/Baz", - "nullable" : false - } + "$ref" : "#/components/schemas/Baz" + }, + "type" : "array" } } } @@ -147,8 +144,8 @@ "content" : { "application/json" : { "schema" : { - "type" : "integer", - "format" : "int64" + "format" : "int64", + "type" : "integer" } } } @@ -169,9 +166,8 @@ "in" : "path", "required" : true, "schema" : { - "type" : "integer", "format" : "int32", - "nullable" : false + "type" : "integer" } }, { @@ -192,8 +188,8 @@ "name" : "p3", "in" : "query", "schema" : { - "type" : "integer", - "format" : "int32" + "format" : "int32", + "type" : "integer" } }, { @@ -248,11 +244,10 @@ "content" : { "application/json" : { "schema" : { - "type" : "array", "items" : { - "$ref" : "#/components/schemas/Baz", - "nullable" : false - } + "$ref" : "#/components/schemas/Baz" + }, + "type" : "array" } } } @@ -273,8 +268,8 @@ "in" : "path", "required" : true, "schema" : { - "type" : "integer", - "format" : "int64" + "format" : "int64", + "type" : "integer" } } ], @@ -336,11 +331,10 @@ "content" : { "application/json" : { "schema" : { - "type" : "array", "items" : { - "$ref" : "#/components/schemas/HelloDto", - "nullable" : false - } + "$ref" : "#/components/schemas/HelloDto" + }, + "type" : "array" } } } @@ -380,11 +374,10 @@ "content" : { "application/json" : { "schema" : { - "type" : "array", "items" : { - "$ref" : "#/components/schemas/HelloDto", - "nullable" : false - } + "$ref" : "#/components/schemas/HelloDto" + }, + "type" : "array" } } } @@ -435,7 +428,8 @@ "url" : { "type" : "string" } - } + }, + "type" : "object" } } }, @@ -498,8 +492,7 @@ "type" : "object", "properties" : { "name" : { - "type" : "string", - "nullable" : false + "type" : "string" }, "email" : { "type" : "string" @@ -508,10 +501,11 @@ "type" : "string" }, "startDate" : { - "type" : "string", - "format" : "date" + "format" : "date", + "type" : "string" } - } + }, + "type" : "object" } } }, @@ -546,7 +540,8 @@ "url" : { "type" : "string" } - } + }, + "type" : "object" } } }, @@ -573,8 +568,7 @@ "type" : "object", "properties" : { "name" : { - "type" : "string", - "nullable" : false + "type" : "string" }, "email" : { "type" : "string" @@ -583,10 +577,11 @@ "type" : "string" }, "startDate" : { - "type" : "string", - "format" : "date" + "format" : "date", + "type" : "string" } - } + }, + "type" : "object" } } }, @@ -666,9 +661,8 @@ "in" : "path", "required" : true, "schema" : { - "type" : "integer", "format" : "int32", - "nullable" : false + "type" : "integer" } }, { @@ -729,8 +723,7 @@ "name" : "name", "in" : "query", "schema" : { - "type" : "string", - "nullable" : false + "type" : "string" } }, { @@ -768,9 +761,8 @@ "in" : "path", "required" : true, "schema" : { - "type" : "integer", "format" : "int32", - "nullable" : false + "type" : "integer" } } ], @@ -796,9 +788,8 @@ "description" : "The hello Id.", "required" : true, "schema" : { - "type" : "integer", "format" : "int32", - "nullable" : false + "type" : "integer" } }, { @@ -807,8 +798,8 @@ "description" : "The name of the hello", "required" : true, "schema" : { - "type" : "string", - "format" : "date" + "format" : "date", + "type" : "string" } }, { @@ -865,8 +856,7 @@ "content" : { "application/json" : { "schema" : { - "$ref" : "#/components/schemas/NullMarkedClassDTO", - "nullable" : false + "$ref" : "#/components/schemas/NullMarkedClassDTO" } } }, @@ -925,41 +915,38 @@ "components" : { "schemas" : { "Bar" : { - "type" : "object", "properties" : { "id" : { - "type" : "integer", "format" : "int64", - "nullable" : false + "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", - "nullable" : false + "type" : "integer" }, "name" : { "type" : "string" @@ -968,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" : [ @@ -988,98 +976,106 @@ "stringArray1", "stringArray2" ], - "type" : "object", "properties" : { "string1" : { - "type" : "string", - "nullable" : false + "type" : "string" }, "string2" : { - "type" : "string" + "type" : [ + "string", + "null" + ] }, "stringArray1" : { - "type" : "array", - "nullable" : false, "items" : { "type" : "string" - } + }, + "type" : "array" }, "stringArray2" : { - "type" : "array", - "nullable" : false, "items" : { - "type" : "string" - } + "type" : [ + "string", + "null" + ] + }, + "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" : [ + "string", + "null" + ] + }, + "type" : "array" }, "set3" : { - "type" : "array", "items" : { - "type" : "string", - "nullable" : false - } + "type" : "string" + }, + "type" : [ + "array", + "null" + ] }, "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" + "type" : [ + "string", + "null" + ] }, - "nullable" : false + "type" : "object" }, "map4" : { - "type" : "object", "additionalProperties" : { - "type" : "string", - "nullable" : false - } + "type" : "string" + }, + "type" : [ + "object", + "null" + ] } - } + }, + "type" : "object" }, "NullMarkedRecordDTO" : { "required" : [ "notNullable" ], - "type" : "object", "properties" : { "notNullable" : { - "type" : "string", - "nullable" : false + "type" : "string" }, "nullable" : { - "type" : "string" + "type" : [ + "string", + "null" + ] } - } + }, + "type" : "object" } } - } + }, + "jsonSchemaDialect" : "https://spec.openapis.org/oas/3.1/dialect/base" } \ No newline at end of file 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..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\",\"nullable\":false}", 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("{\"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()); + 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("{\"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()); + 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("{\"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()); + 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\",\"nullable\":false}", 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); + } } diff --git a/tests/test-jex/src/main/resources/public/openapi.json b/tests/test-jex/src/main/resources/public/openapi.json index 77131cde2..1e06c4753 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" : "" @@ -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,9 +156,8 @@ "in" : "path", "required" : true, "schema" : { - "type" : "integer", "format" : "int64", - "nullable" : false + "type" : "integer" } } ], @@ -191,11 +188,10 @@ "content" : { "application/json" : { "schema" : { - "type" : "array", "items" : { - "$ref" : "#/components/schemas/Baz", - "nullable" : false - } + "$ref" : "#/components/schemas/Baz" + }, + "type" : "array" } } } @@ -224,8 +220,8 @@ "content" : { "application/json" : { "schema" : { - "type" : "integer", - "format" : "int64" + "format" : "int64", + "type" : "integer" } } } @@ -246,9 +242,8 @@ "in" : "path", "required" : true, "schema" : { - "type" : "integer", "format" : "int32", - "nullable" : false + "type" : "integer" } }, { @@ -269,8 +264,8 @@ "name" : "p3", "in" : "query", "schema" : { - "type" : "integer", - "format" : "int32" + "format" : "int32", + "type" : "integer" } }, { @@ -325,11 +320,10 @@ "content" : { "application/json" : { "schema" : { - "type" : "array", "items" : { - "$ref" : "#/components/schemas/Baz", - "nullable" : false - } + "$ref" : "#/components/schemas/Baz" + }, + "type" : "array" } } } @@ -350,8 +344,8 @@ "in" : "path", "required" : true, "schema" : { - "type" : "integer", - "format" : "int64" + "format" : "int64", + "type" : "integer" } } ], @@ -483,11 +477,10 @@ "content" : { "application/json" : { "schema" : { - "type" : "array", "items" : { - "$ref" : "#/components/schemas/WebHelloDto", - "nullable" : false - } + "$ref" : "#/components/schemas/WebHelloDto" + }, + "type" : "array" } } } @@ -538,7 +531,8 @@ "url" : { "type" : "string" } - } + }, + "type" : "object" } } }, @@ -601,8 +595,7 @@ "type" : "object", "properties" : { "name" : { - "type" : "string", - "nullable" : false + "type" : "string" }, "email" : { "type" : "string" @@ -611,10 +604,11 @@ "type" : "string" }, "startDate" : { - "type" : "string", - "format" : "date" + "format" : "date", + "type" : "string" } - } + }, + "type" : "object" } } }, @@ -649,7 +643,8 @@ "url" : { "type" : "string" } - } + }, + "type" : "object" } } }, @@ -676,8 +671,7 @@ "type" : "object", "properties" : { "name" : { - "type" : "string", - "nullable" : false + "type" : "string" }, "email" : { "type" : "string" @@ -686,10 +680,11 @@ "type" : "string" }, "startDate" : { - "type" : "string", - "format" : "date" + "format" : "date", + "type" : "string" } - } + }, + "type" : "object" } } }, @@ -768,12 +763,12 @@ "name" : "myEnum", "in" : "query", "schema" : { - "type" : "string", "enum" : [ "A", "B", "C" - ] + ], + "type" : "string" } } ], @@ -803,9 +798,11 @@ "name" : "myOptional", "in" : "query", "schema" : { - "type" : "integer", "format" : "int64", - "nullable" : true + "type" : [ + "integer", + "null" + ] } } ], @@ -835,12 +832,14 @@ "name" : "myOptional", "in" : "query", "schema" : { - "type" : "string", - "nullable" : true, "enum" : [ "A", "B", "C" + ], + "type" : [ + "string", + "null" ] } } @@ -871,8 +870,10 @@ "name" : "myOptional", "in" : "query", "schema" : { - "type" : "string", - "nullable" : true + "type" : [ + "string", + "null" + ] } } ], @@ -903,9 +904,8 @@ "in" : "path", "required" : true, "schema" : { - "type" : "integer", "format" : "int32", - "nullable" : false + "type" : "integer" } }, { @@ -966,8 +966,7 @@ "name" : "name", "in" : "query", "schema" : { - "type" : "string", - "nullable" : false + "type" : "string" } }, { @@ -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,9 +1036,8 @@ "in" : "path", "required" : true, "schema" : { - "type" : "integer", "format" : "int32", - "nullable" : false + "type" : "integer" } } ], @@ -1066,9 +1062,8 @@ "description" : "The hello Id.", "required" : true, "schema" : { - "type" : "integer", "format" : "int32", - "nullable" : false + "type" : "integer" } }, { @@ -1077,8 +1072,8 @@ "description" : "The name of the hello", "required" : true, "schema" : { - "type" : "string", - "format" : "date" + "format" : "date", + "type" : "string" } }, { @@ -1472,11 +1467,10 @@ "content" : { "application/json" : { "schema" : { - "type" : "array", "items" : { - "$ref" : "#/components/schemas/HelloDto", - "nullable" : false - } + "$ref" : "#/components/schemas/HelloDto" + }, + "type" : "array" } } } @@ -1517,12 +1511,12 @@ "name" : "type", "in" : "query", "schema" : { - "type" : "string", "enum" : [ "PROXY", "HIDE_N_SEEK", "FFA" - ] + ], + "type" : "string" } } ], @@ -1552,16 +1546,15 @@ "name" : "type", "in" : "query", "schema" : { - "type" : "array", "items" : { - "type" : "string", - "nullable" : false, "enum" : [ "PROXY", "HIDE_N_SEEK", "FFA" - ] - } + ], + "type" : "string" + }, + "type" : "array" } } ], @@ -1598,12 +1591,12 @@ "name" : "type", "in" : "query", "schema" : { - "type" : "string", "enum" : [ "PROXY", "HIDE_N_SEEK", "FFA" - ] + ], + "type" : "string" } } ], @@ -1632,11 +1625,10 @@ "content" : { "application/json" : { "schema" : { - "type" : "array", "items" : { - "type" : "string", - "nullable" : false - } + "type" : "string" + }, + "type" : "array" } } }, @@ -1668,11 +1660,10 @@ "name" : "strings", "in" : "query", "schema" : { - "type" : "array", "items" : { - "type" : "string", - "nullable" : false - } + "type" : "string" + }, + "type" : "array" } } ], @@ -1815,146 +1806,141 @@ "components" : { "schemas" : { "Bar" : { - "type" : "object", "properties" : { "id" : { - "type" : "integer", "format" : "int64", - "nullable" : false + "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", - "nullable" : false + "type" : "integer" }, "name" : { - "type" : "string", - "nullable" : false + "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", - "nullable" : false + "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/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 bae5ce0e2..073678b16 100644 --- a/tests/test-nima-jsonb/src/test/resources/expectedOpenApi.json +++ b/tests/test-nima-jsonb/src/test/resources/expectedOpenApi.json @@ -1,9 +1,9 @@ { - "openapi": "3.0.1", - "info": { - "title": "Example service", - "description": "Example Helidon controllers with Java and Maven", - "version": "" + "openapi" : "3.1.2", + "info" : { + "title" : "Example service", + "description" : "Example Helidon controllers with Java and Maven", + "version" : "" }, "servers": [ { @@ -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" @@ -248,5 +246,6 @@ "in": "query" } } - } -} \ No newline at end of file + }, + "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 59a801b85..08c5432eb 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", @@ -62,11 +62,10 @@ "content" : { "application/json" : { "schema" : { - "type" : "array", "items" : { - "$ref" : "#/components/schemas/Bar", - "nullable" : false - } + "$ref" : "#/components/schemas/Bar" + }, + "type" : "array" } } } @@ -87,9 +86,8 @@ "in" : "path", "required" : true, "schema" : { - "type" : "integer", "format" : "int64", - "nullable" : false + "type" : "integer" } } ], @@ -120,11 +118,10 @@ "content" : { "application/json" : { "schema" : { - "type" : "array", "items" : { - "$ref" : "#/components/schemas/Baz", - "nullable" : false - } + "$ref" : "#/components/schemas/Baz" + }, + "type" : "array" } } } @@ -153,8 +150,8 @@ "content" : { "application/json" : { "schema" : { - "type" : "integer", - "format" : "int64" + "format" : "int64", + "type" : "integer" } } } @@ -175,9 +172,8 @@ "in" : "path", "required" : true, "schema" : { - "type" : "integer", "format" : "int32", - "nullable" : false + "type" : "integer" } }, { @@ -198,8 +194,8 @@ "name" : "p3", "in" : "query", "schema" : { - "type" : "integer", - "format" : "int32" + "format" : "int32", + "type" : "integer" } }, { @@ -254,11 +250,10 @@ "content" : { "application/json" : { "schema" : { - "type" : "array", "items" : { - "$ref" : "#/components/schemas/Baz", - "nullable" : false - } + "$ref" : "#/components/schemas/Baz" + }, + "type" : "array" } } } @@ -279,8 +274,8 @@ "in" : "path", "required" : true, "schema" : { - "type" : "integer", - "format" : "int64" + "format" : "int64", + "type" : "integer" } } ], @@ -363,11 +358,10 @@ "content" : { "application/json" : { "schema" : { - "type" : "array", "items" : { - "$ref" : "#/components/schemas/HelloDto", - "nullable" : false - } + "$ref" : "#/components/schemas/HelloDto" + }, + "type" : "array" } } } @@ -427,11 +421,10 @@ "content" : { "application/json" : { "schema" : { - "type" : "array", "items" : { - "$ref" : "#/components/schemas/HelloDto", - "nullable" : false - } + "$ref" : "#/components/schemas/HelloDto" + }, + "type" : "array" } } } @@ -482,7 +475,8 @@ "url" : { "type" : "string" } - } + }, + "type" : "object" } } }, @@ -545,8 +539,7 @@ "type" : "object", "properties" : { "name" : { - "type" : "string", - "nullable" : false + "type" : "string" }, "email" : { "type" : "string" @@ -555,10 +548,11 @@ "type" : "string" }, "startDate" : { - "type" : "string", - "format" : "date" + "format" : "date", + "type" : "string" } - } + }, + "type" : "object" } } }, @@ -593,7 +587,8 @@ "url" : { "type" : "string" } - } + }, + "type" : "object" } } }, @@ -620,8 +615,7 @@ "type" : "object", "properties" : { "name" : { - "type" : "string", - "nullable" : false + "type" : "string" }, "email" : { "type" : "string" @@ -630,10 +624,11 @@ "type" : "string" }, "startDate" : { - "type" : "string", - "format" : "date" + "format" : "date", + "type" : "string" } - } + }, + "type" : "object" } } }, @@ -712,12 +707,12 @@ "name" : "myEnum", "in" : "query", "schema" : { - "type" : "string", "enum" : [ "A", "B", "C" - ] + ], + "type" : "string" } } ], @@ -748,9 +743,8 @@ "in" : "path", "required" : true, "schema" : { - "type" : "integer", "format" : "int32", - "nullable" : false + "type" : "integer" } }, { @@ -811,8 +805,7 @@ "name" : "name", "in" : "query", "schema" : { - "type" : "string", - "nullable" : false + "type" : "string" } }, { @@ -826,11 +819,10 @@ "name" : "addresses", "in" : "query", "schema" : { - "type" : "array", "items" : { - "type" : "string", - "nullable" : false - } + "type" : "string" + }, + "type" : "array" } }, { @@ -844,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" } } ], @@ -884,9 +875,8 @@ "in" : "path", "required" : true, "schema" : { - "type" : "integer", "format" : "int32", - "nullable" : false + "type" : "integer" } } ], @@ -911,9 +901,8 @@ "description" : "The hello Id.", "required" : true, "schema" : { - "type" : "integer", "format" : "int32", - "nullable" : false + "type" : "integer" } }, { @@ -922,8 +911,8 @@ "description" : "The name of the hello", "required" : true, "schema" : { - "type" : "string", - "format" : "date" + "format" : "date", + "type" : "string" } }, { @@ -1114,11 +1103,10 @@ "content" : { "application/json" : { "schema" : { - "type" : "array", "items" : { - "$ref" : "#/components/schemas/Person", - "nullable" : false - } + "$ref" : "#/components/schemas/Person" + }, + "type" : "array" } } }, @@ -1305,14 +1293,15 @@ "type" : "string" }, "type" : { - "type" : "string", "enum" : [ "PROXY", "HIDE_N_SEEK", "FFA" - ] + ], + "type" : "string" } - } + }, + "type" : "object" } } }, @@ -1342,14 +1331,15 @@ "type" : "string" }, "type" : { - "type" : "string", "enum" : [ "PROXY", "HIDE_N_SEEK", "FFA" - ] + ], + "type" : "string" } - } + }, + "type" : "object" } } }, @@ -1382,12 +1372,12 @@ "in" : "path", "required" : true, "schema" : { - "type" : "string", "enum" : [ "PROXY", "HIDE_N_SEEK", "FFA" - ] + ], + "type" : "string" } } ], @@ -1417,12 +1407,12 @@ "name" : "type", "in" : "query", "schema" : { - "type" : "string", "enum" : [ "PROXY", "HIDE_N_SEEK", "FFA" - ] + ], + "type" : "string" } } ], @@ -1452,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" } } ], @@ -1498,12 +1487,12 @@ "name" : "type", "in" : "query", "schema" : { - "type" : "string", "enum" : [ "PROXY", "HIDE_N_SEEK", "FFA" - ] + ], + "type" : "string" } } ], @@ -1543,7 +1532,8 @@ "url" : { "type" : "string" } - } + }, + "type" : "object" } } }, @@ -1585,7 +1575,8 @@ "url" : { "type" : "string" } - } + }, + "type" : "object" } } }, @@ -1619,13 +1610,13 @@ "type" : "object", "properties" : { "strings" : { - "type" : "array", "items" : { - "type" : "string", - "nullable" : false - } + "type" : "string" + }, + "type" : "array" } - } + }, + "type" : "object" } } }, @@ -1709,9 +1700,8 @@ "content" : { "application/json" : { "schema" : { - "type" : "integer", "format" : "int32", - "nullable" : false + "type" : "integer" } } } @@ -1732,9 +1722,8 @@ "content" : { "application/json" : { "schema" : { - "type" : "integer", "format" : "int64", - "nullable" : false + "type" : "integer" } } } @@ -1784,11 +1773,10 @@ "content" : { "application/json" : { "schema" : { - "type" : "array", "items" : { - "$ref" : "#/components/schemas/Person", - "nullable" : false - } + "$ref" : "#/components/schemas/Person" + }, + "type" : "array" } } }, @@ -1862,11 +1850,10 @@ "content" : { "application/json" : { "schema" : { - "type" : "array", "items" : { - "$ref" : "#/components/schemas/Person", - "nullable" : false - } + "$ref" : "#/components/schemas/Person" + }, + "type" : "array" } } } @@ -1897,11 +1884,10 @@ "content" : { "application/json" : { "schema" : { - "type" : "object", "additionalProperties" : { - "$ref" : "#/components/schemas/Person", - "nullable" : false - } + "$ref" : "#/components/schemas/Person" + }, + "type" : "object" } } } @@ -1932,11 +1918,10 @@ "content" : { "application/json" : { "schema" : { - "type" : "array", "items" : { - "$ref" : "#/components/schemas/Person", - "nullable" : false - } + "$ref" : "#/components/schemas/Person" + }, + "type" : "array" } } } @@ -2035,48 +2020,45 @@ "components" : { "schemas" : { "Bar" : { - "type" : "object", "properties" : { "id" : { - "type" : "integer", "format" : "int64", - "nullable" : false + "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" @@ -2084,75 +2066,73 @@ "text" : { "type" : "string" } - } + }, + "type" : "object" }, "HelloDto" : { - "type" : "object", "properties" : { "id" : { - "type" : "integer", "format" : "int32", - "nullable" : false + "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" : { @@ -2163,5 +2143,6 @@ "in" : "query" } } - } + }, + "jsonSchemaDialect" : "https://spec.openapis.org/oas/3.1/dialect/base" } \ No newline at end of file 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 cf4a304aa..37675c056 100644 --- a/tests/test-vertx/src/test/resources/expectedOpenApi.json +++ b/tests/test-vertx/src/test/resources/expectedOpenApi.json @@ -1,30 +1,28 @@ { - "openapi" : "3.0.1", - "info" : { - "title" : "", - "version" : "" + "openapi": "3.1.2", + "info": { + "title": "", + "version": "" }, - "servers" : [ + "servers": [ { - "url" : "localhost:8080", - "description" : "local testing" + "url": "localhost:8080", + "description": "local testing" } ], - "paths" : { - "/a" : { - "get" : { - "tags" : [ - - ], - "summary" : "", - "description" : "", - "responses" : { - "200" : { - "description" : "", - "content" : { - "application/json" : { - "schema" : { - "$ref" : "#/components/schemas/StandardRecordWithComments" + "paths": { + "/a": { + "get": { + "tags": [], + "summary": "", + "description": "", + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/StandardRecordWithComments" } } } @@ -32,22 +30,20 @@ } } }, - "/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\"}" + "/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\"}" } } } @@ -55,44 +51,40 @@ } } }, - "/c" : { - "post" : { - "tags" : [ - - ], - "summary" : "", - "description" : "", - "requestBody" : { - "content" : { - "application/text" : { - "schema" : { - "type" : "string" + "/c": { + "post": { + "tags": [], + "summary": "", + "description": "", + "requestBody": { + "content": { + "application/text": { + "schema": { + "type": "string" } } }, - "required" : true + "required": true }, - "responses" : { - "201" : { - "description" : "No content" + "responses": { + "201": { + "description": "No content" } } } }, - "/hello" : { - "get" : { - "tags" : [ - - ], - "summary" : "", - "description" : "", - "responses" : { - "200" : { - "description" : "", - "content" : { - "text/plain" : { - "schema" : { - "type" : "string" + "/hello": { + "get": { + "tags": [], + "summary": "", + "description": "", + "responses": { + "200": { + "description": "", + "content": { + "text/plain": { + "schema": { + "type": "string" } } } @@ -100,46 +92,43 @@ } } }, - "/hello/with-params/{id}" : { - "get" : { - "tags" : [ - - ], - "summary" : "", - "description" : "", - "parameters" : [ + "/hello/with-params/{id}": { + "get": { + "tags": [], + "summary": "", + "description": "", + "parameters": [ { - "name" : "id", - "in" : "path", - "required" : true, - "schema" : { - "type" : "integer", - "format" : "int64", - "nullable" : false + "name": "id", + "in": "path", + "required": true, + "schema": { + "format": "int64", + "type": "integer" } }, { - "name" : "q", - "in" : "query", - "schema" : { - "type" : "string" + "name": "q", + "in": "query", + "schema": { + "type": "string" } }, { - "name" : "X-Trace", - "in" : "header", - "schema" : { - "type" : "string" + "name": "X-Trace", + "in": "header", + "schema": { + "type": "string" } } ], - "responses" : { - "200" : { - "description" : "", - "content" : { - "text/plain" : { - "schema" : { - "type" : "string" + "responses": { + "200": { + "description": "", + "content": { + "text/plain": { + "schema": { + "type": "string" } } } @@ -147,20 +136,18 @@ } } }, - "/roles-test/blocking" : { - "get" : { - "tags" : [ - - ], - "summary" : "", - "description" : "", - "responses" : { - "200" : { - "description" : "", - "content" : { - "application/json" : { - "schema" : { - "type" : "string" + "/roles-test/blocking": { + "get": { + "tags": [], + "summary": "", + "description": "", + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "type": "string" } } } @@ -168,20 +155,18 @@ } } }, - "/roles-test/explicit" : { - "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" } } } @@ -189,20 +174,18 @@ } } }, - "/roles-test/inherited" : { - "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" } } } @@ -210,30 +193,28 @@ } } }, - "/roles-test/payload" : { - "post" : { - "tags" : [ - - ], - "summary" : "", - "description" : "", - "requestBody" : { - "content" : { - "application/json" : { - "schema" : { - "$ref" : "#/components/schemas/Payload" + "/roles-test/payload": { + "post": { + "tags": [], + "summary": "", + "description": "", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Payload" } } }, - "required" : true + "required": true }, - "responses" : { - "201" : { - "description" : "", - "content" : { - "application/json" : { - "schema" : { - "$ref" : "#/components/schemas/Payload" + "responses": { + "201": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Payload" } } } @@ -242,41 +223,41 @@ } } }, - "components" : { - "schemas" : { - "Payload" : { - "type" : "object", - "properties" : { - "name" : { - "type" : "string" + "components": { + "schemas": { + "Payload": { + "properties": { + "name": { + "type": "string" } - } + }, + "type": "object" }, - "RecordWithSchemaDescriptions" : { - "type" : "object", - "properties" : { - "item" : { - "_default" : "This is a default value", - "description" : "Overridden", + "RecordWithSchemaDescriptions": { + "properties": { + "item": { + "_default": "This is a default value", + "description": "Overridden", "type": "string" } - } + }, + "type": "object" }, - "StandardRecordWithComments" : { - "type" : "object", - "properties" : { - "blah" : { - "type" : "string", - "description" : "The first param" + "StandardRecordWithComments": { + "properties": { + "blah": { + "description": "The first param", + "type": "string" }, - "anotherBlah" : { - "type" : "integer", - "description" : "The second param", - "format" : "int32", - "nullable" : false + "anotherBlah": { + "description": "The second param", + "format": "int32", + "type": "integer" } - } + }, + "type": "object" } } - } + }, + "jsonSchemaDialect": "https://spec.openapis.org/oas/3.1/dialect/base" }