diff --git a/openapi-annotations/src/main/java/io/micronaut/openapi/annotation/OpenAPIGroup.java b/openapi-annotations/src/main/java/io/micronaut/openapi/annotation/OpenAPIGroup.java index 559df82284..24c774062d 100644 --- a/openapi-annotations/src/main/java/io/micronaut/openapi/annotation/OpenAPIGroup.java +++ b/openapi-annotations/src/main/java/io/micronaut/openapi/annotation/OpenAPIGroup.java @@ -17,19 +17,23 @@ import java.lang.annotation.Documented; import java.lang.annotation.ElementType; +import java.lang.annotation.Repeatable; import java.lang.annotation.Retention; import java.lang.annotation.Target; import io.micronaut.context.annotation.AliasFor; +import io.swagger.v3.oas.annotations.extensions.Extension; import static java.lang.annotation.RetentionPolicy.SOURCE; /** * With this annotation, you can specify one or more groups that this endpoint will be included in, - * as well as specify groups from which this endpoint should be excluded. + * as well as specify groups from which this endpoint should be excluded. Also, you can set + * specific endpoint extensions for each group * * @since 4.10.0 */ +@Repeatable(OpenAPIGroups.class) @Retention(SOURCE) @Documented @Target({ElementType.PACKAGE, ElementType.TYPE, ElementType.METHOD}) @@ -51,4 +55,12 @@ * @return The names of the OpenAPi groups to exclude endpoints from. */ String[] exclude() default {}; + + /** + * The list of optional extensions only for these groups + * + * @return an optional array of extensions + * @since 6.7.0 + */ + Extension[] extensions() default {}; } diff --git a/openapi-annotations/src/main/java/io/micronaut/openapi/annotation/OpenAPIGroups.java b/openapi-annotations/src/main/java/io/micronaut/openapi/annotation/OpenAPIGroups.java new file mode 100644 index 0000000000..3338c4a332 --- /dev/null +++ b/openapi-annotations/src/main/java/io/micronaut/openapi/annotation/OpenAPIGroups.java @@ -0,0 +1,39 @@ +/* + * Copyright 2017-2024 original authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.micronaut.openapi.annotation; + +import java.lang.annotation.Documented; +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.Target; + +import static java.lang.annotation.RetentionPolicy.SOURCE; + +/** + * Allows {@link OpenAPIGroup} to be repeatable. + * + * @since 6.7.0 + */ +@Documented +@Retention(SOURCE) +@Target({ElementType.PACKAGE, ElementType.TYPE, ElementType.METHOD}) +public @interface OpenAPIGroups { + + /** + * @return A group of {@link OpenAPIGroup} + */ + OpenAPIGroup[] value() default {}; +} diff --git a/openapi/src/main/java/io/micronaut/openapi/visitor/AbstractOpenApiEndpointVisitor.java b/openapi/src/main/java/io/micronaut/openapi/visitor/AbstractOpenApiEndpointVisitor.java index c33691de63..dc59c32a1e 100644 --- a/openapi/src/main/java/io/micronaut/openapi/visitor/AbstractOpenApiEndpointVisitor.java +++ b/openapi/src/main/java/io/micronaut/openapi/visitor/AbstractOpenApiEndpointVisitor.java @@ -75,6 +75,7 @@ import io.micronaut.openapi.javadoc.JavadocDescription; import io.micronaut.openapi.swagger.core.util.PrimitiveType; import io.micronaut.openapi.visitor.group.EndpointInfo; +import io.micronaut.openapi.visitor.group.EndpointGroupInfo; import io.micronaut.openapi.visitor.group.GroupProperties; import io.micronaut.openapi.visitor.group.GroupProperties.PackageProperties; import io.micronaut.openapi.visitor.group.RouterVersioningProperties; @@ -88,6 +89,7 @@ import io.swagger.v3.oas.annotations.enums.Explode; import io.swagger.v3.oas.annotations.enums.ParameterIn; import io.swagger.v3.oas.annotations.enums.ParameterStyle; +import io.swagger.v3.oas.annotations.extensions.Extension; import io.swagger.v3.oas.annotations.tags.Tag; import io.swagger.v3.oas.annotations.tags.Tags; import io.swagger.v3.oas.models.Components; @@ -129,6 +131,7 @@ import static io.micronaut.openapi.visitor.SchemaUtils.TYPE_OBJECT; import static io.micronaut.openapi.visitor.SchemaUtils.getOperationOnPathItem; import static io.micronaut.openapi.visitor.SchemaUtils.isIgnoredHeader; +import static io.micronaut.openapi.visitor.SchemaUtils.processExtensions; import static io.micronaut.openapi.visitor.SchemaUtils.setOperationOnPathItem; import static io.micronaut.openapi.visitor.Utils.DEFAULT_MEDIA_TYPES; import static io.micronaut.openapi.visitor.Utils.getMediaType; @@ -1810,8 +1813,8 @@ private void processMicronautVersionAndGroup(io.swagger.v3.oas.models.Operation + '#' + CollectionUtils.toString(CollectionUtils.isEmpty(producesMediaTypes) ? DEFAULT_MEDIA_TYPES : producesMediaTypes); Map groupPropertiesMap = getGroupsPropertiesMap(context); - List groups = new ArrayList<>(); - List excludedGroups = new ArrayList<>(); + var groups = new HashMap(); + var excludedGroups = new ArrayList(); ClassElement classEl = methodEl.getDeclaringType(); PackageElement packageEl = classEl.getPackage(); @@ -1828,7 +1831,7 @@ private void processMicronautVersionAndGroup(io.swagger.v3.oas.models.Operation for (PackageProperties groupPackage : groupProperties.getPackages()) { boolean isInclude = groupPackage.isIncludeSubpackages() ? packageName.startsWith(groupPackage.getName()) : packageName.equals(groupPackage.getName()); if (isInclude) { - groups.add(groupProperties.getName()); + groups.put(groupProperties.getName(), new EndpointGroupInfo(groupProperties.getName())); } } } @@ -1881,20 +1884,39 @@ private void processMicronautVersionAndGroup(io.swagger.v3.oas.models.Operation excludedGroups)); } - private void processGroups(List groups, List excludedGroups, List> annotationValues, Map groupPropertiesMap) { + private void processGroups(Map groups, + List excludedGroups, + List> annotationValues, + Map groupPropertiesMap) { if (CollectionUtils.isEmpty(annotationValues)) { return; } for (AnnotationValue annValue : annotationValues) { - groups.addAll(List.of(annValue.stringValues("value"))); excludedGroups.addAll(List.of(annValue.stringValues("exclude"))); + + var extensionAnns = annValue.getAnnotations("extensions"); + for (var groupName : annValue.stringValues("value")) { + var extensions = new HashMap(); + if (CollectionUtils.isNotEmpty(extensionAnns)) { + for (Object extensionAnn : extensionAnns) { + processExtensions(extensions, (AnnotationValue) extensionAnn); + } + } + var groupInfo = groups.get(groupName); + if (groupInfo == null) { + groupInfo = new EndpointGroupInfo(groupName); + groups.put(groupName, groupInfo); + } + + groupInfo.getExtensions().putAll(extensions); + } } Set allKnownGroups = Utils.getAllKnownGroups(); - allKnownGroups.addAll(groups); + allKnownGroups.addAll(groups.keySet()); allKnownGroups.addAll(excludedGroups); } - private void processGroupsFromIncludedEndpoints(List groups, List excludedGroups, String className, Map groupPropertiesMap) { + private void processGroupsFromIncludedEndpoints(Map groups, List excludedGroups, String className, Map groupPropertiesMap) { if (CollectionUtils.isEmpty(Utils.getIncludedClassesGroups()) && CollectionUtils.isEmpty(Utils.getIncludedClassesGroupsExcluded())) { return; } @@ -1902,7 +1924,12 @@ private void processGroupsFromIncludedEndpoints(List groups, List classGroups = Utils.getIncludedClassesGroups() != null ? Utils.getIncludedClassesGroups().get(className) : Collections.emptyList(); List classExcludedGroups = Utils.getIncludedClassesGroupsExcluded() != null ? Utils.getIncludedClassesGroupsExcluded().get(className) : Collections.emptyList(); - groups.addAll(classGroups); + for (var classGroup : classGroups) { + if (groups.containsKey(classGroup)) { + continue; + } + groups.put(classGroup, new EndpointGroupInfo(classGroup)); + } excludedGroups.addAll(classExcludedGroups); Set allKnownGroups = Utils.getAllKnownGroups(); diff --git a/openapi/src/main/java/io/micronaut/openapi/visitor/OpenApiApplicationVisitor.java b/openapi/src/main/java/io/micronaut/openapi/visitor/OpenApiApplicationVisitor.java index 3b1f5bca0a..33acde50a4 100644 --- a/openapi/src/main/java/io/micronaut/openapi/visitor/OpenApiApplicationVisitor.java +++ b/openapi/src/main/java/io/micronaut/openapi/visitor/OpenApiApplicationVisitor.java @@ -55,6 +55,7 @@ import io.micronaut.openapi.postprocessors.OpenApiOperationsPostProcessor; import io.micronaut.openapi.view.OpenApiViewConfig; import io.micronaut.openapi.visitor.group.EndpointInfo; +import io.micronaut.openapi.visitor.group.EndpointGroupInfo; import io.micronaut.openapi.visitor.group.GroupProperties; import io.micronaut.openapi.visitor.group.OpenApiInfo; import io.swagger.v3.oas.annotations.OpenAPIDefinition; @@ -499,18 +500,18 @@ private Map, OpenApiInfo> divideOpenapiByGroupsAndVersions( commonEndpoints.add(endpointInfo); continue; } - for (String group : endpointInfo.getGroups()) { - if (CollectionUtils.isNotEmpty(endpointInfo.getExcludedGroups()) && endpointInfo.getExcludedGroups().contains(group)) { + for (EndpointGroupInfo endpointGroupInfo : endpointInfo.getGroups().values()) { + if (CollectionUtils.isNotEmpty(endpointInfo.getExcludedGroups()) && endpointInfo.getExcludedGroups().contains(endpointGroupInfo)) { continue; } - OpenAPI newOpenApi = addOpenApiInfo(group, endpointInfo.getVersion(), openApi, result, context); - addOperation(endpointInfo, newOpenApi); + OpenAPI newOpenApi = addOpenApiInfo(endpointGroupInfo.getName(), endpointInfo.getVersion(), openApi, result, context); + addOperation(endpointInfo, newOpenApi, endpointGroupInfo, context); } // if we have only versions without groups if (CollectionUtils.isEmpty(endpointInfo.getGroups())) { OpenAPI newOpenApi = addOpenApiInfo(null, endpointInfo.getVersion(), openApi, result, context); - addOperation(endpointInfo, newOpenApi); + addOperation(endpointInfo, newOpenApi, null, context); } } } @@ -530,14 +531,14 @@ private Map, OpenApiInfo> divideOpenapiByGroupsAndVersions( if (CollectionUtils.isNotEmpty(commonEndpoint.getExcludedGroups()) && commonEndpoint.getExcludedGroups().contains(group)) { continue; } - addOperation(commonEndpoint, groupOpenApi); + addOperation(commonEndpoint, groupOpenApi, null, context); } } return result; } - private void addOperation(EndpointInfo endpointInfo, OpenAPI openApi) { + private void addOperation(EndpointInfo endpointInfo, OpenAPI openApi, @Nullable EndpointGroupInfo endpointGroupInfo, VisitorContext context) { if (openApi == null) { return; } @@ -549,33 +550,55 @@ private void addOperation(EndpointInfo endpointInfo, OpenAPI openApi) { PathItem pathItem = paths.computeIfAbsent(endpointInfo.getUrl(), (pathUrl) -> new PathItem()); Operation operation = getOperationOnPathItem(pathItem, endpointInfo.getHttpMethod()); if (operation == null) { - setOperationOnPathItem(pathItem, endpointInfo.getHttpMethod(), endpointInfo.getOperation()); + Operation opCopy = null; + try { + opCopy = OpenApiUtils.getJsonMapper().treeToValue(OpenApiUtils.getJsonMapper().valueToTree(endpointInfo.getOperation()), Operation.class); + if (endpointGroupInfo != null) { + addExtensions(opCopy, endpointGroupInfo.getExtensions()); + } + } catch (JsonProcessingException e) { + warn("Error\n" + Utils.printStackTrace(e), context); + } + setOperationOnPathItem(pathItem, endpointInfo.getHttpMethod(), opCopy != null ? opCopy : endpointInfo.getOperation()); return; } - setOperationOnPathItem(pathItem, endpointInfo.getHttpMethod(), SchemaUtils.mergeOperations(operation, endpointInfo.getOperation())); + var mergedOp = SchemaUtils.mergeOperations(operation, endpointInfo.getOperation()); + if (endpointGroupInfo != null) { + addExtensions(mergedOp, endpointGroupInfo.getExtensions()); + } + setOperationOnPathItem(pathItem, endpointInfo.getHttpMethod(), mergedOp); + } + + private void addExtensions(Operation operation, Map extensions) { + if (CollectionUtils.isEmpty(extensions)) { + return; + } + for (var ext : extensions.entrySet()) { + operation.addExtension(ext.getKey().toString(), ext.getValue()); + } } - private OpenAPI addOpenApiInfo(String group, String version, OpenAPI openApi, + private OpenAPI addOpenApiInfo(String groupName, String version, OpenAPI openApi, Map, OpenApiInfo> openApiInfoMap, VisitorContext context) { - GroupProperties groupProperties = getGroupProperties(group, context); + GroupProperties groupProperties = getGroupProperties(groupName, context); boolean hasGroupProperties = groupProperties != null; - var key = Pair.of(group, version); + var key = Pair.of(groupName, version); OpenApiInfo openApiInfo = openApiInfoMap.get(key); OpenAPI newOpenApi; if (openApiInfo == null) { Map knownOpenApis = Utils.getOpenApis(); - if (CollectionUtils.isNotEmpty(knownOpenApis) && knownOpenApis.containsKey(group)) { - newOpenApi = knownOpenApis.get(group); + if (CollectionUtils.isNotEmpty(knownOpenApis) && knownOpenApis.containsKey(groupName)) { + newOpenApi = knownOpenApis.get(groupName); } else { newOpenApi = new OpenAPI(); } openApiInfo = new OpenApiInfo( version, - group, + groupName, hasGroupProperties ? groupProperties.getDisplayName() : null, hasGroupProperties ? groupProperties.getFilename() : null, !hasGroupProperties || groupProperties.getAdocEnabled() == null || groupProperties.getAdocEnabled(), @@ -593,12 +616,13 @@ private OpenAPI addOpenApiInfo(String group, String version, OpenAPI openApi, return null; } - if (CollectionUtils.isEmpty(knownOpenApis) || !knownOpenApis.containsKey(group)) { + if (CollectionUtils.isEmpty(knownOpenApis) || !knownOpenApis.containsKey(groupName)) { newOpenApi.setTags(openApiCopy.getTags()); newOpenApi.setServers(openApiCopy.getServers()); newOpenApi.setInfo(openApiCopy.getInfo()); newOpenApi.setSecurity(openApiCopy.getSecurity()); newOpenApi.setExternalDocs(openApiCopy.getExternalDocs()); + newOpenApi.setExtensions(openApiCopy.getExtensions()); } // if we have SecuritySchemes specified only for group diff --git a/openapi/src/main/java/io/micronaut/openapi/visitor/group/EndpointGroupInfo.java b/openapi/src/main/java/io/micronaut/openapi/visitor/group/EndpointGroupInfo.java new file mode 100644 index 0000000000..871d71002e --- /dev/null +++ b/openapi/src/main/java/io/micronaut/openapi/visitor/group/EndpointGroupInfo.java @@ -0,0 +1,45 @@ +/* + * Copyright 2017-2024 original authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.micronaut.openapi.visitor.group; + +import java.util.HashMap; +import java.util.Map; + +import io.micronaut.core.annotation.Internal; + +/** + * Entity to storage information about group with specific properties for this operation-group. + * + * @since 6.7.0 + */ +@Internal +public final class EndpointGroupInfo { + + private final String name; + private final Map extensions = new HashMap<>(); + + public EndpointGroupInfo(String name) { + this.name = name; + } + + public String getName() { + return name; + } + + public Map getExtensions() { + return extensions; + } +} diff --git a/openapi/src/main/java/io/micronaut/openapi/visitor/group/EndpointInfo.java b/openapi/src/main/java/io/micronaut/openapi/visitor/group/EndpointInfo.java index b4dc958d5d..fde813b30c 100644 --- a/openapi/src/main/java/io/micronaut/openapi/visitor/group/EndpointInfo.java +++ b/openapi/src/main/java/io/micronaut/openapi/visitor/group/EndpointInfo.java @@ -16,6 +16,7 @@ package io.micronaut.openapi.visitor.group; import java.util.List; +import java.util.Map; import io.micronaut.core.annotation.Internal; import io.micronaut.http.HttpMethod; @@ -36,10 +37,13 @@ public final class EndpointInfo { private final MethodElement method; private final Operation operation; private final String version; - private final List groups; + private final Map groups; private final List excludedGroups; - public EndpointInfo(String url, HttpMethod httpMethod, MethodElement method, Operation operation, String version, List groups, List excludedGroups) { + public EndpointInfo(String url, HttpMethod httpMethod, MethodElement method, + Operation operation, String version, + Map groups, + List excludedGroups) { this.url = url; this.httpMethod = httpMethod; this.method = method; @@ -69,7 +73,7 @@ public String getVersion() { return version; } - public List getGroups() { + public Map getGroups() { return groups; } diff --git a/openapi/src/test/groovy/io/micronaut/openapi/visitor/OpenApiGroupSpec.groovy b/openapi/src/test/groovy/io/micronaut/openapi/visitor/OpenApiGroupSpec.groovy index a89ddbb2f0..a4d1f3ab69 100644 --- a/openapi/src/test/groovy/io/micronaut/openapi/visitor/OpenApiGroupSpec.groovy +++ b/openapi/src/test/groovy/io/micronaut/openapi/visitor/OpenApiGroupSpec.groovy @@ -105,4 +105,129 @@ public class MyBean {} apiPublic.components.securitySchemes.authorizer apiPublic.components.securitySchemes.authorizer.type == SecurityScheme.Type.APIKEY } + + void "test group specific operation extensions"() { + + when: + buildBeanDefinition("test.MyBean", ''' +package test; + +import io.micronaut.http.annotation.Controller; +import io.micronaut.http.annotation.Get; +import io.micronaut.openapi.annotation.OpenAPIGroup; +import io.micronaut.openapi.annotation.OpenAPIGroupInfo; +import io.swagger.v3.oas.annotations.OpenAPIDefinition; +import io.swagger.v3.oas.annotations.enums.SecuritySchemeIn; +import io.swagger.v3.oas.annotations.enums.SecuritySchemeType; +import io.swagger.v3.oas.annotations.extensions.Extension; +import io.swagger.v3.oas.annotations.extensions.ExtensionProperty; +import io.swagger.v3.oas.annotations.info.Info; +import io.swagger.v3.oas.annotations.security.SecurityScheme; + +@Controller +class MyController { + + @OpenAPIGroup( + names = "private", + extensions = { + @Extension(name = "amazon-apigateway-integration", properties = { + @ExtensionProperty(name = "uri", value = "${amz.private.lambda-integration.uri}"), + @ExtensionProperty(name = "httpMethod", value = "${amz.private.lambda-integration.http-method}") + }), + @Extension(name = "google-apigateway-integration", properties = { + @ExtensionProperty(name = "uri", value = "${google.private.lambda-integration.uri}"), + @ExtensionProperty(name = "httpMethod", value = "${google.private.lambda-integration.http-method}") + }) + } + ) + @OpenAPIGroup( + names = "public", + extensions = { + @Extension(name = "amazon-apigateway-integration", properties = { + @ExtensionProperty(name = "uri", value = "${amz.public.lambda-integration.uri}"), + @ExtensionProperty(name = "httpMethod", value = "${amz.public.lambda-integration.http-method}") + }), + @Extension(name = "google-apigateway-integration", properties = { + @ExtensionProperty(name = "uri", value = "${google.public.lambda-integration.uri}"), + @ExtensionProperty(name = "httpMethod", value = "${google.public.lambda-integration.http-method}") + }) + } + ) + @Get("/id/{id}") + String get(String id) { + return null; + } + + @OpenAPIGroup("public") + @Get("/name/{name}") + String getByName(String name) { + return null; + } + + // common + @Get("/all") + String getAll() { + return null; + } +} + +@OpenAPIGroupInfo( + names = "private", + info = @OpenAPIDefinition( + info = @Info( + title = "Private api" + ) + ) +) +@OpenAPIGroupInfo( + names = "public", + info = @OpenAPIDefinition( + info = @Info( + title = "Public api" + ) + ) +) +@SecurityScheme( + name = "common", + type = SecuritySchemeType.HTTP, + scheme = "basic", + in = SecuritySchemeIn.HEADER +) +class Application { +} + +@jakarta.inject.Singleton +public class MyBean {} + +''') + + then: + def openApis = Utils.testReferences + openApis + openApis.size() == 2 + + def apiPrivate = openApis.get(Pair.of("private", null)).getOpenApi() + def apiPublic = openApis.get(Pair.of("public", null)).getOpenApi() + + def opPrivateExt = apiPrivate.paths.'/id/{id}'.get.extensions + def opPublicExt = apiPublic.paths.'/id/{id}'.get.extensions + + opPrivateExt + opPrivateExt.size() == 2 + opPrivateExt.'x-amazon-apigateway-integration' + ((Map) opPrivateExt.'x-amazon-apigateway-integration').uri == '${amz.private.lambda-integration.uri}' + ((Map) opPrivateExt.'x-amazon-apigateway-integration').httpMethod == '${amz.private.lambda-integration.http-method}' + opPrivateExt.'x-google-apigateway-integration' + ((Map) opPrivateExt.'x-google-apigateway-integration').uri == '${google.private.lambda-integration.uri}' + ((Map) opPrivateExt.'x-google-apigateway-integration').httpMethod == '${google.private.lambda-integration.http-method}' + + opPublicExt + opPublicExt.size() == 2 + opPublicExt.'x-amazon-apigateway-integration' + ((Map) opPublicExt.'x-amazon-apigateway-integration').uri == '${amz.public.lambda-integration.uri}' + ((Map) opPublicExt.'x-amazon-apigateway-integration').httpMethod == '${amz.public.lambda-integration.http-method}' + opPublicExt.'x-google-apigateway-integration' + ((Map) opPublicExt.'x-google-apigateway-integration').uri == '${google.public.lambda-integration.uri}' + ((Map) opPublicExt.'x-google-apigateway-integration').httpMethod == '${google.public.lambda-integration.http-method}' + } }