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.Objects;
import java.util.Set; import java.util.Set;
import java.util.TreeMap; import java.util.TreeMap;
import java.util.TreeSet;
import java.util.stream.Collectors; import java.util.stream.Collectors;
import static org.springframework.http.MediaType.APPLICATION_JSON_VALUE; import static org.springframework.http.MediaType.APPLICATION_JSON_VALUE;
@ -305,23 +306,58 @@ public class SwaggerConfiguration {
schema.setProperties(null); schema.setProperties(null);
} }
} }
} else if (schema != null && schema.getProperties() != null && !schema.getProperties().isEmpty()) { } else if (schema != null) {
try { boolean hasAllOf = schema.getAllOf() != null;
var beanDesc = Json.mapper().getSerializationConfig().introspect(javaType); boolean hasProps = schema.getProperties() != null && !schema.getProperties().isEmpty();
var orderedNames = resolvePropertyOrder(cls, beanDesc); if (hasAllOf || hasProps) {
if (!orderedNames.isEmpty()) { try {
@SuppressWarnings("unchecked") var beanDesc = Json.mapper().getSerializationConfig().introspect(javaType);
Map<String, Schema> current = schema.getProperties(); var orderedNames = resolvePropertyOrder(cls, beanDesc);
var reordered = new LinkedHashMap<String, Schema>(); // Reorder top-level properties if present.
for (String name : orderedNames) { // When orderedNames is empty (e.g. for interfaces where Jackson
Schema prop = current.get(name); // returns no properties from beanDesc), fall through to the
if (prop != null) reordered.put(name, prop); // 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)); // Also reorder properties inside allOf inline elements, and mark
schema.setProperties(reordered); // 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) // in the referenced parent schema to avoid duplication (e.g. EntityId children)
schemas.values().forEach(schema -> deduplicateAllOfProperties(schema, schemas)); 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 // Fix polymorphic request/response bodies: replace inline oneOf with base type $ref
paths.values().stream() paths.values().stream()
.flatMap(pathItem -> pathItem.readOperationsMap().values().stream()) .flatMap(pathItem -> pathItem.readOperationsMap().values().stream())
@ -687,6 +733,17 @@ public class SwaggerConfiguration {
return; 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 // Collect properties defined in any $ref'd parent within the allOf
Set<String> parentProperties = new LinkedHashSet<>(); Set<String> parentProperties = new LinkedHashSet<>();
for (Schema<?> allOfElement : schema.getAllOf()) { for (Schema<?> allOfElement : schema.getAllOf()) {
@ -704,45 +761,70 @@ public class SwaggerConfiguration {
return; 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 -> { schema.getAllOf().removeIf(allOfElement -> {
if (allOfElement.get$ref() != null) { if (allOfElement.get$ref() != null) {
return false; return false;
} }
if (allOfElement.getProperties() != null) { if (allOfElement.getProperties() != null) {
parentProperties.forEach(prop -> allOfElement.getProperties().remove(prop)); allOfElement.getProperties().keySet().removeAll(toStrip);
if (allOfElement.getProperties().isEmpty()) { if (allOfElement.getProperties().isEmpty()) {
allOfElement.setProperties(null); allOfElement.setProperties(null);
} }
} }
// Remove the inline element entirely if it has nothing left
return allOfElement.getProperties() == null return allOfElement.getProperties() == null
&& allOfElement.getRequired() == null && allOfElement.getRequired() == null
&& allOfElement.getType() == 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) { if (schema.getRequired() != null) {
schema.getRequired().removeAll(parentProperties); schema.getRequired().removeAll(toStrip);
if (schema.getRequired().isEmpty()) { if (schema.getRequired().isEmpty()) {
schema.setRequired(null); 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) { 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 backing field names to their JSON property names (respects @JsonProperty)
Map<String, String> fieldToJsonName = new LinkedHashMap<>(); Map<String, String> fieldToJsonName = new LinkedHashMap<>();
LinkedHashSet<String> getterOnlyNames = new LinkedHashSet<>(); LinkedHashSet<String> getterOnlyNames = new LinkedHashSet<>();
LinkedHashSet<String> writeOnlyNames = new LinkedHashSet<>();
for (var prop : beanDesc.findProperties()) { for (var prop : beanDesc.findProperties()) {
if (prop.getField() != null) { if (prop.getField() != null && prop.couldSerialize()) {
if (prop.couldSerialize()) { fieldToJsonName.put(prop.getField().getName(), prop.getName());
fieldToJsonName.put(prop.getField().getName(), prop.getName()); } else if (prop.getField() == null) {
} else {
writeOnlyNames.add(prop.getName());
}
} else {
getterOnlyNames.add(prop.getName()); getterOnlyNames.add(prop.getName());
} }
} }
@ -757,14 +839,35 @@ public class SwaggerConfiguration {
for (Field f : c.getDeclaredFields()) { for (Field f : c.getDeclaredFields()) {
if (Modifier.isStatic(f.getModifiers())) continue; if (Modifier.isStatic(f.getModifiers())) continue;
String jsonName = fieldToJsonName.get(f.getName()); 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); if (jsonName != null) ordered.add(jsonName);
} }
} }
// Append getter-only properties (no backing field) at the end // Return only field-backed properties in declaration order.
ordered.addAll(getterOnlyNames); // Getter-only properties (no backing field) are intentionally excluded: their set can vary
// Append write-only properties (e.g. deprecated setter-only fields) last // between restarts (e.g. Optional-typed getters depend on Jackson module registration order),
ordered.addAll(writeOnlyNames); // 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; return ordered;
} }

Loading…
Cancel
Save