Browse Source

fixed schema fields order

pull/15196/head
dashevchenko 7 months ago
parent
commit
eaf69d1aa9
  1. 167
      application/src/main/java/org/thingsboard/server/config/SwaggerConfiguration.java

167
application/src/main/java/org/thingsboard/server/config/SwaggerConfiguration.java

@ -78,6 +78,7 @@ import java.util.Map;
import java.util.Objects;
import java.util.Set;
import java.util.TreeMap;
import java.util.TreeSet;
import java.util.stream.Collectors;
import static org.springframework.http.MediaType.APPLICATION_JSON_VALUE;
@ -305,23 +306,58 @@ public class SwaggerConfiguration {
schema.setProperties(null);
}
}
} else if (schema != null && schema.getProperties() != null && !schema.getProperties().isEmpty()) {
try {
var beanDesc = Json.mapper().getSerializationConfig().introspect(javaType);
var orderedNames = resolvePropertyOrder(cls, beanDesc);
if (!orderedNames.isEmpty()) {
@SuppressWarnings("unchecked")
Map<String, Schema> current = schema.getProperties();
var reordered = new LinkedHashMap<String, Schema>();
for (String name : orderedNames) {
Schema prop = current.get(name);
if (prop != null) reordered.put(name, prop);
} else if (schema != null) {
boolean hasAllOf = schema.getAllOf() != null;
boolean hasProps = schema.getProperties() != null && !schema.getProperties().isEmpty();
if (hasAllOf || hasProps) {
try {
var beanDesc = Json.mapper().getSerializationConfig().introspect(javaType);
var orderedNames = resolvePropertyOrder(cls, beanDesc);
// Reorder top-level properties if present.
// When orderedNames is empty (e.g. for interfaces where Jackson
// returns no properties from beanDesc), fall through to the
// TreeMap fallback which sorts remaining properties alphabetically.
if (hasProps) {
@SuppressWarnings("unchecked")
Map<String, Schema> current = schema.getProperties();
var reordered = new LinkedHashMap<String, Schema>();
for (String name : orderedNames) {
Schema prop = current.get(name);
if (prop != null) reordered.put(name, prop);
}
// Any properties not covered by orderedNames are appended
// alphabetically to guarantee a deterministic stable order.
new TreeMap<>(current).forEach((k, v) -> reordered.putIfAbsent(k, v));
schema.setProperties(reordered);
}
current.forEach((k, v) -> reordered.putIfAbsent(k, v));
schema.setProperties(reordered);
// Also reorder properties inside allOf inline elements, and mark
// which properties are declared in cls itself (not inherited from
// a superclass) so deduplicateAllOfProperties can strip inherited
// ones without touching own-class properties.
if (hasAllOf) {
Set<String> ownProps = computeOwnPropNames(cls, beanDesc);
if (!ownProps.isEmpty()) {
schema.addExtension("x-tb-own-props", List.copyOf(ownProps));
}
@SuppressWarnings("unchecked")
List<Schema> allOfList = schema.getAllOf();
for (Schema allOfElement : allOfList) {
if (allOfElement.get$ref() != null) continue;
@SuppressWarnings("unchecked")
Map<String, Schema> inlineProps = allOfElement.getProperties();
if (inlineProps == null || inlineProps.isEmpty()) continue;
var reordered = new LinkedHashMap<String, Schema>();
for (String name : orderedNames) {
Schema prop = inlineProps.get(name);
if (prop != null) reordered.put(name, prop);
}
new TreeMap<>(inlineProps).forEach((k, v) -> reordered.putIfAbsent(k, v));
allOfElement.setProperties(reordered);
}
}
} catch (Exception ignored) {
log.trace("Failed to resolve property order for {}", cls.getName(), ignored);
}
} catch (Exception ignored) {
log.trace("Failed to resolve property order for {}", cls.getName(), ignored);
}
}
}
@ -411,6 +447,16 @@ public class SwaggerConfiguration {
// in the referenced parent schema to avoid duplication (e.g. EntityId children)
schemas.values().forEach(schema -> deduplicateAllOfProperties(schema, schemas));
// Clean up internal marker extension used by deduplicateAllOfProperties
schemas.values().forEach(schema -> {
if (schema.getExtensions() != null) {
schema.getExtensions().remove("x-tb-own-props");
if (schema.getExtensions().isEmpty()) {
schema.setExtensions(null);
}
}
});
// Fix polymorphic request/response bodies: replace inline oneOf with base type $ref
paths.values().stream()
.flatMap(pathItem -> pathItem.readOperationsMap().values().stream())
@ -687,6 +733,17 @@ public class SwaggerConfiguration {
return;
}
// Properties declared in the class's own fields (not inherited from a superclass).
// These must NOT be stripped from the inline even if they also appear in a parent
// schema (e.g. a field that also has a corresponding interface getter in the parent).
Set<String> ownProps = new LinkedHashSet<>();
if (schema.getExtensions() != null
&& schema.getExtensions().get("x-tb-own-props") instanceof List<?> list) {
for (Object v : list) {
if (v instanceof String s) ownProps.add(s);
}
}
// Collect properties defined in any $ref'd parent within the allOf
Set<String> parentProperties = new LinkedHashSet<>();
for (Schema<?> allOfElement : schema.getAllOf()) {
@ -704,45 +761,70 @@ public class SwaggerConfiguration {
return;
}
// Strip those properties from inline (non-$ref) allOf elements
// Properties to strip: in parent schema AND not declared as own-class fields.
// This removes inherited properties (from superclasses or pure interface getters)
// while keeping properties the class declares as its own fields.
Set<String> toStrip = new LinkedHashSet<>(parentProperties);
toStrip.removeAll(ownProps);
if (toStrip.isEmpty()) {
return;
}
// Strip from inline (non-$ref) allOf elements
schema.getAllOf().removeIf(allOfElement -> {
if (allOfElement.get$ref() != null) {
return false;
}
if (allOfElement.getProperties() != null) {
parentProperties.forEach(prop -> allOfElement.getProperties().remove(prop));
allOfElement.getProperties().keySet().removeAll(toStrip);
if (allOfElement.getProperties().isEmpty()) {
allOfElement.setProperties(null);
}
}
// Remove the inline element entirely if it has nothing left
return allOfElement.getProperties() == null
&& allOfElement.getRequired() == null
&& allOfElement.getType() == null;
});
// Remove required entries at the schema level that are already required by the parent
// Remove stripped properties from the schema's required list
if (schema.getRequired() != null) {
schema.getRequired().removeAll(parentProperties);
schema.getRequired().removeAll(toStrip);
if (schema.getRequired().isEmpty()) {
schema.setRequired(null);
}
}
}
/**
* Returns the JSON property names that are backed by fields declared directly in {@code cls}
* (not inherited from a superclass). Used to distinguish "own" from "inherited" properties
* when deduplicating allOf inline elements.
*/
private static Set<String> computeOwnPropNames(Class<?> cls, com.fasterxml.jackson.databind.BeanDescription beanDesc) {
Map<String, String> allFieldToJson = new LinkedHashMap<>();
for (var prop : beanDesc.findProperties()) {
if (prop.getField() != null && prop.couldSerialize()) {
allFieldToJson.put(prop.getField().getName(), prop.getName());
}
}
Set<String> own = new LinkedHashSet<>();
for (Field f : cls.getDeclaredFields()) {
if (Modifier.isStatic(f.getModifiers())) continue;
String jsonName = allFieldToJson.get(f.getName());
if (jsonName != null) own.add(jsonName);
}
return own;
}
private static List<String> resolvePropertyOrder(Class<?> cls, com.fasterxml.jackson.databind.BeanDescription beanDesc) {
// Map backing field names to their JSON property names (respects @JsonProperty)
Map<String, String> fieldToJsonName = new LinkedHashMap<>();
LinkedHashSet<String> getterOnlyNames = new LinkedHashSet<>();
LinkedHashSet<String> writeOnlyNames = new LinkedHashSet<>();
for (var prop : beanDesc.findProperties()) {
if (prop.getField() != null) {
if (prop.couldSerialize()) {
fieldToJsonName.put(prop.getField().getName(), prop.getName());
} else {
writeOnlyNames.add(prop.getName());
}
} else {
if (prop.getField() != null && prop.couldSerialize()) {
fieldToJsonName.put(prop.getField().getName(), prop.getName());
} else if (prop.getField() == null) {
getterOnlyNames.add(prop.getName());
}
}
@ -757,14 +839,35 @@ public class SwaggerConfiguration {
for (Field f : c.getDeclaredFields()) {
if (Modifier.isStatic(f.getModifiers())) continue;
String jsonName = fieldToJsonName.get(f.getName());
// Handle boolean fields with "is" prefix (e.g. field "isEnabled" → JSON "enabled").
// Jackson derives the JSON property name from the public getter, not the field name.
// Depending on whether Jackson merges the field+getter or not, there are three cases:
// 1. jsonName == null: field invisible, getter-only property in getterOnlyNames
// 2. jsonName == fieldName: Jackson did NOT merge; field → "isEnabled" property,
// getter → "enabled" property separately in getterOnlyNames
// 3. jsonName == strippedName: Jackson merged field+getter → already correct
// Cases 1 and 2 both need to pull the stripped name from getterOnlyNames so that the
// declaration order is preserved instead of leaving it to the alphabetical fallback.
if (jsonName == null || jsonName.equals(f.getName())) {
String fn = f.getName();
if ((f.getType() == boolean.class || f.getType() == Boolean.class)
&& fn.startsWith("is") && fn.length() > 2 && Character.isUpperCase(fn.charAt(2))) {
String stripped = Character.toLowerCase(fn.charAt(2)) + fn.substring(3);
if (getterOnlyNames.remove(stripped)) {
jsonName = stripped;
}
}
}
if (jsonName != null) ordered.add(jsonName);
}
}
// Append getter-only properties (no backing field) at the end
ordered.addAll(getterOnlyNames);
// Append write-only properties (e.g. deprecated setter-only fields) last
ordered.addAll(writeOnlyNames);
// Return only field-backed properties in declaration order.
// Getter-only properties (no backing field) are intentionally excluded: their set can vary
// between restarts (e.g. Optional-typed getters depend on Jackson module registration order),
// so including them here would make their position non-deterministic when some are in orderedNames
// and others are only in the schema map. The converter's TreeMap fallback handles ALL
// non-field-backed properties together in one alphabetical pass, guaranteeing stable order.
return ordered;
}

Loading…
Cancel
Save