codeconsole commented on code in PR #16275:
URL: https://github.com/apache/grails-core/pull/16275#discussion_r4102323857
##########
grails-doc/src/en/guide/upgrading/upgrading80x.adoc:
##########
@@ -4102,3 +4102,30 @@ If you order the Spring Security chain ahead of
`GrailsFilters.FIRST`, its heade
Filters ordered ahead of `GrailsFilters.FIRST`, such as Spring Boot's
forwarded-header filter and, in a WAR deployment, its `ErrorPageFilter`, run
outside the Grails filter, so a response one of them serves itself receives no
default headers.
If a reverse proxy in front of the application already sends these headers
(for example through nginx's `add_header`), set
`grails.security.headers.defaults: auto` so requests that arrive through the
proxy (detected from forwarded request headers,
`server.forward-headers-strategy`, or an active cloud platform) receive only
the headers you configure explicitly, or strip the application's copies at the
proxy.
To disable or customize the Grails defaults, configure
`grails.security.headers.*`; see the Security guide's HTTP Security Headers
section for the full list and the reverse-proxy guidance.
+
+==== 74. A List of Objects in Configuration Binds to Its Declared Type
+
+A list of objects in `application.yml` or `application.groovy`, such as:
+
+[source,yaml]
+.application.yml
+----
+springdoc:
+ group-configs:
+ - group: sales
+ paths-to-match: /api/v1/**
+----
+
+is now presented to Spring element by element, under the indexed names Spring
Boot binds a list from,
+such as `springdoc.group-configs[0].group`, the way Spring Boot's own YAML
loader presents it. A
+`@ConfigurationProperties` class declaring a `List` of objects is bound with
instances of that type.
+Previously the whole list was also presented under its own name, so Spring
Boot bound it as a list of
+maps, and code reading the declared type failed with a `ClassCastException`.
springdoc failed that
+way on `springdoc.group-configs` before the application could start.
+
+`Environment.getProperty('springdoc.group-configs')` now returns `null` and
+`Environment.containsProperty` returns `false`, as they do in a Spring Boot
application, so a
+`@Value('${springdoc.group-configs}')` without a default fails to resolve.
Read such a list through
Review Comment:
Added in f61a7fbd32: `config-report` lists such a list a row per value, a
`${...}` placeholder naming it no longer resolves, and standalone GORM given a
`ConfigurableEnvironment` no longer finds it under its own name, where a Grails
application's datastore, given `grailsApplication.config`, still does.
##########
grails-web-databinding/src/main/groovy/grails/web/databinding/DataBindingUtils.java:
##########
@@ -146,6 +146,36 @@ public static BindingResult bindObjectToInstance(Object
object, Object source) {
return bindObjectToInstance(object, source,
getBindingIncludeList(object), Collections.emptyList(), null);
}
+ /**
+ * The names of the properties {@link #bindObjectToInstance(Object,
Object)} binds on an
+ * instance of the given type: the include list Grails generates for a
domain class or a command
+ * object, as {@code grails.databinding.denyByDefault} selects it.
+ *
+ * @param type a type with a no-argument constructor
+ * @return the bindable property names, or {@code null} where binding the
type is not restricted
+ * or an instance cannot be created
+ * @since 8.0
+ */
+ public static List<String> getBindingIncludeListForType(final Class<?>
type) {
Review Comment:
a842adf492: the include list is now read from the class, the generated lists
and the `bindable` constraints, and kept for each class, in an internal
`org.grails.web.databinding.BindingIncludeLists` that `DataBindingUtils` binds
through, so binding and the description read one implementation.
`getBindingIncludeListForType` is gone, so there is no new public databinding
API, and nothing is instantiated: a test counts constructor calls, and a class
without a no-arg constructor now gets its list.
##########
grails-openapi/src/main/groovy/org/grails/openapi/ActionAnnotations.groovy:
##########
@@ -0,0 +1,429 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you 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 org.grails.openapi
+
+import java.lang.annotation.Annotation
+import java.lang.reflect.AnnotatedElement
+import java.lang.reflect.Array
+import java.lang.reflect.Method
+import java.lang.reflect.Modifier
+import java.lang.reflect.Parameter
+
+import groovy.transform.CompileStatic
+
+import io.swagger.v3.core.util.AnnotationsUtils
+import io.swagger.v3.core.util.ParameterProcessor
+import io.swagger.v3.oas.annotations.ExternalDocumentation as
ExternalDocumentationAnnotation
+import io.swagger.v3.oas.annotations.Hidden
+import io.swagger.v3.oas.annotations.Operation as OperationAnnotation
+import io.swagger.v3.oas.annotations.Parameter as ParameterAnnotation
+import io.swagger.v3.oas.annotations.media.Schema as SchemaAnnotation
+import io.swagger.v3.oas.annotations.parameters.RequestBody as
RequestBodyAnnotation
+import io.swagger.v3.oas.annotations.responses.ApiResponse as
ApiResponseAnnotation
+import io.swagger.v3.oas.annotations.security.SecurityRequirement as
SecurityRequirementAnnotation
+import io.swagger.v3.oas.annotations.tags.Tag as TagAnnotation
+import io.swagger.v3.oas.models.Components
+import io.swagger.v3.oas.models.ExternalDocumentation
+import io.swagger.v3.oas.models.Operation
+import io.swagger.v3.oas.models.headers.Header
+import io.swagger.v3.oas.models.media.Content
+import io.swagger.v3.oas.models.media.Schema
+import io.swagger.v3.oas.models.parameters.Parameter as ParameterModel
+import io.swagger.v3.oas.models.parameters.RequestBody
+import io.swagger.v3.oas.models.responses.ApiResponse
+import io.swagger.v3.oas.models.responses.ApiResponses
+import io.swagger.v3.oas.models.security.SecurityRequirement
+
+import grails.web.RequestParameter
+
+/**
+ * Reads the OpenAPI annotations an application declares on a controller and
its actions, so that
+ * what is derived from the URL mappings can be corrected or enriched.
+ *
+ * <p>The annotations are converted by swagger-core itself, so they mean what
they mean on a
+ * Spring or JAX-RS endpoint: a schema, an array schema, a media type, an
example or a header is
+ * described exactly as declared, and a type an annotation names is added to
the components.</p>
+ */
+@CompileStatic
+class ActionAnnotations {
+
+ static final String DEFAULT_MEDIA_TYPE = 'application/json'
+
+ private static final String[] NO_MEDIA_TYPES = new String[0]
+ private static final String[] DEFAULT_MEDIA_TYPES = [DEFAULT_MEDIA_TYPE]
as String[]
+
+ /**
+ * @return whether the controller as a whole is withheld from the document
+ */
+ static boolean isHidden(Class<?> controllerClass) {
+ controllerClass != null && controllerClass.isAnnotationPresent(Hidden)
+ }
+
+ /**
+ * @return whether the action is withheld from the document, either
through {@code @Hidden} or
+ * through {@code @Operation(hidden = true)}
+ */
+ static boolean isHidden(Class<?> controllerClass, String actionName) {
+ actionMethods(controllerClass, actionName).any { Method method ->
+ method.isAnnotationPresent(Hidden) ||
method.getAnnotation(OperationAnnotation)?.hidden()
+ }
+ }
+
+ /**
+ * @return whether the action declares {@code @Operation} or its
controller declares
+ * {@code @Tag}, which is what an application limiting the document to
what it annotated opts in
+ */
+ static boolean isAnnotated(Class<?> controllerClass, String actionName) {
+ declaredTags(controllerClass) || actionMethods(controllerClass,
actionName).any { Method method ->
+ method.isAnnotationPresent(OperationAnnotation)
+ }
+ }
+
+ /**
+ * The tags a controller declares, which group its operations and can
describe the group.
+ */
+ static List<TagAnnotation> declaredTags(Class<?> controllerClass) {
+ controllerClass == null
+ ? Collections.<TagAnnotation> emptyList()
+ : repeatable(controllerClass, TagAnnotation).findAll {
TagAnnotation tag -> tag.name() }.toList()
+ }
+
+ /**
+ * @return whether the action declares its request body, which then
replaces the derived one
+ */
+ static boolean declaresRequestBody(Class<?> controllerClass, String
actionName) {
+ actionMethods(controllerClass, actionName).any { Method method ->
+ method.isAnnotationPresent(RequestBodyAnnotation) ||
hasRequestBody(method.getAnnotation(OperationAnnotation))
+ }
+ }
+
+ /**
+ * Applies the declared annotations over the operation derived for the
action. The controller's
+ * annotations apply first, so an action can refine what its controller
declares, and a value
+ * that is not declared is left as derived.
+ */
+ static void apply(Operation operation, Class<?> controllerClass, String
actionName,
+ Components components, boolean openapi31) {
+ if (controllerClass == null) {
+ return
+ }
+ repeatable(controllerClass, ApiResponseAnnotation).each {
ApiResponseAnnotation declared ->
+ applyResponse(operation, declared, components, openapi31)
+ }
+ applySecurity(operation, repeatable(controllerClass,
SecurityRequirementAnnotation))
+
+ for (Method method : actionMethods(controllerClass, actionName)) {
+ applyOperation(operation,
method.getAnnotation(OperationAnnotation), components, openapi31)
+ repeatable(method, ApiResponseAnnotation).each {
ApiResponseAnnotation declared ->
+ applyResponse(operation, declared, components, openapi31)
+ }
+ repeatable(method, ParameterAnnotation).each { ParameterAnnotation
declared ->
+ applyParameter(operation, declared, null, components,
openapi31)
+ }
+ for (Parameter parameter : method.parameters) {
+ ParameterAnnotation declared =
parameter.getAnnotation(ParameterAnnotation)
+ if (declared != null) {
+ applyParameter(operation, declared, parameter, components,
openapi31)
+ }
+ }
+ applyRequestBody(operation,
method.getAnnotation(RequestBodyAnnotation), components, openapi31)
+ applySecurity(operation, repeatable(method,
SecurityRequirementAnnotation))
+ }
+ }
+
+ /**
+ * The command object an action binds, if it takes one.
+ *
+ * <p>Follows the rule the controller transform applies: a parameter of a
simple type is bound
+ * from the request parameters, an {@code Object}, an interface or an
abstract class is not bound
+ * at all, and any other type is bound as a command object.</p>
+ *
+ * @return the command object type, or {@code null} when the action takes
none
+ */
+ static Class<?> commandObjectType(Class<?> controllerClass, String
actionName) {
+ Method action = actionMethod(controllerClass, actionName)
+ action?.parameterTypes?.find { Class<?> type -> isCommandObject(type) }
+ }
+
+ /**
+ * The request parameters an action binds by name: the parameters of a
simple type. Their names
+ * are only known where the application is compiled to keep them.
+ */
+ static List<Parameter> requestParameters(Class<?> controllerClass, String
actionName) {
+ Method action = actionMethod(controllerClass, actionName)
+ (action?.parameters ?: new Parameter[0]).findAll { Parameter parameter
->
+ (parameter.namePresent ||
parameter.getAnnotation(RequestParameter) != null) && isSimple(parameter.type)
+ }.toList()
+ }
+
+ /**
+ * The name of the request parameter an action parameter is bound from:
the one
+ * {@code @RequestParameter} names, or its own.
+ */
+ static String requestParameterName(Parameter parameter) {
+ parameter.getAnnotation(RequestParameter)?.value() ?: parameter.name
+ }
+
+ /**
+ * Whether the controller transform binds a parameter of the type from the
request parameters by
+ * name: a primitive, a primitive wrapper, {@code String}, or {@code
Serializable} - the type a
+ * domain identifier is declared as.
+ */
+ private static boolean isSimple(Class<?> type) {
+ type.primitive || type in [Integer, Float, Long, Double, Short,
Boolean, Byte, Character, String, Serializable]
+ }
+
+ private static boolean isCommandObject(Class<?> type) {
+ if (type == null || type.array || isSimple(type) || type == Object) {
+ return false
+ }
+ !type.interface && !Modifier.isAbstract(type.modifiers)
+ }
+
+ private static boolean hasRequestBody(OperationAnnotation declared) {
+ declared != null && (declared.requestBody().content() ||
declared.requestBody().description())
+ }
+
+ private static void applyOperation(Operation operation,
OperationAnnotation declared,
+ Components components, boolean
openapi31) {
+ if (declared == null) {
+ return
+ }
+ if (declared.summary()) {
+ operation.setSummary(declared.summary())
+ }
+ if (declared.description()) {
+ operation.setDescription(declared.description())
+ }
+ if (declared.operationId()) {
+ operation.setOperationId(declared.operationId())
+ }
+ if (declared.tags()) {
+ operation.setTags(declared.tags().toList())
+ }
+ if (declared.deprecated()) {
+ operation.setDeprecated(true)
+ }
+ ExternalDocumentation externalDocs =
externalDocumentation(declared.externalDocs())
+ if (externalDocs != null) {
+ operation.setExternalDocs(externalDocs)
+ }
+ declared.parameters().each { ParameterAnnotation parameter ->
+ applyParameter(operation, parameter, null, components, openapi31)
+ }
+ declared.responses().each { ApiResponseAnnotation response ->
+ applyResponse(operation, response, components, openapi31)
+ }
+ if (hasRequestBody(declared)) {
+ applyRequestBody(operation, declared.requestBody(), components,
openapi31)
+ }
+ applySecurity(operation, declared.security().toList())
+ }
+
+ private static void applyResponse(Operation operation,
ApiResponseAnnotation declared,
+ Components components, boolean
openapi31) {
+ String code = declared.responseCode() ?: 'default'
+ ApiResponses responses = operation.responses ?: new ApiResponses()
+ ApiResponse response = responses.get(code) ?: new ApiResponse()
+
+ if (declared.ref()) {
+ response = new ApiResponse().$ref(declared.ref())
+ }
+ else {
+ if (declared.description()) {
+ response.setDescription(declared.description())
+ }
+ // An action's return type is not declared, so a response body can
only be described
+ // by the annotation. Where one is declared it replaces what was
derived.
+ Content content = AnnotationsUtils.getContent(declared.content(),
NO_MEDIA_TYPES, DEFAULT_MEDIA_TYPES,
+ null, components, null, openapi31).orElse(null)
+ if (content) {
+ response.setContent(content)
+ }
+ Map<String, Header> headers =
AnnotationsUtils.getHeaders(declared.headers(), components, null, openapi31)
+ .orElse(null)
+ if (headers) {
+ response.setHeaders(headers)
+ }
+ if (response.description == null) {
+ response.setDescription(code)
+ }
+ }
+
+ responses.addApiResponse(code, response)
+ operation.setResponses(responses)
+ }
+
+ /**
+ * A parameter the operation already describes, such as a path variable,
is refined by what
+ * is declared; any other declared parameter is added, which is how a
query parameter or a
+ * header an action reads is described.
+ */
+ private static void applyParameter(Operation operation,
ParameterAnnotation declared, Parameter reflected,
+ Components components, boolean
openapi31) {
+ String name = declared.name() ?: (reflected != null ?
requestParameterName(reflected) : null)
+ if (!name) {
+ return
+ }
+ ParameterModel existing = operation.parameters?.find { ParameterModel
candidate ->
+ candidate.name == name && (!declared.in().toString() ||
candidate.in == declared.in().toString())
+ }
+ if (declared.hidden()) {
+ if (existing != null) {
+ operation.parameters.remove(existing)
+ }
+ return
+ }
+
+ ParameterModel parameter = existing ?: new
ParameterModel().name(name).in(parameterLocation(declared))
+ Schema derived = existing?.schema
+ List<Annotation> annotations = [(Annotation) declared]
+ ParameterProcessor.applyAnnotations(parameter,
reflected?.parameterizedType ?: declaredType(declared), annotations,
+ components, NO_MEDIA_TYPES, DEFAULT_MEDIA_TYPES, null,
openapi31)
+ if (derived != null && (reflected == null || reflected.type ==
Object)) {
+ keepDerivedType(parameter, derived, declared)
+ }
+ if (parameter.in == null) {
+ parameter.setIn(parameterLocation(declared))
+ }
+ if (parameter.in == 'path') {
+ parameter.setRequired(true)
+ }
+ if (existing == null) {
+ operation.addParametersItem(parameter)
+ }
+ }
+
+ /**
+ * A parameter declared without a type describes it as a string, so one
that refines a
+ * parameter the operation already describes, such as the identifier of
the resource, keeps
+ * the type it was described with.
+ */
+ private static void keepDerivedType(ParameterModel parameter, Schema
derived, ParameterAnnotation declared) {
+ if (declared.content() ||
AnnotationsUtils.hasArrayAnnotation(declared.array())) {
+ return
+ }
+ SchemaAnnotation schema = declared.schema()
+ if (!AnnotationsUtils.hasSchemaAnnotation(schema)) {
+ parameter.setSchema(derived)
+ return
+ }
+ Schema described = parameter.schema
+ if (described == null || schema.implementation() != Void ||
schema.type() || schema.types() || schema.ref()) {
+ return
+ }
+ described.setType(derived.type)
+ described.setTypes(derived.types)
+ if (!schema.format()) {
+ described.setFormat(derived.format)
+ }
+ }
+
+ /**
+ * The type a parameter declared on the method rather than on a method
parameter is described
+ * as: the implementation its schema names, or a string.
+ */
+ private static Class<?> declaredType(ParameterAnnotation declared) {
+ Class<?> implementation = declared.schema().implementation()
+ if (implementation == null || implementation == Void) {
+ implementation = declared.array().schema().implementation()
+ if (implementation != null && implementation != Void) {
+ return Array.newInstance(implementation, 0).getClass()
+ }
+ return String
+ }
+ implementation
+ }
+
+ private static String parameterLocation(ParameterAnnotation declared) {
+ declared.in().toString() ?: 'query'
+ }
+
+ private static void applyRequestBody(Operation operation,
RequestBodyAnnotation declared,
+ Components components, boolean
openapi31) {
+ if (declared == null) {
+ return
+ }
+ RequestBody requestBody = new RequestBody()
+ if (declared.description()) {
+ requestBody.setDescription(declared.description())
+ }
+ if (declared.required()) {
+ requestBody.setRequired(true)
+ }
+ if (declared.ref()) {
+ requestBody.set$ref(declared.ref())
+ }
+ Content content = AnnotationsUtils.getContent(declared.content(),
NO_MEDIA_TYPES, DEFAULT_MEDIA_TYPES,
+ null, components, null, openapi31).orElse(null)
+ if (content) {
+ requestBody.setContent(content)
+ }
+ operation.setRequestBody(requestBody)
+ }
+
+ private static void applySecurity(Operation operation,
Collection<SecurityRequirementAnnotation> declared) {
+ declared?.each { SecurityRequirementAnnotation requirement ->
+ if (!requirement.name()) {
+ return
+ }
+ SecurityRequirement model = new
SecurityRequirement().addList(requirement.name(), requirement.scopes().toList())
+ if (!operation.security?.contains(model)) {
+ operation.addSecurityItem(model)
+ }
+ }
+ }
+
+ private static ExternalDocumentation
externalDocumentation(ExternalDocumentationAnnotation declared) {
+ if (declared == null || !declared.url()) {
+ return null
+ }
+ new
ExternalDocumentation().url(declared.url()).description(declared.description()
?: null)
+ }
+
+ /**
+ * The annotations of a repeatable type an element declares, whether
declared once or several
+ * times.
+ */
+ private static <A extends Annotation> List<A> repeatable(AnnotatedElement
element, Class<A> type) {
+ element == null ? Collections.<A> emptyList() :
Arrays.asList(element.getAnnotationsByType(type))
+ }
+
+ /**
+ * The method an action is declared as. Grails compiles an action that
takes parameters into a
+ * second method without them, which binds the parameters and calls the
declared one, so the
+ * declared one is the one that takes them.
+ *
+ * @return the declared method, or {@code null} where the controller has
no method of that name
+ */
+ static Method actionMethod(Class<?> controllerClass, String actionName) {
+ actionMethods(controllerClass, actionName).max { Method method ->
method.parameterCount }
Review Comment:
ac451d18b1: an action is taken from the most derived class declaring it,
then by parameter count. Tested with your `@Operation @Override def index()`
shape and a filter keeping `@Operation` actions; `GET` now survives.
##########
grails-openapi/src/main/groovy/org/grails/openapi/ControllerCatalog.groovy:
##########
@@ -0,0 +1,199 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you 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 org.grails.openapi
+
+import java.lang.reflect.Method
+import java.lang.reflect.Parameter
+
+import groovy.transform.CompileStatic
+
+import org.springframework.context.ApplicationContext
+import org.springframework.core.GenericTypeResolver
+
+import grails.core.GrailsApplication
+import grails.core.GrailsClass
+import grails.core.GrailsControllerClass
+import grails.rest.RestfulController
+import org.grails.core.artefact.ControllerArtefactHandler
+
+/**
+ * The controllers of an application, as a description reads them: by the name
and namespace a
+ * mapping reaches them by, and by what they serve.
+ */
+@CompileStatic
+class ControllerCatalog {
+
+ private static final String RESPONSE_FORMATS = 'responseFormats'
+ private static final String ALLOWED_METHODS = 'allowedMethods'
+ private static final String ID = 'id'
+
+ private final GrailsApplication grailsApplication
+ private final Map<String, GrailsControllerClass> byKey = [:]
+ private final Map<String, List<GrailsControllerClass>> byName = [:]
+ private final Map<Class<?>, Object> instances = [:]
+ private final Map<Class<?>, Class<?>> boundResources = [:]
+
+ ControllerCatalog(GrailsApplication grailsApplication) {
+ this.grailsApplication = grailsApplication
+ if (grailsApplication == null) {
+ return
+ }
+ for (GrailsClass artefact :
grailsApplication.getArtefacts(ControllerArtefactHandler.TYPE)) {
+ if (artefact instanceof GrailsControllerClass) {
+ GrailsControllerClass controller = (GrailsControllerClass)
artefact
+ byKey[key(controller.namespace,
controller.logicalPropertyName)] = controller
+ byName.computeIfAbsent(controller.logicalPropertyName) { []
}.add(controller)
+ }
+ }
+ }
+
+ /**
+ * @return every controller of the application
+ */
+ Collection<GrailsControllerClass> getControllers() {
+ byKey.values()
+ }
+
+ /**
+ * The controller a mapping dispatches to. A mapping that names a
namespace reaches that
+ * namespace's controller; one that names none reaches the controller
without a namespace, or
+ * the only controller of that name.
+ */
+ GrailsControllerClass controllerFor(String name, String namespace) {
+ GrailsControllerClass exact = byKey[key(namespace, name)]
+ if (exact != null || namespace) {
+ return exact
+ }
+ List<GrailsControllerClass> named = byName[name]
+ named?.size() == 1 ? named.first() : null
+ }
+
+ /**
+ * The formats a controller declares it responds in, for every action or
by action.
+ */
+ Object responseFormats(GrailsControllerClass controller) {
+ controller?.getPropertyValue(RESPONSE_FORMATS)
+ }
+
+ /**
+ * The HTTP methods a controller allows, by action.
+ */
+ Object allowedMethods(GrailsControllerClass controller) {
+ controller?.getPropertyValue(ALLOWED_METHODS)
+ }
+
+ /**
+ * A controller a mapping reaching controllers by name describes: a
RestfulController, or one
+ * declaring the formats it responds in, as a REST controller does, rather
than one rendering
+ * views for a browser.
+ */
+ boolean isRestController(GrailsControllerClass controller) {
+ RestfulController.isAssignableFrom(controller.clazz) ||
responseFormats(controller) != null
Review Comment:
260c8e58a8: a controller counts as REST where it is a `RestfulController`,
or declares `responseFormats` without `html`, and the guide says so.
##########
grails-openapi/src/main/groovy/grails/openapi/GrailsOpenApiGenerator.groovy:
##########
@@ -0,0 +1,618 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you 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 grails.openapi
+
+import java.lang.reflect.Method
+
+import groovy.transform.CompileStatic
+
+import io.swagger.v3.core.util.Json
+import io.swagger.v3.core.util.Json31
+import io.swagger.v3.core.util.Yaml
+import io.swagger.v3.core.util.Yaml31
+import io.swagger.v3.oas.annotations.tags.Tag as TagAnnotation
+import io.swagger.v3.oas.models.Components
+import io.swagger.v3.oas.models.OpenAPI
+import io.swagger.v3.oas.models.Operation
+import io.swagger.v3.oas.models.PathItem
+import io.swagger.v3.oas.models.Paths
+import io.swagger.v3.oas.models.SpecVersion
+import io.swagger.v3.oas.models.info.Info
+import io.swagger.v3.oas.models.media.Schema
+import io.swagger.v3.oas.models.parameters.RequestBody
+import org.slf4j.Logger
+import org.slf4j.LoggerFactory
+import org.springframework.core.io.DefaultResourceLoader
+import org.springframework.core.io.ResourceLoader
+
+import grails.core.GrailsApplication
+import grails.core.GrailsControllerClass
+import grails.rest.RestfulController
+import grails.web.http.HttpHeaders
+import grails.web.mapping.UrlMapping
+import grails.web.mapping.UrlMappingsHolder
+import org.grails.datastore.mapping.model.MappingContext
+import org.grails.openapi.ActionAnnotations
+import org.grails.openapi.BaseDocument
+import org.grails.openapi.ComponentSchemas
+import org.grails.openapi.ControllerCatalog
+import org.grails.openapi.DocumentCompletion
+import org.grails.openapi.DocumentParts
+import org.grails.openapi.ErrorsViews
+import org.grails.openapi.GrailsModelConverter
+import org.grails.openapi.MappingVersions
+import org.grails.openapi.MediaTypes
+import org.grails.openapi.OperationParameters
+import org.grails.openapi.OperationResponses
+import org.grails.openapi.RestfulControllerActions
+import org.grails.openapi.SchemaReferences
+import org.grails.openapi.UrlMappingPaths
+import org.grails.openapi.ValidationErrorsContent
+import org.grails.web.mapping.ResponseCodeMappingData
+
+/**
+ * Generates an OpenAPI description of a Grails application from its URL
mappings, its
+ * controllers and its GORM mapping context.
+ *
+ * <p>The generator depends on nothing but the application, so the same
description is produced
+ * at build time by the {@code generate-open-api} command and at runtime by
springdoc, which this
+ * module contributes the description to when springdoc is on the
classpath.</p>
+ *
+ * @since 8.0
+ */
+@CompileStatic
+class GrailsOpenApiGenerator {
+
+ /**
+ * The schema describing the validation errors Grails renders when a
request cannot be bound.
+ */
+ static final String VALIDATION_ERRORS_SCHEMA = 'ValidationErrors'
+
+ private static final Logger LOG =
LoggerFactory.getLogger(GrailsOpenApiGenerator)
+
+ private static final String CONTROLLER_TOKEN = 'controller'
+ private static final String ACTION_TOKEN = 'action'
+ private static final String NAMESPACE_TOKEN = 'namespace'
+ private static final String ID_TOKEN = 'id'
+ private static final String DEFAULT_TITLE = 'Grails application'
+ private static final String DEFAULT_VERSION = '1.0'
+ private static final String SPRINGDOC_TITLE = 'OpenAPI definition'
+ private static final String SPRINGDOC_VERSION = 'v0'
+
+ private static final List<String> BODY_METHODS = ['POST', 'PUT',
'PATCH'].asImmutable()
+
+ private final GrailsApplication grailsApplication
+ private final UrlMappingsHolder urlMappingsHolder
+ private final Collection<MappingContext> mappingContexts
+ private final OpenApiSettings settings
+ private final ResourceLoader resourceLoader
+
+ /**
+ * @param grailsApplication the application whose controllers are described
+ * @param urlMappingsHolder the URL mappings whose paths are described
+ * @param mappingContexts the GORM mapping contexts whose entities are
described, which may be
+ * empty in an application without GORM
+ * @param settings what to describe
+ */
+ GrailsOpenApiGenerator(GrailsApplication grailsApplication,
UrlMappingsHolder urlMappingsHolder,
+ Collection<MappingContext> mappingContexts,
OpenApiSettings settings) {
+ this.grailsApplication = grailsApplication
+ this.urlMappingsHolder = urlMappingsHolder
+ this.mappingContexts = mappingContexts ?: Collections.<MappingContext>
emptyList()
+ this.settings = settings ?: new OpenApiSettings()
+ this.resourceLoader = grailsApplication?.mainContext ?: new
DefaultResourceLoader(GrailsOpenApiGenerator.classLoader)
+ }
+
+ /**
+ * @return the settings the generator describes the application with
+ */
+ OpenApiSettings getSettings() {
+ settings
+ }
+
+ /**
+ * @return the default document
+ */
+ OpenAPI generate() {
+ generate(settings.defaultSelection)
+ }
+
+ /**
+ * @param group the name of a group configured under {@code
grails.openapi.groups}
+ * @return the document of that group
+ * @throws IllegalArgumentException if no such group is configured
+ */
+ OpenAPI generate(String group) {
+ OpenApiSelection selection = settings.group(group)
+ if (selection == null) {
+ throw new IllegalArgumentException("No OpenAPI group named
[${group}] is configured".toString())
+ }
+ generate(selection)
+ }
+
+ /**
+ * @return a complete document of the operations the selection selects
+ */
+ OpenAPI generate(OpenApiSelection selection) {
+ boolean openapi31 = settings.specVersion == SpecVersion.V31
+ OpenAPI openApi = new OpenAPI(settings.specVersion).openapi(openapi31
? '3.1.0' : '3.0.1')
+ openApi.setInfo(applicationInfo(selection))
+ contribute(openApi, selection)
+ if (openApi.info?.version == null) {
+ openApi.info.setVersion(applicationInfo(selection).version)
+ }
+ openApi
+ }
+
+ /**
+ * The document titled with the group's display name or {@code
info.app.name}, and versioned
+ * with {@code info.app.version}.
+ */
+ private Info applicationInfo(OpenApiSelection selection) {
+ String name = grailsApplication?.config?.getProperty('info.app.name',
String)
+ String version =
grailsApplication?.config?.getProperty('info.app.version', String)
+ new Info()
+ .title(selection?.displayName ?: name ?: DEFAULT_TITLE)
+ .version(version ?: DEFAULT_VERSION)
+ }
+
+ /**
+ * Adds the described operations, and the base document when one is
configured, to a document
+ * something else has started, such as springdoc.
+ *
+ * @param openApi the document to add to
+ * @param selection what the document selects, or {@code null} for
everything
+ */
+ void contribute(OpenAPI openApi, OpenApiSelection selection) {
+ if (!settings.enabled) {
+ return
+ }
+ GrailsModelConverter.register()
+ GrailsModelConverter.withMappingContexts(mappingContexts,
settings.includeVersion) {
+ new Contribution(openApi, selection ?: new
OpenApiSelection()).contribute()
+ }
+ }
+
+ /**
+ * Writes a document in the format named: {@code json}, or YAML otherwise.
+ */
+ static String serialize(OpenAPI openApi, String format) {
+ boolean openapi31 = openApi.specVersion == SpecVersion.V31
+ boolean json = format?.equalsIgnoreCase('json')
+ if (openapi31) {
+ return json ? Json31.pretty(openApi) : Yaml31.pretty(openApi)
+ }
+ json ? Json.pretty(openApi) : Yaml.pretty(openApi)
+ }
+
+ /**
+ * Adding the operations the URL mappings reach to one document.
+ */
+ private class Contribution {
+
+ private final OpenAPI openApi
+ private final OpenApiSelection selection
+ private final boolean openapi31
+ private final Components components
+ private final Paths paths
+ private final ControllerCatalog controllers
+ private final ComponentSchemas schemas
+ private final MediaTypes mediaTypes
+ private final OperationParameters parameters
+ private final MappingVersions versions
+ private OperationResponses responses
+
+ Contribution(OpenAPI openApi, OpenApiSelection selection) {
+ this.openApi = openApi
+ this.selection = selection
+ this.openapi31 = openApi.specVersion == SpecVersion.V31
+ this.components = openApi.components ?: new Components()
+ this.paths = openApi.paths ?: new Paths()
+ this.controllers = new ControllerCatalog(grailsApplication)
+ this.schemas = new ComponentSchemas(components, openapi31)
+ this.mediaTypes = new MediaTypes(grailsApplication?.mainContext,
controllers)
+ this.parameters = new OperationParameters(mappingContexts, schemas)
+ this.versions = new MappingVersions(urlMappingsHolder)
+ }
+
+ void contribute() {
+ OpenAPI base = BaseDocument.read(settings.baseDocument,
resourceLoader, openapi31)
+ if (base != null) {
+ BaseDocument.merge(base, openApi, paths, components) { String
path -> selection.selectsPath(path) }
+ }
+ describeApplication()
+ // Validation errors the base document declares describe them in
place of those derived.
+ responses = new OperationResponses(schemas, new
ValidationErrorsContent(components,
+ ErrorsViews.of(grailsApplication?.mainContext),
VALIDATION_ERRORS_SCHEMA,
+
base?.components?.schemas?.containsKey(VALIDATION_ERRORS_SCHEMA) ?: false))
+
+ // A name the document already has, from the base document or
springdoc, or that it
+ // derives rather than resolves, is not taken by a class of the
same name.
+ components.schemas?.keySet()?.each { String name ->
schemas.reserve(name) }
+ schemas.reserve(VALIDATION_ERRORS_SCHEMA)
+ GrailsModelConverter.withSchemaNames(schemas.names) {
+ for (UrlMapping mapping : urlMappingsHolder.urlMappings) {
+ DocumentParts.describe("URL mapping
[${mapping.urlData?.urlPattern}]".toString()) {
+ addMappedOperations(mapping)
+ }
+ }
+ addExpandedMappings()
+ }
+ DocumentCompletion.disambiguateOperationIds(paths)
+
+ openApi.setPaths(paths)
+ openApi.setComponents(components)
+ SchemaReferences.rename(openApi, schemas.renames())
+ DocumentCompletion.registerTags(openApi, paths,
controllers.controllers)
+ DocumentCompletion.dropUnresolvedReferences(paths, components)
+ if (!components.schemas && !components.securitySchemes &&
!components.responses
+ && !components.parameters && !components.examples &&
!components.requestBodies
+ && !components.headers && !components.links &&
!components.callbacks) {
+ openApi.setComponents(null)
+ }
+ }
+
+ /**
+ * springdoc starts a document with a placeholder title and version,
which are replaced by
+ * the application's, as a document generated at build time has them.
A title or version
+ * given any other way is kept.
+ */
+ private void describeApplication() {
+ Info info = openApi.info
+ if (info != null && info.title == SPRINGDOC_TITLE && info.version
== SPRINGDOC_VERSION) {
+ openApi.setInfo(applicationInfo(selection))
+ }
+ }
+
+ /**
+ * Describes the operations of a mapping that names its controller.
+ */
+ private void addMappedOperations(UrlMapping mapping) {
+ String controllerName = asStaticName(mapping.controllerName)
+ if (!controllerName || isResponseCode(mapping)) {
+ return
+ }
+ GrailsControllerClass controller =
controllers.controllerFor(controllerName, asStaticName(mapping.namespace))
+ if (controller == null && grailsApplication != null) {
+ // A controller the application does not have answers nothing
but 404.
+ LOG.debug('Skipping the URL mapping [{}]: the application has
no controller [{}]',
+ mapping.urlData?.urlPattern, controllerName)
+ return
+ }
+ Object declaredAction = mapping.actionName
+ if (declaredAction instanceof Map) {
+ // The action is chosen by the method of the request, so there
is an operation for each.
+ ((Map<Object, Object>) declaredAction).each { Object method,
Object action ->
+ addMappedOperation(mapping, controller, controllerName,
asStaticName(action), method?.toString())
+ }
+ return
+ }
+ if (declaredAction != null && !(declaredAction instanceof
CharSequence)) {
+ LOG.warn('Skipping the URL mapping [{}]: its action is decided
as each request is made',
+ mapping.urlData?.urlPattern)
+ return
+ }
+ String actionName = asStaticName(declaredAction)
+ if (actionName == null && controller != null &&
UrlMappingPaths.variableNames(mapping).contains(ACTION_TOKEN)) {
+ // The action is taken from the path, so every action the
controller declares is reached.
Review Comment:
a390c42822: where `$action` is optional, the path without it, and without
anything after it, is described as the controller's default action, where the
controller declares one.
##########
grails-openapi/src/main/groovy/org/grails/openapi/ComponentSchemas.groovy:
##########
@@ -0,0 +1,156 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you 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 org.grails.openapi
+
+import groovy.transform.CompileStatic
+
+import io.swagger.v3.core.converter.AnnotatedType
+import io.swagger.v3.core.converter.ModelConverters
+import io.swagger.v3.core.converter.ResolvedSchema
+import io.swagger.v3.oas.models.Components
+import io.swagger.v3.oas.models.media.ObjectSchema
+import io.swagger.v3.oas.models.media.Schema
+
+/**
+ * The schemas a document holds in its components: resolved from classes
through swagger-core,
+ * derived from them, such as a patch, and named so that no two share a name.
+ */
+@CompileStatic
+class ComponentSchemas {
+
+ static final String REFERENCE_PREFIX = '#/components/schemas/'
+
+ private static final String PATCH_SUFFIX = 'Patch'
+
+ private final Components components
+ private final boolean openapi31
+ private final SchemaNames names = new SchemaNames()
+ private final Set<String> added = [] as Set
+ private final Map<String, String> patches = [:]
+
+ ComponentSchemas(Components components, boolean openapi31) {
+ this.components = components
+ this.openapi31 = openapi31
+ }
+
+ /**
+ * The names the classes described are given while the document is
described.
+ */
+ SchemaNames getNames() {
+ names
+ }
+
+ /**
+ * Keeps a name for a schema that is not resolved from a class, so no
class takes it.
+ */
+ void reserve(String name) {
+ names.reserve(name)
+ }
+
+ /**
+ * Resolves a type into the components through swagger-core and refers to
it.
+ *
+ * @return the reference, or {@code null} where the type cannot be
described
+ */
+ Schema<?> reference(Class<?> type) {
+ if (type == null || type == Object) {
+ return null
+ }
+ ResolvedSchema resolved = null
+ DocumentParts.describe("type [${type.name}]".toString()) {
+ resolved = ModelConverters.getInstance(openapi31)
+ .resolveAsResolvedSchema(new
AnnotatedType(type).resolveAsRef(true))
+ }
+ if (resolved?.schema == null) {
+ return null
+ }
+ resolved.referencedSchemas?.each { String name, Schema schema ->
+ if (!components.schemas?.containsKey(name)) {
+ components.addSchemas(name, schema)
+ added << name
+ }
+ }
+ resolved.schema.$ref ? new Schema<>().$ref(resolved.schema.$ref) :
resolved.schema
+ }
+
+ /**
+ * The schema of a patch of what a reference refers to: its properties
with nothing required,
+ * since a patch binds only what it is sent. A schema requiring nothing is
its own patch.
+ */
+ Schema<?> patchReference(Schema<?> reference) {
+ String name = nameOf(reference)
+ Schema<?> full = name ? components.schemas?.get(name) : null
+ if (full == null || !full.required) {
+ return reference
+ }
+ String patchName = patches.computeIfAbsent(name) { String base ->
names.reserve(base + PATCH_SUFFIX) }
+ if (!components.schemas.containsKey(patchName)) {
+ Schema<?> patch = new ObjectSchema()
+ patch.setProperties(new LinkedHashMap<String,
Schema>(full.properties ?: [:]))
+ patch.setDescription(full.description)
+ components.addSchemas(patchName, patch)
+ added << patchName
+ }
+ referenceTo(patchName)
+ }
+
+ /**
+ * The schema of a type described inline, to read its properties from
rather than to add to the
+ * document, so it claims no name in it.
+ */
+ Schema<?> inline(Class<?> type) {
+ ResolvedSchema resolved = null
+ DocumentParts.describe("type [${type.name}]".toString()) {
+ GrailsModelConverter.withSchemaNames(null) {
+ resolved = ModelConverters.getInstance(openapi31)
+ .resolveAsResolvedSchema(new
AnnotatedType(type).resolveAsRef(false))
+ }
+ }
+ resolved?.schema
+ }
+
+ /**
+ * The names to move the schemas this document added to, now that every
class described is
+ * known: a class holding a name another class also claims moves to its
qualified name, and a
+ * patch schema follows the schema it is a patch of. A schema the document
already had keeps
+ * its name.
+ */
+ Map<String, String> renames() {
+ Map<String, String> renames = names.renames().findAll { String from,
String to -> from in added }
Review Comment:
b74139c73c: the schemas `ActionAnnotations.apply` adds are compared before
and after and counted as added, so a class named by an `@ApiResponse` is named
apart like one reached through a command. The test reaches the annotated class
first.
##########
grails-openapi/src/main/groovy/grails/openapi/GrailsOpenApiGenerator.groovy:
##########
@@ -0,0 +1,618 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you 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 grails.openapi
+
+import java.lang.reflect.Method
+
+import groovy.transform.CompileStatic
+
+import io.swagger.v3.core.util.Json
+import io.swagger.v3.core.util.Json31
+import io.swagger.v3.core.util.Yaml
+import io.swagger.v3.core.util.Yaml31
+import io.swagger.v3.oas.annotations.tags.Tag as TagAnnotation
+import io.swagger.v3.oas.models.Components
+import io.swagger.v3.oas.models.OpenAPI
+import io.swagger.v3.oas.models.Operation
+import io.swagger.v3.oas.models.PathItem
+import io.swagger.v3.oas.models.Paths
+import io.swagger.v3.oas.models.SpecVersion
+import io.swagger.v3.oas.models.info.Info
+import io.swagger.v3.oas.models.media.Schema
+import io.swagger.v3.oas.models.parameters.RequestBody
+import org.slf4j.Logger
+import org.slf4j.LoggerFactory
+import org.springframework.core.io.DefaultResourceLoader
+import org.springframework.core.io.ResourceLoader
+
+import grails.core.GrailsApplication
+import grails.core.GrailsControllerClass
+import grails.rest.RestfulController
+import grails.web.http.HttpHeaders
+import grails.web.mapping.UrlMapping
+import grails.web.mapping.UrlMappingsHolder
+import org.grails.datastore.mapping.model.MappingContext
+import org.grails.openapi.ActionAnnotations
+import org.grails.openapi.BaseDocument
+import org.grails.openapi.ComponentSchemas
+import org.grails.openapi.ControllerCatalog
+import org.grails.openapi.DocumentCompletion
+import org.grails.openapi.DocumentParts
+import org.grails.openapi.ErrorsViews
+import org.grails.openapi.GrailsModelConverter
+import org.grails.openapi.MappingVersions
+import org.grails.openapi.MediaTypes
+import org.grails.openapi.OperationParameters
+import org.grails.openapi.OperationResponses
+import org.grails.openapi.RestfulControllerActions
+import org.grails.openapi.SchemaReferences
+import org.grails.openapi.UrlMappingPaths
+import org.grails.openapi.ValidationErrorsContent
+import org.grails.web.mapping.ResponseCodeMappingData
+
+/**
+ * Generates an OpenAPI description of a Grails application from its URL
mappings, its
+ * controllers and its GORM mapping context.
+ *
+ * <p>The generator depends on nothing but the application, so the same
description is produced
+ * at build time by the {@code generate-open-api} command and at runtime by
springdoc, which this
+ * module contributes the description to when springdoc is on the
classpath.</p>
+ *
+ * @since 8.0
+ */
+@CompileStatic
+class GrailsOpenApiGenerator {
+
+ /**
+ * The schema describing the validation errors Grails renders when a
request cannot be bound.
+ */
+ static final String VALIDATION_ERRORS_SCHEMA = 'ValidationErrors'
+
+ private static final Logger LOG =
LoggerFactory.getLogger(GrailsOpenApiGenerator)
+
+ private static final String CONTROLLER_TOKEN = 'controller'
+ private static final String ACTION_TOKEN = 'action'
+ private static final String NAMESPACE_TOKEN = 'namespace'
+ private static final String ID_TOKEN = 'id'
+ private static final String DEFAULT_TITLE = 'Grails application'
+ private static final String DEFAULT_VERSION = '1.0'
+ private static final String SPRINGDOC_TITLE = 'OpenAPI definition'
+ private static final String SPRINGDOC_VERSION = 'v0'
+
+ private static final List<String> BODY_METHODS = ['POST', 'PUT',
'PATCH'].asImmutable()
+
+ private final GrailsApplication grailsApplication
+ private final UrlMappingsHolder urlMappingsHolder
+ private final Collection<MappingContext> mappingContexts
+ private final OpenApiSettings settings
+ private final ResourceLoader resourceLoader
+
+ /**
+ * @param grailsApplication the application whose controllers are described
+ * @param urlMappingsHolder the URL mappings whose paths are described
+ * @param mappingContexts the GORM mapping contexts whose entities are
described, which may be
+ * empty in an application without GORM
+ * @param settings what to describe
+ */
+ GrailsOpenApiGenerator(GrailsApplication grailsApplication,
UrlMappingsHolder urlMappingsHolder,
+ Collection<MappingContext> mappingContexts,
OpenApiSettings settings) {
+ this.grailsApplication = grailsApplication
+ this.urlMappingsHolder = urlMappingsHolder
+ this.mappingContexts = mappingContexts ?: Collections.<MappingContext>
emptyList()
+ this.settings = settings ?: new OpenApiSettings()
+ this.resourceLoader = grailsApplication?.mainContext ?: new
DefaultResourceLoader(GrailsOpenApiGenerator.classLoader)
+ }
+
+ /**
+ * @return the settings the generator describes the application with
+ */
+ OpenApiSettings getSettings() {
+ settings
+ }
+
+ /**
+ * @return the default document
+ */
+ OpenAPI generate() {
+ generate(settings.defaultSelection)
+ }
+
+ /**
+ * @param group the name of a group configured under {@code
grails.openapi.groups}
+ * @return the document of that group
+ * @throws IllegalArgumentException if no such group is configured
+ */
+ OpenAPI generate(String group) {
+ OpenApiSelection selection = settings.group(group)
+ if (selection == null) {
+ throw new IllegalArgumentException("No OpenAPI group named
[${group}] is configured".toString())
+ }
+ generate(selection)
+ }
+
+ /**
+ * @return a complete document of the operations the selection selects
+ */
+ OpenAPI generate(OpenApiSelection selection) {
+ boolean openapi31 = settings.specVersion == SpecVersion.V31
+ OpenAPI openApi = new OpenAPI(settings.specVersion).openapi(openapi31
? '3.1.0' : '3.0.1')
+ openApi.setInfo(applicationInfo(selection))
+ contribute(openApi, selection)
+ if (openApi.info?.version == null) {
+ openApi.info.setVersion(applicationInfo(selection).version)
+ }
+ openApi
+ }
+
+ /**
+ * The document titled with the group's display name or {@code
info.app.name}, and versioned
+ * with {@code info.app.version}.
+ */
+ private Info applicationInfo(OpenApiSelection selection) {
+ String name = grailsApplication?.config?.getProperty('info.app.name',
String)
+ String version =
grailsApplication?.config?.getProperty('info.app.version', String)
+ new Info()
+ .title(selection?.displayName ?: name ?: DEFAULT_TITLE)
+ .version(version ?: DEFAULT_VERSION)
+ }
+
+ /**
+ * Adds the described operations, and the base document when one is
configured, to a document
+ * something else has started, such as springdoc.
+ *
+ * @param openApi the document to add to
+ * @param selection what the document selects, or {@code null} for
everything
+ */
+ void contribute(OpenAPI openApi, OpenApiSelection selection) {
+ if (!settings.enabled) {
+ return
+ }
+ GrailsModelConverter.register()
+ GrailsModelConverter.withMappingContexts(mappingContexts,
settings.includeVersion) {
+ new Contribution(openApi, selection ?: new
OpenApiSelection()).contribute()
+ }
+ }
+
+ /**
+ * Writes a document in the format named: {@code json}, or YAML otherwise.
+ */
+ static String serialize(OpenAPI openApi, String format) {
+ boolean openapi31 = openApi.specVersion == SpecVersion.V31
+ boolean json = format?.equalsIgnoreCase('json')
+ if (openapi31) {
+ return json ? Json31.pretty(openApi) : Yaml31.pretty(openApi)
+ }
+ json ? Json.pretty(openApi) : Yaml.pretty(openApi)
+ }
+
+ /**
+ * Adding the operations the URL mappings reach to one document.
+ */
+ private class Contribution {
+
+ private final OpenAPI openApi
+ private final OpenApiSelection selection
+ private final boolean openapi31
+ private final Components components
+ private final Paths paths
+ private final ControllerCatalog controllers
+ private final ComponentSchemas schemas
+ private final MediaTypes mediaTypes
+ private final OperationParameters parameters
+ private final MappingVersions versions
+ private OperationResponses responses
+
+ Contribution(OpenAPI openApi, OpenApiSelection selection) {
+ this.openApi = openApi
+ this.selection = selection
+ this.openapi31 = openApi.specVersion == SpecVersion.V31
+ this.components = openApi.components ?: new Components()
+ this.paths = openApi.paths ?: new Paths()
+ this.controllers = new ControllerCatalog(grailsApplication)
+ this.schemas = new ComponentSchemas(components, openapi31)
+ this.mediaTypes = new MediaTypes(grailsApplication?.mainContext,
controllers)
+ this.parameters = new OperationParameters(mappingContexts, schemas)
+ this.versions = new MappingVersions(urlMappingsHolder)
+ }
+
+ void contribute() {
+ OpenAPI base = BaseDocument.read(settings.baseDocument,
resourceLoader, openapi31)
+ if (base != null) {
+ BaseDocument.merge(base, openApi, paths, components) { String
path -> selection.selectsPath(path) }
+ }
+ describeApplication()
+ // Validation errors the base document declares describe them in
place of those derived.
+ responses = new OperationResponses(schemas, new
ValidationErrorsContent(components,
+ ErrorsViews.of(grailsApplication?.mainContext),
VALIDATION_ERRORS_SCHEMA,
+
base?.components?.schemas?.containsKey(VALIDATION_ERRORS_SCHEMA) ?: false))
+
+ // A name the document already has, from the base document or
springdoc, or that it
+ // derives rather than resolves, is not taken by a class of the
same name.
+ components.schemas?.keySet()?.each { String name ->
schemas.reserve(name) }
Review Comment:
065c74c21f: the `ModelConverter` bean springdoc registers now holds the
application's mapping contexts, so springdoc's own resolution is Grails-aware,
and records the class it resolved under each name; the contribution reserves
such a name for that class, so the Grails endpoints refer to springdoc's schema
rather than qualifying a second one.
##########
grails-openapi/src/main/groovy/grails/openapi/OpenApiSelection.groovy:
##########
@@ -0,0 +1,178 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you 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 grails.openapi
+
+import java.lang.reflect.Method
+
+import groovy.transform.CompileStatic
+import groovy.transform.PackageScope
+
+import io.swagger.v3.oas.models.Components
+import io.swagger.v3.oas.models.Operation
+import org.springframework.util.AntPathMatcher
+
+/**
+ * Which operations a document describes: the default document, or one named
group.
+ *
+ * <p>The criteria are the ones springdoc applies to a group: they are read
from
+ * {@code grails.openapi} for the default document and from {@code
grails.openapi.groups.<name>} for
+ * a group, and from a springdoc {@code GroupedOpenApi} where the application
declares one, so a
+ * document generated at build time and one springdoc serves select the same
operations.</p>
+ *
+ * <p>A path criterion is an Ant pattern matched against the described path,
such as
+ * {@code /api/v1/**}. A package criterion names the package of the controller
that serves the
+ * operation, and matches its sub-packages too. A media type or header
criterion is matched the way
+ * springdoc matches a handler method: an operation matches only where it
produces, consumes, or
+ * declares exactly what the criterion lists. A criterion left empty selects
everything.</p>
+ *
+ * <p>A subclass can decide by the action itself, and customize each
operation, by overriding
+ * {@link #selectsAction} and {@link #customize}; springdoc's method filters
and operation
+ * customizers are applied that way.</p>
+ *
+ * @since 8.0
+ */
+@CompileStatic
+class OpenApiSelection {
+
+ private static final AntPathMatcher PATH_MATCHER = new AntPathMatcher()
+
+ /**
+ * The group name, or {@code null} for the default document.
+ */
+ String group
+
+ /**
+ * The title of the document, and the name a viewer shows for the group.
+ */
+ String displayName
+
+ /**
+ * Ant patterns of the paths described, such as {@code /api/v1/**}; empty
for every path.
+ */
+ List<String> pathsToMatch = []
+
+ /**
+ * Ant patterns of the paths left out.
+ */
+ List<String> pathsToExclude = []
+
+ /**
+ * The packages, with their sub-packages, of the controllers whose
operations are described;
+ * empty for every package.
+ */
+ List<String> packagesToScan = []
+
+ /**
+ * The packages, with their sub-packages, of the controllers whose
operations are left out.
+ */
+ List<String> packagesToExclude = []
+
+ /**
+ * The media types an operation must produce: those of the formats its
controller declares in
+ * {@code responseFormats}, or {@code application/json} where it declares
none.
+ */
+ List<String> producesToMatch = []
+
+ /**
+ * The media types an operation must consume: those it produces, where it
binds a body.
+ */
+ List<String> consumesToMatch = []
+
+ /**
+ * The header conditions an operation must declare, such as {@code
Accept-Version=1.0}.
+ */
+ List<String> headersToMatch = []
+
+ OpenApiSelection() {
+ }
+
+ /**
+ * A selection with the same criteria as another.
+ */
+ OpenApiSelection(OpenApiSelection criteria) {
+ group = criteria.group
+ displayName = criteria.displayName
+ pathsToMatch = new ArrayList<String>(criteria.pathsToMatch)
+ pathsToExclude = new ArrayList<String>(criteria.pathsToExclude)
+ packagesToScan = new ArrayList<String>(criteria.packagesToScan)
+ packagesToExclude = new ArrayList<String>(criteria.packagesToExclude)
+ producesToMatch = new ArrayList<String>(criteria.producesToMatch)
+ consumesToMatch = new ArrayList<String>(criteria.consumesToMatch)
+ headersToMatch = new ArrayList<String>(criteria.headersToMatch)
+ }
+
+ /**
+ * Whether the action serving an operation is described, decided by the
method it is declared
+ * as. Every action is.
+ *
+ * @param action the method the action is declared as, the one taking its
parameters
+ */
+ protected boolean selectsAction(Method action) {
Review Comment:
7160cf2d1b: `OpenApiSelection` is a plain criteria bean again. The bridge
implements an internal `org.grails.openapi.ActionHooks`, which the generator
applies where a selection has it.
##########
grails-openapi/src/main/groovy/grails/openapi/OpenApiSettings.groovy:
##########
@@ -0,0 +1,210 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you 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 grails.openapi
+
+import groovy.transform.CompileStatic
+
+import io.swagger.v3.oas.models.SpecVersion
+import org.springframework.core.env.ConfigurableEnvironment
+import org.springframework.core.env.EnumerablePropertySource
+import org.springframework.core.env.Environment
+import org.springframework.core.env.PropertySource
+
+/**
+ * How the OpenAPI document is generated, read from the {@code grails.openapi}
configuration.
+ *
+ * <p>The same settings drive a document generated at build time and one
springdoc serves. Where
+ * springdoc is present, its {@code springdoc.paths-to-match}, {@code
springdoc.paths-to-exclude},
+ * {@code springdoc.packages-to-scan} and {@code
springdoc.packages-to-exclude} are honored for the
+ * default document as well, as are {@code springdoc.produces-to-match},
+ * {@code springdoc.consumes-to-match} and {@code springdoc.headers-to-match},
and
+ * {@code springdoc.api-docs.version} chooses the OpenAPI version.</p>
+ *
+ * @since 8.0
+ */
+@CompileStatic
+class OpenApiSettings {
+
+ /**
+ * The prefix of the settings.
+ */
+ static final String PREFIX = 'grails.openapi'
+
+ private static final String GROUPS_PREFIX = PREFIX + '.groups.'
+ private static final String SPRINGDOC_PREFIX = 'springdoc'
+
+ /**
+ * Whether the document is generated at all.
+ */
+ boolean enabled = true
+
+ /**
+ * Whether only an action that declares {@code @Operation}, or whose
controller declares
+ * {@code @Tag}, is described. Without it every reachable action is.
+ */
+ boolean annotatedOnly = false
+
+ /**
+ * Whether the {@code create} and {@code edit} actions of a {@code
RestfulController} are
+ * described. They answer the forms an HTML client renders rather than an
API client.
+ */
+ boolean includeFormActions = false
+
+ /**
+ * A resource location, such as {@code classpath:openapi-base.yml}, of a
YAML or JSON
+ * document whose information, servers, security, tags, extensions, paths
and components the
+ * generated document starts from.
+ */
+ String baseDocument
+
+ /**
+ * Where the {@code generate-open-api} command writes the documents,
relative to the
+ * directory it runs in.
+ */
+ String outputDirectory = 'build/openapi'
+
+ /**
+ * The format the {@code generate-open-api} command writes: {@code yaml}
or {@code json}.
+ */
+ String outputFormat = 'yaml'
+
+ /**
+ * The OpenAPI version described.
+ */
+ SpecVersion specVersion = SpecVersion.V31
+
+ /**
+ * Whether Grails renders the version of a domain class, which it does
where
+ * {@code grails.converters.json.domain.include.version} or
+ * {@code grails.converters.domain.include.version} is set.
+ */
+ boolean includeVersion = false
+
+ /**
+ * What the default document selects.
+ */
+ OpenApiSelection defaultSelection = new OpenApiSelection()
+
+ /**
+ * The groups, each a document of its own.
+ */
+ List<OpenApiSelection> groups = []
+
+ /**
+ * @return the group of that name, or {@code null} if none is configured
+ */
+ OpenApiSelection group(String name) {
+ groups.find { OpenApiSelection selection -> selection.group == name }
+ }
+
+ /**
+ * Reads the settings from an environment.
+ *
+ * <p>The properties are read one by one rather than bound, because the
configuration Grails
+ * loads from {@code application.yml} exposes each nested block as a value
of its own, which a
+ * binder would try to convert into the bound type.</p>
+ */
+ static OpenApiSettings from(Environment environment) {
+ OpenApiSettings settings = new OpenApiSettings()
+ settings.enabled =
environment.getProperty("${PREFIX}.enabled".toString(), Boolean, true)
+ settings.annotatedOnly =
environment.getProperty("${PREFIX}.annotated-only".toString(), Boolean, false)
+ settings.includeFormActions =
environment.getProperty("${PREFIX}.include-form-actions".toString(), Boolean,
false)
+ settings.baseDocument =
environment.getProperty("${PREFIX}.base-document".toString())
+ settings.outputDirectory =
environment.getProperty("${PREFIX}.output-directory".toString(),
settings.outputDirectory)
+ settings.outputFormat =
environment.getProperty("${PREFIX}.output-format".toString(),
settings.outputFormat)
+
+ settings.includeVersion =
environment.getProperty('grails.converters.json.domain.include.version',
Boolean,
+
environment.getProperty('grails.converters.domain.include.version', Boolean,
false))
+
+ String version = environment.getProperty('springdoc.api-docs.version')
+ if (version?.toLowerCase(Locale.ENGLISH)?.contains('3_0')) {
+ settings.specVersion = SpecVersion.V30
+ }
+
+ OpenApiSelection defaults = selection(environment, PREFIX, null)
+ OpenApiSelection springdoc = selection(environment, SPRINGDOC_PREFIX,
null)
Review Comment:
b9950e6395: a group's criteria are now taken as springdoc takes them, the
top-level one where it is set and the group's otherwise, criterion by
criterion, from `SpringDocConfigProperties`.
##########
grails-openapi/src/cli/groovy/org/apache/grails/openapi/cli/GenerateOpenApiCommand.groovy:
##########
@@ -0,0 +1,135 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you 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 org.apache.grails.openapi.cli
+
+import groovy.transform.CompileStatic
+import groovy.util.logging.Slf4j
+
+import io.swagger.v3.oas.models.OpenAPI
+import org.springframework.util.ClassUtils
+
+import grails.openapi.GrailsOpenApiGenerator
+import grails.openapi.OpenApiSelection
+import grails.openapi.OpenApiSettings
+import org.apache.grails.core.cli.ApplicationCommand
+import org.apache.grails.core.cli.ExecutionContext
+import org.grails.openapi.springdoc.GroupedOpenApiContributor
+
+/**
+ * Writes the application's OpenAPI description to files, so the description
can be packaged,
+ * served as a static file, reviewed in a change, or handed to a code
generator without running
+ * the application.
+ *
+ * <p>The default document is written to {@code openapi.yaml}, and each group
- configured under
+ * {@code grails.openapi.groups}, or declared to springdoc as a {@code
GroupedOpenApi} - to
+ * {@code openapi-<group>.yaml}. Where springdoc is configured, its method
filters and customizers
+ * are applied as they are to the documents it serves. The directory and
format come
+ * from {@code grails.openapi.output-directory} and {@code
grails.openapi.output-format}, and can be
+ * overridden with the {@code --output-directory} and {@code --format}
options.</p>
+ */
+@Slf4j
+@CompileStatic
+class GenerateOpenApiCommand implements ApplicationCommand {
+
+ private static final String SPRINGDOC_GROUP =
'org.springdoc.core.models.GroupedOpenApi'
+
+ final String description = 'Writes the OpenAPI description of the
application to files'
+
+ @Override
+ boolean handle(ExecutionContext executionContext) {
+ GrailsOpenApiGenerator generator =
applicationContext.getBeanProvider(GrailsOpenApiGenerator).getIfAvailable()
+ if (generator == null) {
+ log.error('Wrote no OpenAPI description: grails.openapi.enabled is
false')
+ return false
+ }
+ OpenApiSettings settings = generator.settings
+
+ String format = (option(executionContext, 'format') ?:
settings.outputFormat).toLowerCase(Locale.ENGLISH)
+ if (!(format in ['yaml', 'json'])) {
+ log.error('Unsupported OpenAPI format [{}]: use yaml or json',
format)
+ return false
+ }
+ File directory = outputDirectory(executionContext,
option(executionContext, 'output-directory') ?: settings.outputDirectory)
+ if (!directory.directory && !directory.mkdirs()) {
+ log.error('Could not create the OpenAPI output directory [{}]',
directory)
+ return false
+ }
+
+ write(customized(generator.generate(defaultSelection(settings)),
null), new File(directory, "openapi.${format}"), format)
+ for (OpenApiSelection group : groups(settings)) {
+ write(customized(generator.generate(group), group.group), new
File(directory, "openapi-${group.group}.${format}"), format)
+ }
+ true
+ }
+
+ /**
+ * The default document, with the method filters springdoc applies to it
where springdoc is
+ * configured, so the file describes what springdoc serves.
+ */
+ private OpenApiSelection defaultSelection(OpenApiSettings settings) {
+ springdocPresent()
+ ?
GroupedOpenApiContributor.defaultSelection(applicationContext,
settings.defaultSelection)
+ : settings.defaultSelection
+ }
+
+ /**
+ * The groups springdoc serves, which include those configured under
+ * {@code grails.openapi.groups}, or those groups alone without springdoc.
+ */
+ private Collection<OpenApiSelection> groups(OpenApiSettings settings) {
+ Map<String, OpenApiSelection> byName = [:]
+ if (springdocPresent()) {
+ GroupedOpenApiContributor.declaredGroups(applicationContext).each
{ OpenApiSelection group ->
+ byName.putIfAbsent(group.group, group)
Review Comment:
b91f1c51e8: a group name declared by more than one `GroupedOpenApi` (a bean,
`springdoc.group-configs`, or a `grails.openapi.groups` one) is warned of, at
runtime and by the command; the first is still used, as springdoc does.
--
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.
To unsubscribe, e-mail: [email protected]
For queries about this service, please contact Infrastructure at:
[email protected]