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()));
+ }
}