diff --git a/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/ParserContext.java b/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/ParserContext.java index 811b9d5394..212d187772 100644 --- a/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/ParserContext.java +++ b/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/ParserContext.java @@ -274,6 +274,7 @@ public Schema schema(Class type) { new SchemaRef( resolvedSchema.schema, RefUtils.constructRef(resolvedSchema.schema.getName())); schemas.put(type.getName(), schemaRef); + stabilizeProperties(type, resolvedSchema.schema); document(type, resolvedSchema.schema, resolvedSchema); if (resolvedSchema.referencedSchemas != null) { for (var e : resolvedSchema.referencedSchemas.entrySet()) { @@ -286,6 +287,7 @@ public Schema schema(Class type) { for (var e : resolvedSchema.referencedSchemasByType.entrySet()) { var qualifiedTypeName = toClass(e.getKey()); if (qualifiedTypeName instanceof Class classType) { + stabilizeProperties(classType, e.getValue()); document(classType, e.getValue(), resolvedSchema); } } @@ -304,6 +306,14 @@ private java.lang.reflect.Type toClass(java.lang.reflect.Type type) { return type; } + private void stabilizeProperties(Class type, Schema schema) { + if (schema == null || schema.getProperties() == null || schema.getProperties().isEmpty()) { + return; + } + var node = classNodeOrNull(Type.getType(type)); + SchemaPropertyOrder.stabilize(node, schema, this::classNodeOrNull); + } + private void document(Class typeName, Schema schema, ResolvedSchemaExt resolvedSchema) { javadocParser .parse(typeName.getName()) diff --git a/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/SchemaPropertyOrder.java b/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/SchemaPropertyOrder.java new file mode 100644 index 0000000000..dcf605cce2 --- /dev/null +++ b/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/SchemaPropertyOrder.java @@ -0,0 +1,146 @@ +/* + * Jooby https://jooby.io + * Apache License Version 2.0 https://jooby.io/LICENSE.txt + * Copyright 2014 Edgar Espina + */ +package io.jooby.internal.openapi; + +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.function.Function; + +import org.objectweb.asm.Opcodes; +import org.objectweb.asm.Type; +import org.objectweb.asm.tree.ClassNode; +import org.objectweb.asm.tree.FieldNode; +import org.objectweb.asm.tree.MethodNode; + +import io.swagger.v3.oas.models.media.Schema; + +/** + * Makes {@link Schema#getProperties()} order deterministic by reordering keys according to + * class-file declaration order (fields, then getters), including the superclass chain. + * + *

This preserves a natural bean-like order + */ +public final class SchemaPropertyOrder { + + private SchemaPropertyOrder() {} + + public static void stabilize( + ClassNode node, Schema schema, Function classNodes) { + Map properties = schema.getProperties(); + if (properties == null || properties.isEmpty() || node == null) { + return; + } + List declarationOrder = declarationOrder(node, classNodes); + if (declarationOrder.isEmpty()) { + return; + } + + var ordered = new LinkedHashMap(); + for (String name : declarationOrder) { + Schema property = properties.get(name); + if (property != null) { + ordered.put(name, property); + } + } + // Keep any leftover properties (e.g. synthetic names) in their original relative order. + properties.forEach(ordered::putIfAbsent); + schema.setProperties(ordered); + } + + static List declarationOrder(ClassNode node, Function classNodes) { + var names = new LinkedHashSet(); + collect(node, classNodes, names); + return new ArrayList<>(names); + } + + private static void collect( + ClassNode node, Function classNodes, Set names) { + if (node == null || isExcluded(node.name)) { + return; + } + if (node.superName != null && !isExcluded(node.superName)) { + collect(classNodes.apply(Type.getObjectType(node.superName)), classNodes, names); + } + if (node.fields != null) { + for (FieldNode field : node.fields) { + if (isInstanceField(field)) { + names.add(field.name); + } + } + } + if (node.methods != null) { + for (MethodNode method : node.methods) { + if (isGetter(method)) { + names.add(propertyName(method.name)); + } + } + } + } + + private static boolean isExcluded(String internalName) { + return internalName == null + || internalName.equals("java/lang/Object") + || internalName.equals("java/lang/Record") + || internalName.equals("java/lang/Enum"); + } + + private static boolean isInstanceField(FieldNode field) { + return (field.access & Opcodes.ACC_STATIC) == 0 + && (field.access & Opcodes.ACC_SYNTHETIC) == 0; + } + + private static boolean isGetter(MethodNode method) { + if ((method.access & Opcodes.ACC_STATIC) != 0 + || (method.access & Opcodes.ACC_PUBLIC) == 0 + || (method.access & Opcodes.ACC_SYNTHETIC) != 0 + || (method.access & Opcodes.ACC_BRIDGE) != 0) { + return false; + } + if (Type.getArgumentTypes(method.desc).length != 0) { + return false; + } + Type returnType = Type.getReturnType(method.desc); + if (returnType.equals(Type.VOID_TYPE)) { + return false; + } + if (method.name.startsWith("get") && method.name.length() > 3) { + return true; + } + return method.name.startsWith("is") + && method.name.length() > 2 + && (returnType.equals(Type.BOOLEAN_TYPE) + || returnType.getClassName().equals(Boolean.class.getName())); + } + + private static String propertyName(String methodName) { + if (methodName.startsWith("get")) { + return decapitalize(methodName.substring(3)); + } + if (methodName.startsWith("is")) { + return decapitalize(methodName.substring(2)); + } + return methodName; + } + + /** Same rules as {@code java.beans.Introspector.decapitalize}. */ + private static String decapitalize(String name) { + if (name == null || name.isEmpty()) { + return name; + } + if (name.length() > 1 + && Character.isUpperCase(name.charAt(0)) + && Character.isUpperCase(name.charAt(1))) { + return name; + } + char[] chars = name.toCharArray(); + chars[0] = Character.toLowerCase(chars[0]); + return new String(chars); + } +} diff --git a/modules/jooby-openapi/src/test/java/examples/MediaTypeSchemaApp.java b/modules/jooby-openapi/src/test/java/examples/MediaTypeSchemaApp.java new file mode 100644 index 0000000000..305f7bfe65 --- /dev/null +++ b/modules/jooby-openapi/src/test/java/examples/MediaTypeSchemaApp.java @@ -0,0 +1,20 @@ +/* + * Jooby https://jooby.io + * Apache License Version 2.0 https://jooby.io/LICENSE.txt + * Copyright 2014 Edgar Espina + */ +package examples; + +import io.jooby.Context; +import io.jooby.Jooby; +import io.jooby.MediaType; + +public class MediaTypeSchemaApp extends Jooby { + { + get("/media-type", this::mediaType); + } + + public MediaType mediaType(Context ctx) { + return MediaType.json; + } +} diff --git a/modules/jooby-openapi/src/test/java/io/jooby/openapi/OpenAPIGeneratorTest.java b/modules/jooby-openapi/src/test/java/io/jooby/openapi/OpenAPIGeneratorTest.java index 27037da65c..b4887f4a8f 100644 --- a/modules/jooby-openapi/src/test/java/io/jooby/openapi/OpenAPIGeneratorTest.java +++ b/modules/jooby-openapi/src/test/java/io/jooby/openapi/OpenAPIGeneratorTest.java @@ -11,13 +11,16 @@ import static org.junit.jupiter.api.Assertions.assertNull; import static org.junit.jupiter.api.Assertions.assertTrue; +import java.util.ArrayList; import java.util.Arrays; +import java.util.List; import java.util.Map; import java.util.concurrent.Callable; import com.fasterxml.jackson.databind.JavaType; import examples.ABean; import examples.Letter; +import examples.MediaTypeSchemaApp; import examples.MvcApp; import examples.MvcAppWithRoutes; import examples.MvcInstanceApp; @@ -33,6 +36,7 @@ import examples.RouteQueryArgs; import examples.RouteReturnTypeApp; import examples.RouterProduceConsume; +import io.jooby.internal.openapi.OpenAPIExt; import io.jooby.internal.openapi.RequestBodyExt; import io.swagger.v3.oas.models.media.ArraySchema; import io.swagger.v3.oas.models.media.BooleanSchema; @@ -1636,4 +1640,27 @@ public void ktAppWithMain(RouteIterator iterator) { }) .verify(); } + + /** + * Check that the schema property order is stable across repeated OpenAPI + * generations. + * + *

For example, the static field {@code MediaType.json} and boolean getter {@code isJson()} + * resolve to the same OpenAPI property name {@code json}. Without stabilizing property order from + * class-file declaration order, {@code json} (and other boolean properties like {@code textual}) + * can jump between builds. + */ + @SuppressWarnings({"unchecked", "rawtypes"}) + @OpenAPITest(MediaTypeSchemaApp.class) + public void mediaTypePropertiesOrderIsReproducible(OpenAPIExt openApi) { + var mediaType = openApi.getComponents().getSchemas().get("MediaType"); + + assertNotNull(mediaType); + + // Declaration order: instance fields (charset, value), then getters (quality, textual, json, + // type, subtype). Static MediaType.json must not displace isJson()'s property. + assertEquals( + List.of("charset", "value", "quality", "textual", "json", "type", "subtype"), + new ArrayList<>(mediaType.getProperties().keySet())); + } }