From c3b0ce2ca27a02e60b51a504f8012fc88daef51c Mon Sep 17 00:00:00 2001 From: dashevchenko Date: Fri, 13 Mar 2026 16:33:35 +0200 Subject: [PATCH 1/2] fixed openapi schema ordering --- .../server/config/SwaggerConfiguration.java | 136 ++++++++---------- .../controller/TwoFactorAuthController.java | 2 + .../common/data/widget/WidgetTypeDetails.java | 2 +- 3 files changed, 62 insertions(+), 78 deletions(-) diff --git a/application/src/main/java/org/thingsboard/server/config/SwaggerConfiguration.java b/application/src/main/java/org/thingsboard/server/config/SwaggerConfiguration.java index db862e2a43..dee30e5e09 100644 --- a/application/src/main/java/org/thingsboard/server/config/SwaggerConfiguration.java +++ b/application/src/main/java/org/thingsboard/server/config/SwaggerConfiguration.java @@ -83,6 +83,7 @@ import java.util.Map; import java.util.Objects; import java.util.Set; import java.util.TreeMap; +import java.util.concurrent.ConcurrentHashMap; import java.util.stream.Collectors; import static org.springframework.http.MediaType.APPLICATION_JSON_VALUE; @@ -110,6 +111,11 @@ public class SwaggerConfiguration { private static final ApiResponses defaultErrorResponses = defaultErrorResponses(false); private static final ApiResponses defaultPostErrorResponses = defaultErrorResponses(true); + // Populated by mapAwareConverter, consumed by customOpenApiCustomizer. + // Keyed by Class.getSimpleName() which matches swagger-core's default schema naming. + private final Map> schemaPropertyOrders = new ConcurrentHashMap<>(); + private final Map> schemaOwnProps = new ConcurrentHashMap<>(); + @Value("${swagger.api_path:/api/**}") private String apiPath; @Value("${swagger.security_path_regex}") @@ -312,62 +318,20 @@ public class SwaggerConfiguration { schema.setProperties(null); } } - } 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 current = schema.getProperties(); - var reordered = new LinkedHashMap(); - 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); - } - // 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 ownProps = computeOwnPropNames(cls, beanDesc); - if (!ownProps.isEmpty()) { - schema.addExtension("x-tb-own-props", List.copyOf(ownProps)); - } - @SuppressWarnings("unchecked") - List allOfList = schema.getAllOf(); - for (Schema allOfElement : allOfList) { - if (allOfElement.get$ref() != null) continue; - @SuppressWarnings("unchecked") - Map inlineProps = allOfElement.getProperties(); - if (inlineProps == null || inlineProps.isEmpty()) continue; - var reordered = new LinkedHashMap(); - 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 e) { - log.debug("Failed to resolve property order for {}: {}", cls.getName(), e.getMessage()); - // Fallback: at minimum sort alphabetically for determinism - if (hasProps) { - schema.setProperties(new LinkedHashMap<>(new TreeMap<>(schema.getProperties()))); - } + } else { + // Precompute property order and own-prop names for this class. + // The actual reordering happens later in the OpenApiCustomizer, + // which has access to the final state of all component schemas + // (including ones where the ModelConverter only sees a $ref). + try { + var beanDesc = Json.mapper().getSerializationConfig().introspect(javaType); + schemaPropertyOrders.put(cls.getSimpleName(), resolvePropertyOrder(cls, beanDesc)); + Set ownProps = computeOwnPropNames(cls, beanDesc); + if (!ownProps.isEmpty()) { + schemaOwnProps.put(cls.getSimpleName(), ownProps); } + } catch (Exception e) { + log.debug("Failed to resolve property order for {}: {}", cls.getName(), e.getMessage()); } } } @@ -454,16 +418,18 @@ public class SwaggerConfiguration { }); // Deduplicate allOf child schemas: remove properties that are already defined - // in the referenced parent schema to avoid duplication (e.g. EntityId children), - // then clean up the internal marker extension used during deduplication. - schemas.values().forEach(schema -> { - deduplicateAllOfProperties(schema, schemas); - if (schema.getExtensions() != null) { - schema.getExtensions().remove("x-tb-own-props"); - if (schema.getExtensions().isEmpty()) { - schema.setExtensions(null); - } - } + // in the referenced parent schema to avoid duplication (e.g. EntityId children). + schemas.forEach((schemaName, schema) -> { + Set ownProps = schemaOwnProps.getOrDefault(schemaName, Set.of()); + deduplicateAllOfProperties(schema, schemas, ownProps); + }); + + // Reorder properties for all component schemas. This runs after all + // schemas are finalized so it covers schemas the ModelConverter only + // saw as a $ref (e.g. interface-based discriminator types like EntityId). + schemas.forEach((schemaName, schema) -> { + List propOrder = schemaPropertyOrders.getOrDefault(schemaName, List.of()); + reorderSchemaProperties(schema, propOrder); }); // Fix polymorphic request/response bodies: replace inline oneOf with base type $ref @@ -771,22 +737,11 @@ public class SwaggerConfiguration { } @SuppressWarnings("unchecked") - private void deduplicateAllOfProperties(Schema schema, Map allSchemas) { + private void deduplicateAllOfProperties(Schema schema, Map allSchemas, Set ownProps) { if (schema.getAllOf() == null) { 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 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, recursively // walking the ancestor chain (each parent may itself use allOf to extend a grandparent). Set parentProperties = new LinkedHashSet<>(); @@ -856,6 +811,33 @@ public class SwaggerConfiguration { return own; } + @SuppressWarnings("unchecked") + private static void reorderSchemaProperties(Schema schema, List propOrder) { + if (schema.getProperties() != null && schema.getProperties().size() > 1) { + schema.setProperties(reorderProperties(schema.getProperties(), propOrder)); + } + if (schema.getAllOf() != null) { + for (Schema allOfElement : schema.getAllOf()) { + if (allOfElement.get$ref() != null) continue; + if (allOfElement.getProperties() != null && allOfElement.getProperties().size() > 1) { + allOfElement.setProperties(reorderProperties(allOfElement.getProperties(), propOrder)); + } + } + } + } + + private static LinkedHashMap reorderProperties(Map current, List propOrder) { + var reordered = new LinkedHashMap(); + for (String name : propOrder) { + Schema prop = current.get(name); + if (prop != null) reordered.put(name, prop); + } + // Any properties not covered by propOrder are appended + // alphabetically to guarantee a deterministic stable order. + new TreeMap<>(current).forEach((k, v) -> reordered.putIfAbsent(k, v)); + return reordered; + } + /** * Resolves the property ordering for a schema class. * diff --git a/application/src/main/java/org/thingsboard/server/controller/TwoFactorAuthController.java b/application/src/main/java/org/thingsboard/server/controller/TwoFactorAuthController.java index 269e02818a..8007a77cb1 100644 --- a/application/src/main/java/org/thingsboard/server/controller/TwoFactorAuthController.java +++ b/application/src/main/java/org/thingsboard/server/controller/TwoFactorAuthController.java @@ -15,6 +15,7 @@ */ package org.thingsboard.server.controller; +import com.fasterxml.jackson.annotation.JsonProperty; import jakarta.servlet.http.HttpServletRequest; import lombok.AllArgsConstructor; import lombok.Builder; @@ -166,6 +167,7 @@ public class TwoFactorAuthController extends BaseController { @Builder public static class TwoFaProviderInfo { private TwoFaProviderType type; + @JsonProperty("default") private boolean isDefault; private String contact; private Integer minVerificationCodeSendPeriod; diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/widget/WidgetTypeDetails.java b/common/data/src/main/java/org/thingsboard/server/common/data/widget/WidgetTypeDetails.java index 398f4b6929..1cbaa957f0 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/widget/WidgetTypeDetails.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/widget/WidgetTypeDetails.java @@ -33,7 +33,7 @@ import java.util.List; @Data @EqualsAndHashCode(callSuper = true) -@JsonPropertyOrder({"fqn", "name", "deprecated", "image", "description", "descriptor", "externalId", "resources"}) +@JsonPropertyOrder({"id", "createdTime", "tenantId", "fqn", "name", "deprecated", "scada", "version", "descriptor", "image", "description", "tags", "externalId", "resources"}) public class WidgetTypeDetails extends WidgetType implements HasName, HasTenantId, HasImage, ExportableEntity { @Schema(description = "Relative or external image URL. Replaced with image data URL (Base64) in case of relative URL and 'inlineImages' option enabled.") From f2a0d53118da47ef9e899653568fd7aae80cb03f Mon Sep 17 00:00:00 2001 From: dashevchenko Date: Mon, 16 Mar 2026 11:18:40 +0200 Subject: [PATCH 2/2] fixed schema ordering for generic classes --- .../server/config/SwaggerConfiguration.java | 27 ++++++++++++++++--- 1 file changed, 24 insertions(+), 3 deletions(-) diff --git a/application/src/main/java/org/thingsboard/server/config/SwaggerConfiguration.java b/application/src/main/java/org/thingsboard/server/config/SwaggerConfiguration.java index dee30e5e09..d1c036d38e 100644 --- a/application/src/main/java/org/thingsboard/server/config/SwaggerConfiguration.java +++ b/application/src/main/java/org/thingsboard/server/config/SwaggerConfiguration.java @@ -112,7 +112,7 @@ public class SwaggerConfiguration { private static final ApiResponses defaultPostErrorResponses = defaultErrorResponses(true); // Populated by mapAwareConverter, consumed by customOpenApiCustomizer. - // Keyed by Class.getSimpleName() which matches swagger-core's default schema naming. + // Keyed by the schema name that swagger-core generates (see resolveSchemaName). private final Map> schemaPropertyOrders = new ConcurrentHashMap<>(); private final Map> schemaOwnProps = new ConcurrentHashMap<>(); @@ -325,10 +325,11 @@ public class SwaggerConfiguration { // (including ones where the ModelConverter only sees a $ref). try { var beanDesc = Json.mapper().getSerializationConfig().introspect(javaType); - schemaPropertyOrders.put(cls.getSimpleName(), resolvePropertyOrder(cls, beanDesc)); + String schemaName = resolveSchemaName(javaType); + schemaPropertyOrders.put(schemaName, resolvePropertyOrder(cls, beanDesc)); Set ownProps = computeOwnPropNames(cls, beanDesc); if (!ownProps.isEmpty()) { - schemaOwnProps.put(cls.getSimpleName(), ownProps); + schemaOwnProps.put(schemaName, ownProps); } } catch (Exception e) { log.debug("Failed to resolve property order for {}: {}", cls.getName(), e.getMessage()); @@ -790,6 +791,26 @@ public class SwaggerConfiguration { } } + /** + * Computes the schema name that swagger-core will use for the given JavaType. + * For simple types, this is just the class simple name (e.g. {@code Device}). + * For parameterized types, type parameter names are appended + * (e.g. {@code PageData} becomes {@code PageDataDevice}). + * This matches the naming convention used by swagger-core's {@code TypeNameResolver}. + */ + private static String resolveSchemaName(JavaType javaType) { + StringBuilder sb = new StringBuilder(javaType.getRawClass().getSimpleName()); + if (javaType.hasGenericTypes()) { + for (int i = 0; i < javaType.containedTypeCount(); i++) { + JavaType param = javaType.containedType(i); + if (param != null) { + sb.append(param.getRawClass().getSimpleName()); + } + } + } + return sb.toString(); + } + /** * 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