diff --git a/README.adoc b/README.adoc index 84e0fc3f4..8b4735aab 100644 --- a/README.adoc +++ b/README.adoc @@ -169,7 +169,9 @@ Set this boolean value to enable or disable the expansion of YAML aliases when l mp.openapi.extensions.smallrye.scan.profiles mp.openapi.extensions.smallrye.scan.exclude.profiles ---- -These properties are used together with the <> extension to provide a way to limit which REST endpoints and associated resources are included in the OpenAPI model. +These properties are used together with the <> extension to provide a way to limit which REST endpoints and associated resources are included in the OpenAPI model. Path items where all operations have been excluded will also be removed from the model unless the path item is in the model's components section. + +Note: Profiles will be processed after the OpenAPI model is fully merged from the static YAML/JSON file, the `OASModelReader` output, and annotation scanning but prior to any user-provided filters. This means that user filters will receive the model after non-included profiles have been removed. === Extensions 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 7df285a09..5d8400d79 100644 --- a/core/src/main/java/io/smallrye/openapi/api/OpenApiDocument.java +++ b/core/src/main/java/io/smallrye/openapi/api/OpenApiDocument.java @@ -15,6 +15,7 @@ import io.smallrye.openapi.api.util.UnusedSchemaFilter; import io.smallrye.openapi.model.Extensions; import io.smallrye.openapi.model.ReferenceType; +import io.smallrye.openapi.runtime.util.ProfileFilter; /** * Holds the final OpenAPI document produced during the startup of the app. @@ -190,6 +191,10 @@ private OpenAPI filterModel(OpenAPI model) { return model; } + if (!config.getScanProfiles().isEmpty() || !config.getScanExcludeProfiles().isEmpty()) { + model = FilterUtil.applyFilter(new ProfileFilter(model, config), model); + } + if (config.removeUnusedSchemas()) { model = FilterUtil.applyFilter(new UnusedSchemaFilter(), model); } diff --git a/core/src/main/java/io/smallrye/openapi/runtime/scanner/spi/AbstractAnnotationScanner.java b/core/src/main/java/io/smallrye/openapi/runtime/scanner/spi/AbstractAnnotationScanner.java index 037084590..76197ce77 100644 --- a/core/src/main/java/io/smallrye/openapi/runtime/scanner/spi/AbstractAnnotationScanner.java +++ b/core/src/main/java/io/smallrye/openapi/runtime/scanner/spi/AbstractAnnotationScanner.java @@ -104,30 +104,15 @@ protected static String createPathFromSegments(String... segments) { } /** - * Checks if the given extensible contains profiles, and if the extensible should be included in the final openapi document. - * Any extension containing a profile is removed from the extensible. - * inclusion is then calculated based on all collected profiles. + * Checks if the given extensible contains profiles, and if the extensible should be included in the final OpenAPI document. + * Inclusion is calculated based on all collected profiles. * * @param config current config * @param extensible the extensible to check for profiles - * @return true, if the given extensible should be included in the final openapi document, otherwise false + * @return true, if the given extensible should be included in the final OpenAPI document, otherwise false */ protected static boolean processProfiles(OpenApiConfig config, Extensible extensible) { - Set profiles = Extensions.getProfiles(extensible); - Extensions.removeProfiles(extensible); - return profileIncluded(config, profiles); - } - - private static boolean profileIncluded(OpenApiConfig config, Set profiles) { - if (!config.getScanExcludeProfiles().isEmpty()) { - return config.getScanExcludeProfiles().stream().noneMatch(profiles::contains); - } - - if (config.getScanProfiles().isEmpty()) { - return true; - } - - return config.getScanProfiles().stream().anyMatch(profiles::contains); + return Extensions.includedProfile(extensible, config.getScanProfiles(), config.getScanExcludeProfiles()); } @Override diff --git a/core/src/main/java/io/smallrye/openapi/runtime/util/ProfileFilter.java b/core/src/main/java/io/smallrye/openapi/runtime/util/ProfileFilter.java new file mode 100644 index 000000000..87f820de8 --- /dev/null +++ b/core/src/main/java/io/smallrye/openapi/runtime/util/ProfileFilter.java @@ -0,0 +1,72 @@ +package io.smallrye.openapi.runtime.util; + +import java.util.Collections; +import java.util.Map; +import java.util.Optional; +import java.util.Set; + +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.Operation; +import org.eclipse.microprofile.openapi.models.PathItem; +import org.eclipse.microprofile.openapi.models.PathItem.HttpMethod; + +import io.smallrye.openapi.api.OpenApiConfig; +import io.smallrye.openapi.model.Extensions; + +/** + * Not intended for use outside of smallrye-open-api. Interface and functionality + * may not be stable for general use. + * + * Removes operations and path items from the model if they are not included + * based on configuration. Note that path items will be removed if all operations + * have been removed by the filter and if the path item is not specified in the + * OpenAPI components section. + */ +public class ProfileFilter implements OASFilter { + + private final Map pathItemComponents; + private final Set included; + private final Set excluded; + + public ProfileFilter(OpenAPI model, OpenApiConfig config) { + pathItemComponents = Optional.ofNullable(model.getComponents()) + .map(Components::getPathItems) + .orElseGet(Collections::emptyMap); + + included = config.getScanProfiles(); + excluded = config.getScanExcludeProfiles(); + } + + @Override + public PathItem filterPathItem(PathItem pathItem) { + boolean operationExcluded = false; + + for (HttpMethod method : Set.copyOf(pathItem.getOperations().keySet())) { + Operation o = pathItem.getOperations().get(method); + + if (!Extensions.includedProfile(o, included, excluded)) { + operationExcluded = true; + pathItem.setOperation(method, null); + } + } + + if (operationExcluded && pathItem.getOperations().isEmpty() && nonComponent(pathItem)) { + // Only remove the path item if it is not a component that may be referenced elsewhere. + return null; + } + + return pathItem; + } + + private boolean nonComponent(PathItem pathItem) { + for (PathItem component : pathItemComponents.values()) { + if (pathItem == component) { + // If it's the same object, the given pathItem is in components + return false; + } + } + return true; + } +} diff --git a/core/src/test/java/io/smallrye/openapi/runtime/scanner/spi/AbstractAnnotationScannerTest.java b/core/src/test/java/io/smallrye/openapi/runtime/scanner/spi/AbstractAnnotationScannerTest.java index 0c3667093..b1a0d5c63 100644 --- a/core/src/test/java/io/smallrye/openapi/runtime/scanner/spi/AbstractAnnotationScannerTest.java +++ b/core/src/test/java/io/smallrye/openapi/runtime/scanner/spi/AbstractAnnotationScannerTest.java @@ -121,7 +121,6 @@ void testNoConfiguredProfile() { boolean result = AbstractAnnotationScanner.processProfiles(config, operation); assertTrue(result); - assertEquals(0, operation.getExtensions().size()); } @Test @@ -142,7 +141,6 @@ public Set getScanProfiles() { result = AbstractAnnotationScanner.processProfiles(config, operation); assertTrue(result); - assertEquals(0, operation.getExtensions().size()); } @Test @@ -163,7 +161,6 @@ public Set getScanExcludeProfiles() { result = AbstractAnnotationScanner.processProfiles(config, operation); assertFalse(result); - assertEquals(0, operation.getExtensions().size()); } @ParameterizedTest diff --git a/extension-jaxrs/src/main/java/io/smallrye/openapi/jaxrs/JaxRsAnnotationScanner.java b/extension-jaxrs/src/main/java/io/smallrye/openapi/jaxrs/JaxRsAnnotationScanner.java index 4a98de006..03aa0b75d 100644 --- a/extension-jaxrs/src/main/java/io/smallrye/openapi/jaxrs/JaxRsAnnotationScanner.java +++ b/extension-jaxrs/src/main/java/io/smallrye/openapi/jaxrs/JaxRsAnnotationScanner.java @@ -496,6 +496,19 @@ private void processResourceMethod(final ClassInfo resourceClass, // Process tags - @Tag and @Tags annotations combines with the resource tags we've already found (passed in) processOperationTags(context, method, context.getOpenApi(), resourceTags, operation); + // Process @Extension annotations + processExtensions(context, method, operation); + + if (!processProfiles(context.getConfig(), operation)) { + // Any operations not included by a profile (if configured) will also be filtered out + // later during the filter stage so that profile included/exclusion takes into account + // the static file and the OASReader models. This short-circuit is left in as an + // optimization to avoid additional scanning when the operation would have been + // excluded anyway. As a result, profile configurations are built-time only for Quarkus + // applications. + return; + } + // Process @Parameter annotations. List operationParams = params.getOperationParameters(); operation.setParameters(operationParams); @@ -524,19 +537,12 @@ private void processResourceMethod(final ClassInfo resourceClass, // Process @Server annotations processServerAnnotation(context, method, operation); - // Process @Extension annotations - processExtensions(context, method, operation); - // Process Security Roles context.getJavaSecurityProcessor().processSecurityRoles(method, operation); // Now set the operation on the PathItem as appropriate based on the Http method type pathItem.setOperation(methodType, operation); - if (!processProfiles(context.getConfig(), operation)) { - return; - } - // When processing a sub-resource tree, ignore any @Path information from the current class List operationPaths = this.subResourceStack.isEmpty() ? params.getFullOperationPaths() : params.getOperationPaths(); diff --git a/extension-jaxrs/src/test/java/io/smallrye/openapi/runtime/scanner/ProfileSelectionWithStaticModelTest.java b/extension-jaxrs/src/test/java/io/smallrye/openapi/runtime/scanner/ProfileSelectionWithStaticModelTest.java new file mode 100644 index 000000000..e9dcd6efe --- /dev/null +++ b/extension-jaxrs/src/test/java/io/smallrye/openapi/runtime/scanner/ProfileSelectionWithStaticModelTest.java @@ -0,0 +1,90 @@ +package io.smallrye.openapi.runtime.scanner; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.Collections; +import java.util.List; + +import jakarta.ws.rs.GET; +import jakarta.ws.rs.Path; + +import org.eclipse.microprofile.openapi.OASConfig; +import org.eclipse.microprofile.openapi.OASFactory; +import org.eclipse.microprofile.openapi.OASModelReader; +import org.eclipse.microprofile.openapi.annotations.extensions.Extension; +import org.eclipse.microprofile.openapi.models.OpenAPI; +import org.eclipse.microprofile.openapi.models.media.Schema.SchemaType; +import org.jboss.jandex.Index; +import org.junit.jupiter.api.Test; + +import io.smallrye.openapi.api.SmallRyeOASConfig; +import io.smallrye.openapi.api.SmallRyeOpenAPI; + +class ProfileSelectionWithStaticModelTest { + + public static class MyReader implements OASModelReader { + @Override + public OpenAPI buildModel() { + return OASFactory.createOpenAPI() + .components(OASFactory.createComponents() + .addPathItem("Orders", OASFactory.createPathItem() + .GET(OASFactory.createOperation() + .responses(OASFactory.createAPIResponses() + .addAPIResponse("default", OASFactory.createAPIResponse() + .content(OASFactory.createContent() + .addMediaType("text/plain", OASFactory.createMediaType() + .schema(OASFactory.createSchema() + .type(List.of(SchemaType.STRING)))))))))); + } + } + + @Test + void testStaticModelOperationExcluded() throws Exception { + @Path("/api") + class MyResource { + @Path("/users") + @GET + @Extension(name = "x-smallrye-profile-public", value = "") + public List listUsers() { + return Collections.emptyList(); + } + } + + SmallRyeOpenAPI result; + + try { + System.setProperty(SmallRyeOASConfig.SCAN_PROFILES, "public"); + System.setProperty(OASConfig.MODEL_READER, MyReader.class.getName()); + + result = SmallRyeOpenAPI.builder() + .enableStandardFilter(false) + .enableStandardStaticFiles(false) + .withCustomStaticFile(() -> { + return getClass() + .getClassLoader() + .getResourceAsStream( + "io/smallrye/openapi/runtime/scanner/static/profile-selection-static-model.yaml"); + }) + .enableAnnotationScan(true) + .withIndex(Index.of(MyResource.class)) + .build(); + } finally { + System.clearProperty(SmallRyeOASConfig.SCAN_PROFILES); + System.clearProperty(OASConfig.MODEL_READER); + } + + OpenAPI model = result.model(); + + var paths = model.getPaths().getPathItems(); + assertEquals(2, paths.size(), () -> paths.keySet().toString()); + assertTrue(paths.containsKey("/api/users"), () -> paths.keySet().toString()); + assertTrue(paths.containsKey("/api/public/echo"), () -> paths.keySet().toString()); + + var componentPaths = model.getComponents().getPathItems(); + assertEquals(1, componentPaths.size()); + var orderOperations = componentPaths.get("Orders").getOperations(); + // removed by filter + assertTrue(orderOperations.isEmpty()); + } +} diff --git a/extension-jaxrs/src/test/resources/io/smallrye/openapi/runtime/scanner/static/profile-selection-static-model.yaml b/extension-jaxrs/src/test/resources/io/smallrye/openapi/runtime/scanner/static/profile-selection-static-model.yaml new file mode 100644 index 000000000..87ff3422e --- /dev/null +++ b/extension-jaxrs/src/test/resources/io/smallrye/openapi/runtime/scanner/static/profile-selection-static-model.yaml @@ -0,0 +1,49 @@ +openapi: 3.0.3 +info: + title: echo + version: '1.0.0' + description: "" +paths: + /api/public/echo: + get: + summary: Echo GET + operationId: get-echo + x-smallrye-profile-public: "" + parameters: + - name: Message + in: query + schema: + type: string + options: + summary: Echo OPTIONS + operationId: options-echo + description: Echo OPTIONS not for public use! + + /api/private/echo: + post: + summary: Echo POST + operationId: post-echo + requestBody: + content: + application/json: + schema: + $ref: "#/components/schemas/Message" + responses: + "200": + description: OK + content: + application/json: + schema: + $ref: '#/components/schemas/Echo' +components: + schemas: + Echo: + type: object + properties: + echo: + type: string + Message: + type: object + properties: + message: + type: string diff --git a/model/src/main/java/io/smallrye/openapi/model/Extensions.java b/model/src/main/java/io/smallrye/openapi/model/Extensions.java index 7130f1e00..4de2d3aa4 100644 --- a/model/src/main/java/io/smallrye/openapi/model/Extensions.java +++ b/model/src/main/java/io/smallrye/openapi/model/Extensions.java @@ -83,6 +83,8 @@ private static void set(Extensible extensible, String name, T value) { } } + // Profiles + public static Set getProfiles(Extensible extensible) { Set profiles = new HashSet<>(2); @@ -95,6 +97,20 @@ public static Set getProfiles(Extensible extensible) { return profiles; } + public static boolean includedProfile(Extensible extensible, Set included, Set excluded) { + Set profiles = getProfiles(extensible); + + if (!excluded.isEmpty()) { + return excluded.stream().noneMatch(profiles::contains); + } + + if (included.isEmpty()) { + return true; + } + + return included.stream().anyMatch(profiles::contains); + } + public static void removeProfiles(Extensible extensible) { for (String name : extensionNames(extensible)) { if (name.startsWith(EXT_PROFILE_PREFIX)) { @@ -103,7 +119,7 @@ public static void removeProfiles(Extensible extensible) { } } - ////// + // Name public static String getName(Extensible extensible) { return get(extensible, PRIVATE_EXT_PREFIX + "name", String.class); @@ -113,7 +129,7 @@ public static void setName(Extensible extensible, String name) { set(extensible, PRIVATE_EXT_PREFIX + "name", name); } - ////// + // Hidden public static boolean isHidden(Extensible extensible) { return Boolean.TRUE.equals(get(extensible, PRIVATE_EXT_PREFIX + "hidden", Boolean.class)); @@ -123,7 +139,7 @@ public static void setHidden(Extensible extensible, Boolean hidden) { set(extensible, PRIVATE_EXT_PREFIX + "hidden", hidden); } - ////// + // Response-Code public static String getResponseCode(APIResponse response) { return get(response, PRIVATE_EXT_PREFIX + "response-code", String.class); @@ -133,7 +149,7 @@ public static void setResponseCode(APIResponse response, String responseCode) { set(response, PRIVATE_EXT_PREFIX + "response-code", responseCode); } - ////// + // Required-Default public static Boolean getRequiredDefault(RequestBody requestBody) { return get(requestBody, PRIVATE_EXT_PREFIX + "required-default", Boolean.class); @@ -151,7 +167,7 @@ public static void setRequiredDefault(RequestBody requestBody, Boolean requiredD set(requestBody, PRIVATE_EXT_PREFIX + "required-default", requiredDefault); } - ////// + // Is-Required-Set /** * Returns whether {@link RequestBody#setRequired(Boolean)} has been called on a request body. @@ -167,7 +183,7 @@ public static void setIsRequiredSet(RequestBody requestBody, Boolean requiredDef set(requestBody, PRIVATE_EXT_PREFIX + "is-required-set", requiredDefault); } - ////// + // Param-Ref /** * Implementation specific, set a reference to the Java method parameter, so that we can bind back to it later if needed @@ -183,7 +199,7 @@ public static void setParamRef(Parameter parameter, AnnotationTarget source) { set(parameter, PRIVATE_EXT_PREFIX + "param-ref", ref); } - ////// + // Method-Ref /** * Implementation specific, set a reference to the Java method, so that we can bind back to it later if needed @@ -199,7 +215,7 @@ public static void setMethodRef(Operation operation, ClassInfo resourceClass, Me set(operation, PRIVATE_EXT_PREFIX + "method-ref", ref); } - ////// + // Schema-Type-Observers @SuppressWarnings("unchecked") public static List getTypeObservers(Schema schema) { @@ -210,7 +226,7 @@ public static void setTypeObservers(Schema schema, List observers) { set(schema, PRIVATE_EXT_PREFIX + "schema-type-observers", observers); } - ////// + // Private extension accessors/mutator public static boolean isPrivateExtension(String name) { return name.startsWith(PRIVATE_EXT_PREFIX); @@ -224,7 +240,7 @@ public static void setPrivateExtension(Extensible extensible, String name, Ob extensible.addExtension(PRIVATE_EXT_PREFIX + name, value); } - /////// + // Directives @SuppressWarnings("unchecked") public static Collection getDirectives(Extensible extensible) { @@ -239,7 +255,7 @@ public static Collection getDirectives(Extensible extensible) { return Collections.emptySet(); } - /////// + // Reference helpers private static String createUniqueAnnotationTargetRef(AnnotationTarget annotationTarget) { switch (annotationTarget.kind()) {