Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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()) {
Expand All @@ -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);
}
}
Expand All @@ -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())
Expand Down
Original file line number Diff line number Diff line change
@@ -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.
*
* <p>This preserves a natural bean-like order
*/
public final class SchemaPropertyOrder {

private SchemaPropertyOrder() {}

public static void stabilize(
ClassNode node, Schema<?> schema, Function<Type, ClassNode> classNodes) {
Map<String, Schema> properties = schema.getProperties();
if (properties == null || properties.isEmpty() || node == null) {
return;
}
List<String> declarationOrder = declarationOrder(node, classNodes);
if (declarationOrder.isEmpty()) {
return;
}

var ordered = new LinkedHashMap<String, Schema>();
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<String> declarationOrder(ClassNode node, Function<Type, ClassNode> classNodes) {
var names = new LinkedHashSet<String>();
collect(node, classNodes, names);
return new ArrayList<>(names);
}

private static void collect(
ClassNode node, Function<Type, ClassNode> classNodes, Set<String> 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);
}
}
Original file line number Diff line number Diff line change
@@ -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;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;
Expand Down Expand Up @@ -1636,4 +1640,27 @@ public void ktAppWithMain(RouteIterator iterator) {
})
.verify();
}

/**
* Check that the schema property order is stable across repeated OpenAPI
* generations.
*
* <p>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()));
}
}
Loading