diff --git a/README.adoc b/README.adoc index 8b4735aab..12f41f24e 100644 --- a/README.adoc +++ b/README.adoc @@ -69,6 +69,19 @@ Note, this feature only supports simple component references with a format like Specific components that are not referenced but should be retained in the final output should be identified using the <> directive with the <> extension. +[#remove-unused-tags] +==== Remove Unused Tags + +[source%nowrap] +---- +mp.openapi.extensions.smallrye.remove-unused-tags.enable +---- +Set to `true` to enable automatic removal of unused tags from the top-level `tags` array in the OpenAPI model. A tag is considered unused if it is not referenced by any operation in the `paths` section. Unused tags will be removed following annotation scanning and after running all other `OASFilter` instances that may be configured. Default value is `false`. + +Tags that are not referenced but should be retained in the final output should be identified using the <> directive with the <> extension. + + + [#remove-unused-schemas] ==== Removal of Unused Schemas (DEPRECATED) @@ -183,7 +196,7 @@ The `x-smallrye-directives` extension is a string list/array that contains one o [#x-smallrye-directives-retain] ===== retain -The `retain` directive is used together with the `mp.openapi.extensions.smallrye.remove-unused-components` property to indicate that a component should not be removed from the OpenAPI model even when it has no references from within the same model. +The `retain` directive is used together with the `mp.openapi.extensions.smallrye.remove-unused-components` or `mp.openapi.extensions.smallrye.remove-unused-tags.enable` properties to indicate that a component or tag should not be removed from the OpenAPI model even when it has no references from within the same model. [source%nowrap, java] ---- 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 995343902..05c6e7add 100644 --- a/core/src/main/java/io/smallrye/openapi/api/OpenApiConfig.java +++ b/core/src/main/java/io/smallrye/openapi/api/OpenApiConfig.java @@ -352,6 +352,10 @@ default Set removeUnusedComponents() { .collect(Collectors.toSet()); } + default boolean removeUnusedTags() { + return getConfigValue(SmallRyeOASConfig.SMALLRYE_REMOVE_UNUSED_TAGS, Boolean.class, () -> Boolean.FALSE); + } + default boolean mergeSchemaExamples() { return getConfigValue(SmallRyeOASConfig.SMALLRYE_MERGE_SCHEMA_EXAMPLES, Boolean.class, () -> Boolean.TRUE); } 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 5d8400d79..e2bd4b847 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.MergeUtil; import io.smallrye.openapi.api.util.UnusedComponentFilter; import io.smallrye.openapi.api.util.UnusedSchemaFilter; +import io.smallrye.openapi.api.util.UnusedTagFilter; import io.smallrye.openapi.model.Extensions; import io.smallrye.openapi.model.ReferenceType; import io.smallrye.openapi.runtime.util.ProfileFilter; @@ -209,6 +210,10 @@ private OpenAPI filterModel(OpenAPI model) { model = FilterUtil.applyFilter(new UnusedComponentFilter(removeUnusedComponents), model); } + if (config.removeUnusedTags()) { + model = FilterUtil.applyFilter(new UnusedTagFilter(), model); + } + if (!intermediateModel) { // Remove any `x-smallrye-*` extensions that should not be in the result model = FilterUtil.applyFilter(Extensions.newRemovalFilter(), model); diff --git a/core/src/main/java/io/smallrye/openapi/api/SmallRyeOASConfig.java b/core/src/main/java/io/smallrye/openapi/api/SmallRyeOASConfig.java index ac1845da6..7c4839a1b 100644 --- a/core/src/main/java/io/smallrye/openapi/api/SmallRyeOASConfig.java +++ b/core/src/main/java/io/smallrye/openapi/api/SmallRyeOASConfig.java @@ -24,6 +24,7 @@ private SmallRyeOASConfig() { private static final String SUFFIX_SORTED_PROPERTIES_ENABLE = "sorted-properties.enable"; private static final String SUFFIX_REMOVE_UNUSED_COMPONENTS = "remove-unused-components"; private static final String SUFFIX_REMOVE_UNUSED_SCHEMAS_ENABLE = "remove-unused-schemas.enable"; + private static final String SUFFIX_REMOVE_UNUSED_TAGS_ENABLE = "remove-unused-tags.enable"; private static final String SUFFIX_MERGE_SCHEMA_EXAMPLES = "merge-schema-examples"; private static final String SUFFIX_SORTED_PARAMETERS_ENABLE = "sorted-parameters.enable"; private static final String SUFFIX_GENERIC_RESPONSE_USE_DEFAULT = "generic-response-use-default"; @@ -56,6 +57,8 @@ private SmallRyeOASConfig() { public static final String SMALLRYE_REMOVE_UNUSED_COMPONENTS = SMALLRYE_PREFIX + SUFFIX_REMOVE_UNUSED_COMPONENTS; + public static final String SMALLRYE_REMOVE_UNUSED_TAGS = SMALLRYE_PREFIX + SUFFIX_REMOVE_UNUSED_TAGS_ENABLE; + public static final String SMALLRYE_MERGE_SCHEMA_EXAMPLES = SMALLRYE_PREFIX + SUFFIX_MERGE_SCHEMA_EXAMPLES; public static final String SMALLRYE_SORTED_PARAMETERS_ENABLE = SMALLRYE_PREFIX + SUFFIX_SORTED_PARAMETERS_ENABLE; diff --git a/core/src/main/java/io/smallrye/openapi/api/util/UnusedTagFilter.java b/core/src/main/java/io/smallrye/openapi/api/util/UnusedTagFilter.java new file mode 100644 index 000000000..e4d67dec7 --- /dev/null +++ b/core/src/main/java/io/smallrye/openapi/api/util/UnusedTagFilter.java @@ -0,0 +1,73 @@ +package io.smallrye.openapi.api.util; + +import java.util.ArrayList; +import java.util.HashSet; +import java.util.List; +import java.util.Set; + +import org.eclipse.microprofile.openapi.OASFilter; +import org.eclipse.microprofile.openapi.models.Extensible; +import org.eclipse.microprofile.openapi.models.OpenAPI; +import org.eclipse.microprofile.openapi.models.Operation; +import org.eclipse.microprofile.openapi.models.tags.Tag; + +import io.smallrye.openapi.model.Extensions; + +/** + * An implementation of OASFilter that scans the OpenAPI model and removes + * any tags from the top-level `tags` array that are not referenced by any + * operation in the paths. + */ +public class UnusedTagFilter implements OASFilter { + + /** + * Set of tag names that are referenced by at least one operation. + */ + private final Set referencedTagNames = new HashSet<>(); + + @Override + public Operation filterOperation(Operation operation) { + List operationTags = operation.getTags(); + + if (operationTags != null) { + referencedTagNames.addAll(operationTags); + } + + return operation; + } + + @Override + public void filterOpenAPI(OpenAPI openAPI) { + List tags = openAPI.getTags(); + + if (tags == null || tags.isEmpty()) { + return; + } + + List tagsToKeep = new ArrayList<>(); + + for (Tag tag : tags) { + String tagName = tag.getName(); + + // Keep tags that are referenced by operations + if (tagName != null && referencedTagNames.contains(tagName)) { + tagsToKeep.add(tag); + continue; + } + + // Keep tags marked with x-smallrye-directives=retain + if (Extensions.getDirectives((Extensible) tag).contains("retain")) { + tagsToKeep.add(tag); + continue; + } + + // Tag is unused and not marked to retain, log removal + UtilLogging.logger.unusedTagRemoved(tagName != null ? tagName : ""); + } + + // Update the tags list if any were removed + if (tagsToKeep.size() != tags.size()) { + openAPI.setTags(tagsToKeep); + } + } +} 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 f6a5538c3..3e19855c2 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 @@ -25,4 +25,8 @@ interface UtilLogging extends BasicLogger { @Message(id = 1002, value = "%s with zero references removed from %s: %s") void unusedComponentRemoved(String type, String prefix, String name); + @LogMessage(level = Logger.Level.DEBUG) + @Message(id = 1003, value = "Tag with zero references removed: %s") + void unusedTagRemoved(String name); + } diff --git a/core/src/test/java/io/smallrye/openapi/api/util/UnusedTagFilterTest.java b/core/src/test/java/io/smallrye/openapi/api/util/UnusedTagFilterTest.java new file mode 100644 index 000000000..2d7324b77 --- /dev/null +++ b/core/src/test/java/io/smallrye/openapi/api/util/UnusedTagFilterTest.java @@ -0,0 +1,245 @@ +package io.smallrye.openapi.api.util; + +import static org.eclipse.microprofile.openapi.OASFactory.createOpenAPI; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.io.IOException; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + +import org.eclipse.microprofile.config.Config; +import org.eclipse.microprofile.config.spi.ConfigSource; +import org.eclipse.microprofile.openapi.OASFactory; +import org.eclipse.microprofile.openapi.models.OpenAPI; +import org.eclipse.microprofile.openapi.models.tags.Tag; +import org.json.JSONException; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.skyscreamer.jsonassert.JSONAssert; +import org.skyscreamer.jsonassert.JSONCompareMode; + +import io.smallrye.config.PropertiesConfigSource; +import io.smallrye.config.SmallRyeConfigBuilder; +import io.smallrye.openapi.api.SmallRyeOASConfig; +import io.smallrye.openapi.api.SmallRyeOpenAPI; + +class UnusedTagFilterTest { + + private static final Config REMOVE_UNUSED_TAGS_CONFIG = new SmallRyeConfigBuilder() + .withSources(new PropertiesConfigSource( + Map.of(SmallRyeOASConfig.SMALLRYE_REMOVE_UNUSED_TAGS, "true"), + "unit-test", + ConfigSource.DEFAULT_ORDINAL)) + .build(); + + UnusedTagFilter target; + + @BeforeEach + void setUp() { + target = new UnusedTagFilter(); + } + + @Test + void testEmptyNoFailureWithoutTags() { + OpenAPI initialModel = createOpenAPI() + .info(OASFactory.createInfo() + .description("Test") + .version("1.0")); + + Assertions.assertDoesNotThrow(() -> { + SmallRyeOpenAPI.builder() + .withInitialModel(initialModel) + .withConfig(REMOVE_UNUSED_TAGS_CONFIG) + .build() + .model(); + }); + } + + @Test + void testAllUnusedTagsRemoved() { + OpenAPI initialModel = createOpenAPI() + .info(OASFactory.createInfo() + .description("Test") + .version("1.0")) + .addTag(OASFactory.createTag().name("unused-tag-1")) + .addTag(OASFactory.createTag().name("unused-tag-2")) + .addTag(OASFactory.createTag().name("unused-tag-3")) + .paths(OASFactory.createPaths() + .addPathItem("/test", OASFactory.createPathItem() + .GET(OASFactory.createOperation() + .summary("Test operation")))); + + OpenAPI filteredModel = SmallRyeOpenAPI.builder() + .withInitialModel(initialModel) + .withConfig(REMOVE_UNUSED_TAGS_CONFIG) + .build() + .model(); + + assertEquals(3, initialModel.getTags().size()); + assertTrue(filteredModel.getTags() == null || filteredModel.getTags().isEmpty()); + } + + @Test + void testUsedTagsRetained() { + OpenAPI initialModel = createOpenAPI() + .info(OASFactory.createInfo() + .description("Test") + .version("1.0")) + .addTag(OASFactory.createTag().name("used-tag-1").description("Used tag 1")) + .addTag(OASFactory.createTag().name("unused-tag").description("Unused tag")) + .addTag(OASFactory.createTag().name("used-tag-2").description("Used tag 2")) + .paths(OASFactory.createPaths() + .addPathItem("/test", OASFactory.createPathItem() + .GET(OASFactory.createOperation() + .summary("Test operation") + .addTag("used-tag-1") + .addTag("used-tag-2")))); + + OpenAPI filteredModel = SmallRyeOpenAPI.builder() + .withInitialModel(initialModel) + .withConfig(REMOVE_UNUSED_TAGS_CONFIG) + .build() + .model(); + + assertEquals(3, initialModel.getTags().size()); + assertEquals(2, filteredModel.getTags().size()); + + List remainingTagNames = filteredModel.getTags().stream() + .map(Tag::getName) + .collect(Collectors.toList()); + + assertTrue(remainingTagNames.contains("used-tag-1")); + assertTrue(remainingTagNames.contains("used-tag-2")); + } + + @Test + void testRetainedTagsNotRemoved() { + OpenAPI initialModel = createOpenAPI() + .info(OASFactory.createInfo() + .description("Test") + .version("1.0")) + .addTag(OASFactory.createTag() + .name("unused-but-retained") + .description("This should be kept") + .addExtension("x-smallrye-directives", List.of("retain"))) + .addTag(OASFactory.createTag().name("unused-tag")) + .paths(OASFactory.createPaths() + .addPathItem("/test", OASFactory.createPathItem() + .GET(OASFactory.createOperation() + .summary("Test operation")))); + + OpenAPI filteredModel = SmallRyeOpenAPI.builder() + .withInitialModel(initialModel) + .withConfig(REMOVE_UNUSED_TAGS_CONFIG) + .build() + .model(); + + assertEquals(2, initialModel.getTags().size()); + assertEquals(1, filteredModel.getTags().size()); + assertEquals("unused-but-retained", filteredModel.getTags().get(0).getName()); + } + + @Test + void testMultipleOperationsReferenceSameTag() { + OpenAPI initialModel = createOpenAPI() + .info(OASFactory.createInfo() + .description("Test") + .version("1.0")) + .addTag(OASFactory.createTag().name("shared-tag")) + .addTag(OASFactory.createTag().name("unused-tag")) + .paths(OASFactory.createPaths() + .addPathItem("/test1", OASFactory.createPathItem() + .GET(OASFactory.createOperation() + .summary("Test operation 1") + .addTag("shared-tag"))) + .addPathItem("/test2", OASFactory.createPathItem() + .POST(OASFactory.createOperation() + .summary("Test operation 2") + .addTag("shared-tag")))); + + OpenAPI filteredModel = SmallRyeOpenAPI.builder() + .withInitialModel(initialModel) + .withConfig(REMOVE_UNUSED_TAGS_CONFIG) + .build() + .model(); + + assertEquals(2, initialModel.getTags().size()); + assertEquals(1, filteredModel.getTags().size()); + assertEquals("shared-tag", filteredModel.getTags().get(0).getName()); + } + + @Test + void testMixedUsedUnusedAndRetained() { + OpenAPI initialModel = createOpenAPI() + .info(OASFactory.createInfo() + .description("Test") + .version("1.0")) + .addTag(OASFactory.createTag().name("used-tag")) + .addTag(OASFactory.createTag().name("unused-tag")) + .addTag(OASFactory.createTag() + .name("retained-tag") + .addExtension("x-smallrye-directives", List.of("retain"))) + .addTag(OASFactory.createTag().name("another-unused-tag")) + .paths(OASFactory.createPaths() + .addPathItem("/test", OASFactory.createPathItem() + .GET(OASFactory.createOperation() + .summary("Test operation") + .addTag("used-tag")))); + + OpenAPI filteredModel = SmallRyeOpenAPI.builder() + .withInitialModel(initialModel) + .withConfig(REMOVE_UNUSED_TAGS_CONFIG) + .build() + .model(); + + assertEquals(4, initialModel.getTags().size()); + assertEquals(2, filteredModel.getTags().size()); + + List remainingTagNames = filteredModel.getTags().stream() + .map(Tag::getName) + .collect(Collectors.toList()); + + assertTrue(remainingTagNames.contains("used-tag")); + assertTrue(remainingTagNames.contains("retained-tag")); + } + + @Test + void testUnusedTagsRemoved() throws JSONException, IOException { + SmallRyeOpenAPI result = SmallRyeOpenAPI.builder() + .withCustomStaticFile(() -> getClass().getResourceAsStream("UnusedTagFilter-before.json")) + .withConfig(REMOVE_UNUSED_TAGS_CONFIG) + .build(); + + JSONAssert.assertEquals( + new String(getClass().getResourceAsStream("UnusedTagFilter-after.json").readAllBytes()), + result.toJSON(), + JSONCompareMode.STRICT); + } + + @Test + void testDisabledByDefault() { + OpenAPI initialModel = createOpenAPI() + .info(OASFactory.createInfo() + .description("Test") + .version("1.0")) + .addTag(OASFactory.createTag().name("unused-tag")) + .paths(OASFactory.createPaths() + .addPathItem("/test", OASFactory.createPathItem() + .GET(OASFactory.createOperation() + .summary("Test operation")))); + + // Build without the remove-unused-tags config + OpenAPI filteredModel = SmallRyeOpenAPI.builder() + .withInitialModel(initialModel) + .build() + .model(); + + assertNotNull(filteredModel.getTags()); + assertEquals(1, filteredModel.getTags().size()); + assertEquals("unused-tag", filteredModel.getTags().get(0).getName()); + } +} diff --git a/core/src/test/resources/io/smallrye/openapi/api/util/UnusedTagFilter-after.json b/core/src/test/resources/io/smallrye/openapi/api/util/UnusedTagFilter-after.json new file mode 100644 index 000000000..79ad0aab9 --- /dev/null +++ b/core/src/test/resources/io/smallrye/openapi/api/util/UnusedTagFilter-after.json @@ -0,0 +1,75 @@ +{ + "openapi": "3.1.0", + "info": { + "title": "Test Sample for UnusedTagFilter", + "version": "1.0" + }, + "tags": [ + { + "name": "used-tag-1", + "description": "This tag is used by operations" + }, + { + "name": "used-tag-2", + "description": "This tag is also used", + "externalDocs": { + "url": "https://example.com", + "description": "Find more info here" + } + }, + { + "name": "unused-retained-tag", + "description": "This tag is unused but marked to retain" + } + ], + "paths": { + "/resource-a": { + "get": { + "tags": [ + "used-tag-1" + ], + "summary": "Get resource A", + "responses": { + "200": { + "description": "Success" + } + } + } + }, + "/resource-b": { + "get": { + "tags": [ + "used-tag-1", + "used-tag-2" + ], + "summary": "Get resource B", + "responses": { + "200": { + "description": "Success" + } + } + }, + "post": { + "tags": [ + "used-tag-2" + ], + "summary": "Create resource B", + "responses": { + "201": { + "description": "Created" + } + } + } + }, + "/resource-c": { + "get": { + "summary": "Get resource C without tags", + "responses": { + "200": { + "description": "Success" + } + } + } + } + } +} diff --git a/core/src/test/resources/io/smallrye/openapi/api/util/UnusedTagFilter-before.json b/core/src/test/resources/io/smallrye/openapi/api/util/UnusedTagFilter-before.json new file mode 100644 index 000000000..854630007 --- /dev/null +++ b/core/src/test/resources/io/smallrye/openapi/api/util/UnusedTagFilter-before.json @@ -0,0 +1,86 @@ +{ + "openapi": "3.1.0", + "info": { + "title": "Test Sample for UnusedTagFilter", + "version": "1.0" + }, + "tags": [ + { + "name": "used-tag-1", + "description": "This tag is used by operations" + }, + { + "name": "unused-tag", + "description": "This tag is not referenced anywhere" + }, + { + "name": "used-tag-2", + "description": "This tag is also used", + "externalDocs": { + "url": "https://example.com", + "description": "Find more info here" + } + }, + { + "name": "unused-retained-tag", + "description": "This tag is unused but marked to retain", + "x-smallrye-directives": [ + "retain" + ] + }, + { + "name": "another-unused-tag", + "description": "Another unused tag" + } + ], + "paths": { + "/resource-a": { + "get": { + "tags": [ + "used-tag-1" + ], + "summary": "Get resource A", + "responses": { + "200": { + "description": "Success" + } + } + } + }, + "/resource-b": { + "get": { + "tags": [ + "used-tag-1", + "used-tag-2" + ], + "summary": "Get resource B", + "responses": { + "200": { + "description": "Success" + } + } + }, + "post": { + "tags": [ + "used-tag-2" + ], + "summary": "Create resource B", + "responses": { + "201": { + "description": "Created" + } + } + } + }, + "/resource-c": { + "get": { + "summary": "Get resource C without tags", + "responses": { + "200": { + "description": "Success" + } + } + } + } + } +} diff --git a/tools/maven-plugin/README.adoc b/tools/maven-plugin/README.adoc index 975243122..233e2d876 100644 --- a/tools/maven-plugin/README.adoc +++ b/tools/maven-plugin/README.adoc @@ -62,6 +62,7 @@ E.g. for includeDependenciesScopes could be configured as: - `includeDependenciesScopes` (List, default: compile, system) - If the above `scanDependenciesDisable` is true, you can control what scopes should be included. - `includeDependenciesTypes` (List, default: jar) - If the above `scanDependenciesDisable` is true, you can control what types should be included. - `includeStandardJavaModules` (List, default: empty list) - List of Java modules to be indexed and available for introspection by the annotation scanner. Use with caution as introspection of the core Java modules generally is not useful for generation of the OpenAPI definition. +- `systemPropertyVariables` (Map) - Map of system properties that will be set before generating the schema. Can be used to pass configuration properties that are not directly supported as plugin parameters, such as those starting with `mp.openapi.extensions.smallrye` - `configProperties` (String) - Load any properties from a file. Example `${basedir}/src/main/resources/application.properties`. - `attachArtifacts` (boolean, default: false) - Attach the built OpenAPI schema as build artifact. - `skip` (boolean, default: false) - Skip execution of the plugin. @@ -106,3 +107,4 @@ All properties from the SmallRye OpenAPI Implementation is supported. Properties - `PACKAGE_CLASS_METHOD` - The fully qualified class name and method name is used as operationId. - `scanProfiles` (List) - Profiles which explicitly include operations. Any operation without a matching profile is excluded. - `scanExcludeProfiles` (List) - Profiles which explicitly exclude operations. Any operation without a matching profile is included. +