From 32055a8d92b751b1ddaadbe10c6f5bc15f1d1063 Mon Sep 17 00:00:00 2001 From: Michael Edgar Date: Sun, 7 Aug 2022 10:25:23 -0400 Subject: [PATCH] Add support for removing unused models Fixes #1183 Signed-off-by: Michael Edgar --- README.adoc | 1 + .../smallrye/openapi/api/OpenApiConfig.java | 4 + .../openapi/api/OpenApiConfigImpl.java | 11 ++ .../smallrye/openapi/api/OpenApiDocument.java | 6 +- .../api/constants/OpenApiConstants.java | 2 + .../smallrye/openapi/api/util/FilterUtil.java | 1 + .../openapi/api/util/UnusedSchemaFilter.java | 109 ++++++++++++++++++ .../openapi/api/util/UtilLogging.java | 5 + .../api/util/UnusedSchemaFilterTest.java | 94 +++++++++++++++ 9 files changed, 232 insertions(+), 1 deletion(-) create mode 100644 core/src/main/java/io/smallrye/openapi/api/util/UnusedSchemaFilter.java create mode 100644 core/src/test/java/io/smallrye/openapi/api/util/UnusedSchemaFilterTest.java diff --git a/README.adoc b/README.adoc index 5d73215e9..0724fcc04 100644 --- a/README.adoc +++ b/README.adoc @@ -39,3 +39,4 @@ mvn clean install ** A standard JSON-B naming strategy (listed in `jakarta.json.bind.config.PropertyNamingStrategy`/`javax.json.bind.config.PropertyNamingStrategy`) ** A fully-qualified class name of an implementation of a JSON-B property naming strategy (`jakarta.json.bind.config.PropertyNamingStrategy` or `javax.json.bind.config.PropertyNamingStrategy`) ** A fully-qualified class name of an implementation of a Jackson property naming strategy base class (`com.fasterxml.jackson.databind.PropertyNamingStrategies.NamingBase`). Only the `translate` method is utilized. +* `mp.openapi.extensions.smallrye.remove-unused-schemas.enable` - Set to `true` enable automatic removal of unused schemas from `components/schemas` in the OpenAPI model. Unused schemas will be removed following annotation scanning but prior to running any `OASFilter` that may be configured. Default value is `false`. diff --git a/core/src/main/java/io/smallrye/openapi/api/OpenApiConfig.java b/core/src/main/java/io/smallrye/openapi/api/OpenApiConfig.java index 4ebd2e052..7673cc8d7 100644 --- a/core/src/main/java/io/smallrye/openapi/api/OpenApiConfig.java +++ b/core/src/main/java/io/smallrye/openapi/api/OpenApiConfig.java @@ -164,6 +164,10 @@ default Set getScanExcludeProfiles() { return new HashSet<>(); } + default boolean removeUnusedSchemas() { + return false; + } + default void doAllowNakedPathParameter() { } diff --git a/core/src/main/java/io/smallrye/openapi/api/OpenApiConfigImpl.java b/core/src/main/java/io/smallrye/openapi/api/OpenApiConfigImpl.java index 423b5eb85..e65bf1d75 100644 --- a/core/src/main/java/io/smallrye/openapi/api/OpenApiConfigImpl.java +++ b/core/src/main/java/io/smallrye/openapi/api/OpenApiConfigImpl.java @@ -53,6 +53,7 @@ public class OpenApiConfigImpl implements OpenApiConfig { private OperationIdStrategy operationIdStrategy; private Set scanProfiles; private Set scanExcludeProfiles; + private Boolean removeUnusedSchemas; private Optional defaultProduces = UNSET; private Optional defaultConsumes = UNSET; private Optional allowNakedPathParameter = Optional.empty(); @@ -438,6 +439,16 @@ public Set getScanExcludeProfiles() { return scanExcludeProfiles; } + @Override + public boolean removeUnusedSchemas() { + if (removeUnusedSchemas == null) { + removeUnusedSchemas = getConfig() + .getOptionalValue(OpenApiConstants.SMALLRYE_REMOVE_UNUSED_SCHEMAS, Boolean.class) + .orElse(OpenApiConfig.super.removeUnusedSchemas()); + } + return removeUnusedSchemas; + } + /** * getConfig().getOptionalValue(key) can return "" if optional {@link Converter}s are used. Enforce a null value if * we get an empty string back. diff --git a/core/src/main/java/io/smallrye/openapi/api/OpenApiDocument.java b/core/src/main/java/io/smallrye/openapi/api/OpenApiDocument.java index c5fb3f859..066eec5c5 100644 --- a/core/src/main/java/io/smallrye/openapi/api/OpenApiDocument.java +++ b/core/src/main/java/io/smallrye/openapi/api/OpenApiDocument.java @@ -13,6 +13,7 @@ import io.smallrye.openapi.api.util.ConfigUtil; import io.smallrye.openapi.api.util.FilterUtil; import io.smallrye.openapi.api.util.MergeUtil; +import io.smallrye.openapi.api.util.UnusedSchemaFilter; /** * Holds the final OpenAPI document produced during the startup of the app. @@ -166,9 +167,12 @@ public synchronized void initialize() { * @param model */ private OpenAPI filterModel(OpenAPI model) { - if (model == null || filters.isEmpty()) { + if (model == null) { return model; } + if (config.removeUnusedSchemas()) { + model = FilterUtil.applyFilter(new UnusedSchemaFilter(), model); + } for (OASFilter filter : filters.values()) { model = FilterUtil.applyFilter(filter, model); } diff --git a/core/src/main/java/io/smallrye/openapi/api/constants/OpenApiConstants.java b/core/src/main/java/io/smallrye/openapi/api/constants/OpenApiConstants.java index a45699b02..66ce709f7 100644 --- a/core/src/main/java/io/smallrye/openapi/api/constants/OpenApiConstants.java +++ b/core/src/main/java/io/smallrye/openapi/api/constants/OpenApiConstants.java @@ -25,6 +25,7 @@ public final class OpenApiConstants { public static final String SUFFIX_PRIVATE_PROPERTIES_ENABLE = "private-properties.enable"; public static final String SUFFIX_PROPERTY_NAMING_STRATEGY = "property-naming-strategy"; public static final String SUFFIX_SORTED_PROPERTIES_ENABLE = "sorted-properties.enable"; + public static final String SUFFIX_REMOVE_UNUSED_SCHEMAS_ENABLE = "remove-unused-schemas.enable"; public static final String SCAN_DEPENDENCIES_DISABLE = OASConfig.EXTENSIONS_PREFIX + SUFFIX_SCAN_DEPENDENCIES_DISABLE; public static final String SCAN_DEPENDENCIES_JARS = OASConfig.EXTENSIONS_PREFIX + SUFFIX_SCAN_DEPENDENCIES_JARS; @@ -42,6 +43,7 @@ public final class OpenApiConstants { public static final String SMALLRYE_PRIVATE_PROPERTIES_ENABLE = SMALLRYE_PREFIX + SUFFIX_PRIVATE_PROPERTIES_ENABLE; public static final String SMALLRYE_PROPERTY_NAMING_STRATEGY = SMALLRYE_PREFIX + SUFFIX_PROPERTY_NAMING_STRATEGY; public static final String SMALLRYE_SORTED_PROPERTIES_ENABLE = SMALLRYE_PREFIX + SUFFIX_SORTED_PROPERTIES_ENABLE; + public static final String SMALLRYE_REMOVE_UNUSED_SCHEMAS = SMALLRYE_PREFIX + SUFFIX_REMOVE_UNUSED_SCHEMAS_ENABLE; public static final String SCAN_PROFILES = SMALLRYE_PREFIX + "scan.profiles"; public static final String SCAN_EXCLUDE_PROFILES = SMALLRYE_PREFIX + "scan.exclude.profiles"; diff --git a/core/src/main/java/io/smallrye/openapi/api/util/FilterUtil.java b/core/src/main/java/io/smallrye/openapi/api/util/FilterUtil.java index 997d14348..8ed6d354d 100644 --- a/core/src/main/java/io/smallrye/openapi/api/util/FilterUtil.java +++ b/core/src/main/java/io/smallrye/openapi/api/util/FilterUtil.java @@ -372,6 +372,7 @@ private static void filterSchema(OASFilter filter, Schema model) { model::setAdditionalPropertiesSchema); filter(filter, model.getAllOf(), FilterUtil::filterSchema, filter::filterSchema, model::removeAllOf); filter(filter, model.getAnyOf(), FilterUtil::filterSchema, filter::filterSchema, model::removeAnyOf); + filter(filter, model.getOneOf(), FilterUtil::filterSchema, filter::filterSchema, model::removeOneOf); filter(filter, model.getItems(), FilterUtil::filterSchema, filter::filterSchema, model::setItems); filter(filter, model.getNot(), FilterUtil::filterSchema, filter::filterSchema, model::setNot); filter(filter, model.getProperties(), FilterUtil::filterSchema, filter::filterSchema, model::removeProperty); diff --git a/core/src/main/java/io/smallrye/openapi/api/util/UnusedSchemaFilter.java b/core/src/main/java/io/smallrye/openapi/api/util/UnusedSchemaFilter.java new file mode 100644 index 000000000..c5c9217b0 --- /dev/null +++ b/core/src/main/java/io/smallrye/openapi/api/util/UnusedSchemaFilter.java @@ -0,0 +1,109 @@ +package io.smallrye.openapi.api.util; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.Set; +import java.util.stream.Collectors; + +import org.eclipse.microprofile.openapi.OASFilter; +import org.eclipse.microprofile.openapi.models.Components; +import org.eclipse.microprofile.openapi.models.OpenAPI; +import org.eclipse.microprofile.openapi.models.media.Schema; + +import io.smallrye.openapi.runtime.util.ModelUtil; + +public class UnusedSchemaFilter implements OASFilter { + + /** + * Map of schemas present in {@code /components/schemas} with a list of the + * schemas that refer to them. + */ + Map> references = new HashMap<>(); + + @Override + public Schema filterSchema(Schema schema) { + String name = referencedName(schema); + + if (name != null) { + references.computeIfAbsent(name, k -> new ArrayList<>()).add(schema); + } + + return schema; + } + + @Override + public void filterOpenAPI(OpenAPI openAPI) { + final Components components = openAPI.getComponents(); + + Optional.ofNullable(components) + .map(Components::getSchemas) + .map(Map::keySet) + .ifPresent(schemaNames -> { + Set unusedNames = unusedSchemaNames(schemaNames); + + while (!unusedNames.isEmpty()) { + unusedNames.forEach(name -> remove(name, components)); + unusedNames = unusedSchemaNames(schemaNames); + } + }); + } + + String referencedName(Schema schema) { + final String ref = schema.getRef(); + + if (ref != null && ref.startsWith("#/components/schemas/")) { + return ModelUtil.nameFromRef(ref); + } + + return null; + } + + boolean notUsed(String schemaName) { + return !references.containsKey(schemaName); + } + + Set unusedSchemaNames(Set allSchemaNames) { + return allSchemaNames.stream().filter(this::notUsed).collect(Collectors.toSet()); + } + + void remove(String schemaName, Components components) { + Schema unusedSchema = components.getSchemas().get(schemaName); + removeReference(unusedSchema.getAdditionalPropertiesSchema()); + removeReferences(unusedSchema.getAllOf()); + removeReferences(unusedSchema.getAnyOf()); + removeReferences(unusedSchema.getOneOf()); + removeReference(unusedSchema.getItems()); + removeReference(unusedSchema.getNot()); + removeReferences(unusedSchema.getProperties()); + components.removeSchema(schemaName); + UtilLogging.logger.unusedSchemaRemoved(schemaName); + } + + void removeReference(Schema schema) { + if (schema != null) { + String name = referencedName(schema); + + if (name != null) { + references.computeIfPresent(name, (k, v) -> { + v.remove(schema); + return v.isEmpty() ? null : v; + }); + } + } + } + + void removeReferences(Map schemas) { + if (schemas != null) { + schemas.values().forEach(this::removeReference); + } + } + + void removeReferences(List schemas) { + if (schemas != null) { + schemas.forEach(this::removeReference); + } + } +} diff --git a/core/src/main/java/io/smallrye/openapi/api/util/UtilLogging.java b/core/src/main/java/io/smallrye/openapi/api/util/UtilLogging.java index 203b4693b..c30e5415a 100644 --- a/core/src/main/java/io/smallrye/openapi/api/util/UtilLogging.java +++ b/core/src/main/java/io/smallrye/openapi/api/util/UtilLogging.java @@ -14,4 +14,9 @@ interface UtilLogging extends BasicLogger { @LogMessage(level = Logger.Level.ERROR) @Message(id = 1000, value = "Failed to introspect BeanInfo for: %s") void failedToIntrospectBeanInfo(Class clazz, @Cause Throwable cause); + + @LogMessage(level = Logger.Level.INFO) + @Message(id = 1001, value = "Schema with zero references removed from #/components/schemas: %s") + void unusedSchemaRemoved(String name); + } diff --git a/core/src/test/java/io/smallrye/openapi/api/util/UnusedSchemaFilterTest.java b/core/src/test/java/io/smallrye/openapi/api/util/UnusedSchemaFilterTest.java new file mode 100644 index 000000000..7f5c09b2a --- /dev/null +++ b/core/src/test/java/io/smallrye/openapi/api/util/UnusedSchemaFilterTest.java @@ -0,0 +1,94 @@ +package io.smallrye.openapi.api.util; + +import static org.eclipse.microprofile.openapi.OASFactory.createAPIResponse; +import static org.eclipse.microprofile.openapi.OASFactory.createAPIResponses; +import static org.eclipse.microprofile.openapi.OASFactory.createComponents; +import static org.eclipse.microprofile.openapi.OASFactory.createContent; +import static org.eclipse.microprofile.openapi.OASFactory.createMediaType; +import static org.eclipse.microprofile.openapi.OASFactory.createOpenAPI; +import static org.eclipse.microprofile.openapi.OASFactory.createOperation; +import static org.eclipse.microprofile.openapi.OASFactory.createPathItem; +import static org.eclipse.microprofile.openapi.OASFactory.createPaths; +import static org.eclipse.microprofile.openapi.OASFactory.createSchema; +import static org.junit.jupiter.api.Assertions.assertEquals; + +import org.eclipse.microprofile.openapi.models.OpenAPI; +import org.eclipse.microprofile.openapi.models.media.Schema.SchemaType; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +class UnusedSchemaFilterTest { + + UnusedSchemaFilter target; + OpenAPI openAPI; + + @BeforeEach + void setUp() throws Exception { + target = new UnusedSchemaFilter(); + + openAPI = createOpenAPI(); + openAPI.paths(createPaths() + .addPathItem("/data", createPathItem() + .GET(createOperation() + .responses(createAPIResponses() + .addAPIResponse("200", createAPIResponse() + .content(createContent() + .addMediaType("text/plain", createMediaType() + .schema(createSchema().ref("#/components/schemas/Data"))) + .addMediaType("text/html", createMediaType() + .schema(createSchema().ref( + "http://example.com/schemas/Data?type=html"))))))))) + .components(createComponents() + .addSchema("Data", createSchema() + .type(SchemaType.STRING) + .description("The data returned by the API"))); + } + + @Test + void testUnusedSchemaPropertyRemoved() { + openAPI.getComponents() + .addSchema("RemovedSchema", createSchema() + .type(SchemaType.OBJECT) + .description("Schema to be removed, pass 1") + .addProperty("prop1", createSchema() + .ref("#/components/schemas/RemovedPropertySchema")) + .addProperty("prop2", createSchema() + .ref("#/components/schemas/RemovedPropertySchema"))) + .addSchema("RemovedPropertySchema", createSchema() + .type(SchemaType.STRING) + .description("Schema to be removed, pass 2")); + + assertEquals(3, openAPI.getComponents().getSchemas().size()); + + openAPI = FilterUtil.applyFilter(target, openAPI); + assertEquals(1, openAPI.getComponents().getSchemas().size()); + assertEquals("Data", openAPI.getComponents().getSchemas().keySet().iterator().next()); + } + + @Test + void testUnusedOneOfSchemasRemoved() { + openAPI.getComponents() + .addSchema("RemovedSchema", createSchema() + .type(SchemaType.OBJECT) + .description("Schema to be removed, pass 1") + .addOneOf(createSchema() + .ref("#/components/schemas/RemovedAllOfSchema1")) + .addOneOf(createSchema() + .ref("#/components/schemas/RemovedAllOfSchema2")) + .addOneOf(createSchema() + .type(SchemaType.BOOLEAN))) + .addSchema("RemovedAllOfSchema1", createSchema() + .type(SchemaType.INTEGER) + .description("Schema to be removed, pass 2")) + .addSchema("RemovedAllOfSchema2", createSchema() + .type(SchemaType.STRING) + .description("Schema to be removed, pass 2")); + + assertEquals(4, openAPI.getComponents().getSchemas().size()); + + openAPI = FilterUtil.applyFilter(target, openAPI); + assertEquals(1, openAPI.getComponents().getSchemas().size()); + assertEquals("Data", openAPI.getComponents().getSchemas().keySet().iterator().next()); + } + +}