From dcaf0647fee1370a6bcacfa5d388fbe981c21149 Mon Sep 17 00:00:00 2001 From: dashevchenko Date: Mon, 27 Apr 2026 10:10:51 +0300 Subject: [PATCH 1/3] added swagger UI example for objects with discriminatorProperty --- .../server/config/SwaggerConfiguration.java | 107 ++++++++++++++++++ 1 file changed, 107 insertions(+) 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 6bdb1ae758..bd747e52ff 100644 --- a/application/src/main/java/org/thingsboard/server/config/SwaggerConfiguration.java +++ b/application/src/main/java/org/thingsboard/server/config/SwaggerConfiguration.java @@ -528,6 +528,12 @@ public class SwaggerConfiguration { reorderSchemaProperties(schema, propOrder); }); + // Synthesize a request-body example for every schema that uses a discriminator. + // Without this, Swagger UI shows only the discriminator-property field for + // polymorphic types (the parent schema doesn't know which oneOf branch to pick). + // We resolve the first declared subtype and inline its full property tree. + schemas.forEach((schemaName, schema) -> fillDiscriminatorExample(schema, schemas)); + // Fix polymorphic request/response bodies: replace inline oneOf with base type $ref paths.values().stream() .flatMap(pathItem -> pathItem.readOperationsMap().values().stream()) @@ -858,6 +864,107 @@ public class SwaggerConfiguration { } } + private static final int MAX_EXAMPLE_DEPTH = 4; + + /** + * If {@code schema} has a discriminator and no explicit example, synthesize one by + * picking the first declared subtype in the discriminator mapping and inlining its + * full property tree (own + inherited via allOf $refs). The discriminator field is + * forced to the chosen subtype's mapping value so the example is internally consistent. + */ + @SuppressWarnings("unchecked") + private void fillDiscriminatorExample(Schema schema, Map allSchemas) { + var discriminator = schema.getDiscriminator(); + if (discriminator == null || discriminator.getMapping() == null || discriminator.getMapping().isEmpty()) { + return; + } + if (schema.getExample() != null) { + return; + } + // Mapping is a LinkedHashMap → declaration order preserved, so "first" is deterministic. + var firstEntry = discriminator.getMapping().entrySet().iterator().next(); + String discriminatorValue = firstEntry.getKey(); + String subtypeRef = firstEntry.getValue(); + String subtypeName = subtypeRef.substring(subtypeRef.lastIndexOf('/') + 1); + + Map example = new LinkedHashMap<>(); + buildSchemaExample(subtypeName, allSchemas, example, new HashSet<>(), 0); + if (example.isEmpty()) { + return; + } + example.put(discriminator.getPropertyName(), discriminatorValue); + schema.setExample(example); + } + + @SuppressWarnings("unchecked") + private void buildSchemaExample(String schemaName, Map allSchemas, + Map result, Set visited, int depth) { + if (depth > MAX_EXAMPLE_DEPTH || !visited.add(schemaName)) { + return; + } + Schema schema = allSchemas.get(schemaName); + if (schema == null) { + return; + } + // Walk parents first so own properties (added later) override inherited entries. + if (schema.getAllOf() != null) { + for (Schema allOfElement : schema.getAllOf()) { + String ref = allOfElement.get$ref(); + if (ref != null) { + String refName = ref.substring(ref.lastIndexOf('/') + 1); + buildSchemaExample(refName, allSchemas, result, visited, depth); + } else if (allOfElement.getProperties() != null) { + allOfElement.getProperties().forEach((k, v) -> + result.put(k, sampleValue((Schema) v, allSchemas, visited, depth + 1))); + } + } + } + if (schema.getProperties() != null) { + schema.getProperties().forEach((k, v) -> + result.put(k, sampleValue((Schema) v, allSchemas, visited, depth + 1))); + } + } + + @SuppressWarnings("unchecked") + private Object sampleValue(Schema propSchema, Map allSchemas, + Set visited, int depth) { + if (propSchema == null) { + return null; + } + if (propSchema.getExample() != null) { + return propSchema.getExample(); + } + String ref = propSchema.get$ref(); + if (ref != null) { + String refName = ref.substring(ref.lastIndexOf('/') + 1); + Schema refSchema = allSchemas.get(refName); + if (refSchema != null && refSchema.getExample() != null) { + return refSchema.getExample(); + } + if (depth >= MAX_EXAMPLE_DEPTH) { + return Map.of(); + } + Map nested = new LinkedHashMap<>(); + buildSchemaExample(refName, allSchemas, nested, new HashSet<>(visited), depth + 1); + return nested; + } + if (propSchema.getEnum() != null && !propSchema.getEnum().isEmpty()) { + return propSchema.getEnum().get(0); + } + String type = propSchema.getType(); + if (type == null) { + return null; + } + return switch (type) { + case "string" -> "string"; + case "integer", "number" -> 0; + case "boolean" -> false; + case "array" -> List.of(); + case "object" -> Map.of(); + default -> null; + }; + } + @SuppressWarnings("unchecked") private void deduplicateAllOfProperties(Schema schema, Map allSchemas, Set ownProps) { if (schema.getAllOf() == null) { From 806f51bb1c2b4dd33e9eec74f435985ebf986b76 Mon Sep 17 00:00:00 2001 From: dashevchenko Date: Mon, 27 Apr 2026 11:38:38 +0300 Subject: [PATCH 2/3] fixed EntityId example --- .../server/config/SwaggerConfiguration.java | 74 ++++++++++++++----- 1 file changed, 56 insertions(+), 18 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 bd747e52ff..b4a4d784b3 100644 --- a/application/src/main/java/org/thingsboard/server/config/SwaggerConfiguration.java +++ b/application/src/main/java/org/thingsboard/server/config/SwaggerConfiguration.java @@ -867,10 +867,11 @@ public class SwaggerConfiguration { private static final int MAX_EXAMPLE_DEPTH = 4; /** - * If {@code schema} has a discriminator and no explicit example, synthesize one by - * picking the first declared subtype in the discriminator mapping and inlining its - * full property tree (own + inherited via allOf $refs). The discriminator field is - * forced to the chosen subtype's mapping value so the example is internally consistent. + * If {@code schema} has a discriminator, populate examples for the parent and every + * concrete subtype it maps to. Each subtype gets its own example with the discriminator + * field set to the mapping value that points at it, so fields typed as a specific + * subtype (e.g. {@code EntityView.id} → {@code EntityViewId}) resolve to a correct + * example without falling back to the parent's. */ @SuppressWarnings("unchecked") private void fillDiscriminatorExample(Schema schema, Map allSchemas) { @@ -878,22 +879,46 @@ public class SwaggerConfiguration { if (discriminator == null || discriminator.getMapping() == null || discriminator.getMapping().isEmpty()) { return; } - if (schema.getExample() != null) { - return; + // 1. Populate an example on each mapped subtype. + for (var entry : discriminator.getMapping().entrySet()) { + String discriminatorValue = entry.getKey(); + String subtypeRef = entry.getValue(); + String subtypeName = subtypeRef.substring(subtypeRef.lastIndexOf('/') + 1); + Schema subtype = allSchemas.get(subtypeName); + if (subtype == null || subtype.getExample() != null) { + continue; + } + Map example = new LinkedHashMap<>(); + buildSchemaExample(subtypeName, allSchemas, example, new HashSet<>(), 0); + if (example.isEmpty()) { + continue; + } + example.put(discriminator.getPropertyName(), discriminatorValue); + subtype.setExample(example); } - // Mapping is a LinkedHashMap → declaration order preserved, so "first" is deterministic. - var firstEntry = discriminator.getMapping().entrySet().iterator().next(); - String discriminatorValue = firstEntry.getKey(); - String subtypeRef = firstEntry.getValue(); - String subtypeName = subtypeRef.substring(subtypeRef.lastIndexOf('/') + 1); - - Map example = new LinkedHashMap<>(); - buildSchemaExample(subtypeName, allSchemas, example, new HashSet<>(), 0); - if (example.isEmpty()) { - return; + // 2. Mirror a subtype's example onto the parent so a field typed as the parent + // interface still gets a complete example. Prefer the subtype whose mapping key + // matches the example declared on the discriminator property itself + // (e.g. EntityId.getEntityType() has example = "DEVICE" → mirror DeviceId, not + // the alphabetically first AdminSettingsId). Fall back to the first mapping entry. + if (schema.getExample() == null) { + String preferredValue = null; + if (schema.getProperties() != null) { + Schema discProp = (Schema) schema.getProperties().get(discriminator.getPropertyName()); + if (discProp != null && discProp.getExample() != null) { + preferredValue = discProp.getExample().toString(); + } + } + String chosenRef = preferredValue != null ? discriminator.getMapping().get(preferredValue) : null; + if (chosenRef == null) { + chosenRef = discriminator.getMapping().values().iterator().next(); + } + String chosenSubtypeName = chosenRef.substring(chosenRef.lastIndexOf('/') + 1); + Schema chosenSubtype = allSchemas.get(chosenSubtypeName); + if (chosenSubtype != null && chosenSubtype.getExample() != null) { + schema.setExample(chosenSubtype.getExample()); + } } - example.put(discriminator.getPropertyName(), discriminatorValue); - schema.setExample(example); } @SuppressWarnings("unchecked") @@ -908,11 +933,24 @@ public class SwaggerConfiguration { } // Walk parents first so own properties (added later) override inherited entries. if (schema.getAllOf() != null) { + String selfRef = "#/components/schemas/" + schemaName; for (Schema allOfElement : schema.getAllOf()) { String ref = allOfElement.get$ref(); if (ref != null) { String refName = ref.substring(ref.lastIndexOf('/') + 1); buildSchemaExample(refName, allSchemas, result, visited, depth); + // If the parent uses a discriminator, this schema is one of its mapping + // targets — override the discriminator field with the value that points + // back at us (e.g. EntityViewId → entityType: "ENTITY_VIEW", not "ADMIN_SETTINGS"). + Schema parentSchema = allSchemas.get(refName); + if (parentSchema != null && parentSchema.getDiscriminator() != null + && parentSchema.getDiscriminator().getMapping() != null) { + parentSchema.getDiscriminator().getMapping().entrySet().stream() + .filter(e -> selfRef.equals(e.getValue())) + .map(Map.Entry::getKey) + .findFirst() + .ifPresent(value -> result.put(parentSchema.getDiscriminator().getPropertyName(), value)); + } } else if (allOfElement.getProperties() != null) { allOfElement.getProperties().forEach((k, v) -> result.put(k, sampleValue((Schema) v, allSchemas, visited, depth + 1))); From 6647bb4bfe11590d624337b2d6fc8298e78c6ebe Mon Sep 17 00:00:00 2001 From: dashevchenko Date: Mon, 27 Apr 2026 14:48:17 +0300 Subject: [PATCH 3/3] fixed addDefaultSchemas() to not drop unresolved refs. --- .../server/config/SwaggerConfiguration.java | 28 ++++++++++++++----- 1 file changed, 21 insertions(+), 7 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 b4a4d784b3..c079eec1d4 100644 --- a/application/src/main/java/org/thingsboard/server/config/SwaggerConfiguration.java +++ b/application/src/main/java/org/thingsboard/server/config/SwaggerConfiguration.java @@ -23,6 +23,7 @@ import com.fasterxml.jackson.databind.node.ObjectNode; import io.swagger.v3.core.converter.AnnotatedType; import io.swagger.v3.core.converter.ModelConverter; import io.swagger.v3.core.converter.ModelConverters; +import io.swagger.v3.core.converter.ResolvedSchema; import io.swagger.v3.core.jackson.ModelResolver; import io.swagger.v3.core.util.Json; import io.swagger.v3.oas.models.Components; @@ -373,13 +374,26 @@ public class SwaggerConfiguration { ._enum(Arrays.stream(ThingsboardErrorCode.values()) .map(ThingsboardErrorCode::getErrorCode) .collect(Collectors.toList())); - openAPI.getComponents() - .addSchemas("LoginRequest", ModelConverters.getInstance().readAllAsResolvedSchema(new AnnotatedType().type(LoginRequest.class)).schema) - .addSchemas("LoginResponse", ModelConverters.getInstance().readAllAsResolvedSchema(new AnnotatedType().type(LoginResponse.class)).schema) - .addSchemas("ThingsboardErrorResponse", ModelConverters.getInstance().readAllAsResolvedSchema(new AnnotatedType().type(ThingsboardErrorResponse.class)).schema) - .addSchemas("ThingsboardCredentialsExpiredResponse", ModelConverters.getInstance().readAllAsResolvedSchema(new AnnotatedType().type(ThingsboardCredentialsExpiredResponse.class)).schema) - .addSchemas("ThingsboardErrorCode", errorCodeSchema) - .addSchemas("AiChatModelConfig", ModelConverters.getInstance().readAllAsResolvedSchema(new AnnotatedType().type(AiChatModelConfig.class)).schema); + Components components = openAPI.getComponents(); + registerSchema(components, "LoginRequest", LoginRequest.class); + registerSchema(components, "LoginResponse", LoginResponse.class); + registerSchema(components, "ThingsboardErrorResponse", ThingsboardErrorResponse.class); + registerSchema(components, "ThingsboardCredentialsExpiredResponse", ThingsboardCredentialsExpiredResponse.class); + components.addSchemas("ThingsboardErrorCode", errorCodeSchema); + registerSchema(components, "AiChatModelConfig", AiChatModelConfig.class); + } + + private static void registerSchema(Components components, String name, Class cls) { + ResolvedSchema resolved = ModelConverters.getInstance() + .readAllAsResolvedSchema(new AnnotatedType().type(cls)); + components.addSchemas(name, resolved.schema); + if (resolved.referencedSchemas != null) { + resolved.referencedSchemas.forEach((refName, refSchema) -> { + if (components.getSchemas() == null || !components.getSchemas().containsKey(refName)) { + components.addSchemas(refName, refSchema); + } + }); + } } private OperationCustomizer operationCustomizer() {