codeconsole commented on code in PR #16275:
URL: https://github.com/apache/grails-core/pull/16275#discussion_r4102326007


##########
grails-openapi/src/main/groovy/org/grails/openapi/springdoc/GroupedOpenApiContributor.groovy:
##########
@@ -0,0 +1,191 @@
+/*
+ *  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.springdoc
+
+import groovy.transform.CompileStatic
+
+import io.swagger.v3.oas.models.OpenAPI
+import org.slf4j.Logger
+import org.slf4j.LoggerFactory
+import org.springdoc.api.AbstractMultipleOpenApiResource
+import org.springdoc.core.customizers.GlobalOpenApiCustomizer
+import org.springdoc.core.customizers.OpenApiCustomizer
+import org.springdoc.core.customizers.SpringDocCustomizers
+import org.springdoc.core.models.GroupedOpenApi
+import org.springframework.beans.BeansException
+import org.springframework.beans.factory.BeanFactory
+import org.springframework.beans.factory.BeanFactoryAware
+import org.springframework.beans.factory.ListableBeanFactory
+import org.springframework.beans.factory.config.BeanPostProcessor
+import org.springframework.context.ApplicationContext
+
+import grails.openapi.GrailsOpenApiGenerator
+import grails.openapi.OpenApiSelection
+
+/**
+ * Contributes the Grails description to every springdoc group.
+ *
+ * <p>springdoc gives a group only the customizers the group itself carries 
and the global ones,
+ * and a global customizer cannot tell which group it is customizing. So each 
group - one this
+ * module registers for {@code grails.openapi.groups}, or one the application 
declares - is given
+ * its own customizer, selecting what the group's criteria select, before 
springdoc builds the
+ * group's document.</p>
+ *
+ * <p>The Grails description is contributed ahead of every other customizer, 
in each group and in

Review Comment:
   314fd7e777: Javadoc narrowed to `OpenApiCustomizer`s, and the guide says 
locale customizers run before the Grails operations are added and that the 
command runs none.
   



##########
grails-doc/src/en/guide/REST/openApi.adoc:
##########
@@ -0,0 +1,749 @@
+////
+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.
+////
+Grails applications can publish an https://www.openapis.org/[OpenAPI] 
description of their REST
+endpoints. The optional `grails-openapi` module derives it from the 
application itself - its URL
+mappings, its controllers, its domain classes and command objects, and their 
constraints - and
+lets the standard OpenAPI annotations correct or enrich what is derived.
+
+The description can be produced two ways, from the same configuration:
+
+* At build time, by the `generate-open-api` command, which writes it to a 
file. The file can be
+  packaged and served as a static resource, reviewed in a change, or handed to 
a client code
+  generator, and the running application exposes nothing it does not choose to 
serve.
+* At runtime, by https://springdoc.org[springdoc-openapi], which serves it at 
`/v3/api-docs` and
+  can serve Swagger UI beside it. springdoc builds its document from Spring 
MVC handler methods,
+  which Grails does not use, so the module contributes the Grails endpoints to 
it.
+
+==== Getting Started
+
+[source,groovy]
+----
+dependencies {
+    implementation 'org.apache.grails:grails-openapi'
+}
+----
+
+That is all the build-time command needs. To serve the description at runtime 
as well, add
+springdoc, and optionally Swagger UI:
+
+[source,groovy]
+----
+dependencies {
+    // the document, served at /v3/api-docs
+    implementation 'org.springdoc:springdoc-openapi-starter-webmvc-api'
+
+    // or the document and Swagger UI, served at /swagger-ui/index.html
+    implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui'
+}
+----
+
+==== Generating the Description at Build Time
+
+[source,shell]
+----
+./gradlew generateOpenApi
+----
+
+The command starts the application context, without a web server, and writes 
the default document
+to `build/openapi/openapi.yaml` and each <<openApiGroups,group>> to 
`build/openapi/openapi-<group>.yaml`.
+The `--output-directory` and `--format` options, or the 
`grails.openapi.output-directory` and
+`grails.openapi.output-format` settings, choose where and whether YAML or JSON 
is written:
+
+[source,shell]
+----
+./gradlew runCommand -Pargs="generate-open-api --format=json 
--output-directory=build/api"
+----
+
+Because the command starts the application, it needs whatever the application 
needs to start in
+the environment it runs in, such as a datasource.
+
+Where springdoc is configured, the command applies the method filters, 
operation customizers and

Review Comment:
   40177b342f: the command warns where springdoc is on the classpath but 
`springdoc.api-docs.enabled` is false, and the guide says what it then does not 
apply.
   



##########
grails-openapi/src/main/groovy/org/grails/openapi/PropertyNames.groovy:
##########
@@ -0,0 +1,155 @@
+/*
+ *  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.beans.PropertyDescriptor
+
+import groovy.transform.CompileStatic
+
+import com.fasterxml.jackson.databind.BeanDescription
+import com.fasterxml.jackson.databind.SerializationConfig
+import com.fasterxml.jackson.databind.introspect.AnnotatedMember
+import com.fasterxml.jackson.databind.introspect.AnnotatedMethod
+import com.fasterxml.jackson.databind.introspect.BeanPropertyDefinition
+import io.swagger.v3.core.util.Json
+import io.swagger.v3.oas.annotations.media.Schema as SchemaAnnotation
+import io.swagger.v3.oas.models.media.Schema
+import org.springframework.beans.BeanUtils
+
+/**
+ * The names the properties of a type Grails renders and binds are described 
under.
+ *
+ * <p>swagger-core names a property as Jackson does, which is not always the 
name Grails renders and
+ * binds it by: Jackson describes {@code getISBN()} as {@code isbn}, where 
Grails uses {@code ISBN}.
+ * A property is described by the name Grails uses, unless it is renamed on 
purpose, with
+ * {@code @JsonProperty} or {@code @Schema(name)}.</p>

Review Comment:
   f583f8ff77: agreed; a domain class or command object is described by the 
property's own name whatever `@JsonProperty` says, and only `@Schema(name)` 
renames the description.
   



##########
grails-openapi/src/main/groovy/org/grails/openapi/GrailsModelConverter.groovy:
##########
@@ -0,0 +1,622 @@
+/*
+ *  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.beans.PropertyDescriptor
+import java.lang.reflect.Method
+import java.lang.reflect.Type
+
+import groovy.transform.CompileStatic
+
+import com.fasterxml.jackson.databind.JavaType
+import com.fasterxml.jackson.databind.type.TypeFactory
+import io.swagger.v3.core.converter.AnnotatedType
+import io.swagger.v3.core.converter.ModelConverter
+import io.swagger.v3.core.converter.ModelConverterContext
+import io.swagger.v3.core.converter.ModelConverters
+import io.swagger.v3.core.util.PrimitiveType
+import io.swagger.v3.oas.models.media.ArraySchema
+import io.swagger.v3.oas.models.media.IntegerSchema
+import io.swagger.v3.oas.models.media.ObjectSchema
+import io.swagger.v3.oas.models.media.Schema
+import io.swagger.v3.oas.models.media.StringSchema
+import io.swagger.v3.oas.models.media.XML
+import org.codehaus.groovy.runtime.InvokerHelper
+import org.slf4j.Logger
+import org.slf4j.LoggerFactory
+import org.springframework.beans.BeanUtils
+import org.springframework.util.ClassUtils
+import org.springframework.validation.Errors
+import org.springframework.validation.Validator
+
+import grails.gorm.validation.Constrained
+import grails.util.GrailsNameUtils
+import grails.gorm.validation.ConstrainedEntity
+import grails.gorm.validation.ConstrainedProperty
+import grails.validation.Validateable
+import grails.web.databinding.DataBindingUtils
+import org.grails.datastore.mapping.model.MappingContext
+import org.grails.datastore.mapping.model.PersistentEntity
+import org.grails.datastore.mapping.model.PersistentProperty
+import org.grails.datastore.mapping.model.types.Association
+import org.grails.datastore.mapping.model.types.Embedded
+import org.grails.datastore.mapping.model.types.EmbeddedCollection
+import org.grails.datastore.mapping.model.types.ToMany
+
+/**
+ * Teaches swagger-core what a Groovy class, a command object and a GORM 
entity look like, so the
+ * schema of such a type is right wherever it is resolved: as a resource, as a 
command object, or
+ * as the implementation an annotation names.
+ *
+ * <ul>
+ *   <li>The {@code metaClass} every Groovy object has, and the {@code errors} 
a validateable type
+ *   has, are not properties of the resource and are left out.</li>
+ *   <li>The declared constraints become the matching schema keywords.</li>
+ *   <li>The identifier and version of an entity are read only, because the 
server assigns them.</li>
+ *   <li>An association to another entity is described by the identifier that 
Grails renders and
+ *   binds for it, rather than by the whole associated resource.</li>
+ * </ul>
+ *
+ * <p>The converter is registered with swagger-core once and applies to every 
type it resolves.
+ * The GORM metadata it uses is supplied by whatever is resolving at the time, 
through
+ * {@link #withMappingContexts}. A declaration that cannot be read, such as 
constraints that fail
+ * to evaluate, is left out, and logged; each is read before the schema is 
changed.</p>
+ */
+@CompileStatic
+class GrailsModelConverter implements ModelConverter {
+
+    static final GrailsModelConverter INSTANCE = new GrailsModelConverter()
+
+    private static final Logger LOG = 
LoggerFactory.getLogger(GrailsModelConverter)
+
+    private static final String EMAIL_FORMAT = 'email'
+    private static final String URI_FORMAT = 'uri'
+    private static final String INT64_FORMAT = 'int64'
+    private static final String NULL_TYPE = 'null'
+    private static final String REFERENCE_PREFIX = '#/components/schemas/'
+
+    private static final ThreadLocal<Collection<MappingContext>> 
MAPPING_CONTEXTS = new ThreadLocal<>()
+
+    private static final ThreadLocal<Boolean> INCLUDE_VERSION = new 
ThreadLocal<>()
+
+    private static final String DATABINDING_WHITELIST = 
'$defaultDatabindingWhiteList'
+
+    private static final List<Class<?>> FILE_TYPES = 
['org.springframework.web.multipart.MultipartFile',
+                                                      
'jakarta.servlet.http.Part'].findAll { String name ->
+        ClassUtils.isPresent(name, GrailsModelConverter.classLoader)
+    }.collect { String name -> ClassUtils.resolveClassName(name, 
GrailsModelConverter.classLoader) }.asImmutable()
+
+    private static final ThreadLocal<SchemaNames> SCHEMA_NAMES = new 
ThreadLocal<>()
+
+    private static final ThreadLocal<Deque<Class<?>>> RESOLVING = 
ThreadLocal.<Deque<Class<?>>> withInitial {
+        (Deque<Class<?>>) new ArrayDeque<Class<?>>()
+    }
+
+    /**
+     * Registers the converter with both of swagger-core's converter lists, 
once.
+     */
+    static synchronized void register() {
+        for (boolean openapi31 : [false, true]) {
+            ModelConverters converters = ModelConverters.getInstance(openapi31)
+            if (!converters.converters.any { it instanceof 
GrailsModelConverter }) {
+                converters.addConverter(INSTANCE)
+            }
+        }
+    }
+
+    /**
+     * Resolves with the entities of the given mapping contexts known, so 
their constraints,
+     * identifiers and associations are described.
+     */
+    static <T> T withMappingContexts(Collection<MappingContext> 
mappingContexts, Closure<T> work) {
+        withMappingContexts(mappingContexts, false, work)
+    }
+
+    /**
+     * @param includeVersion whether Grails renders the version of an entity, 
which it does not
+     * unless {@code grails.converters.domain.include.version} is set
+     */
+    static <T> T withMappingContexts(Collection<MappingContext> 
mappingContexts, boolean includeVersion, Closure<T> work) {
+        Collection<MappingContext> previous = MAPPING_CONTEXTS.get()
+        Boolean previousIncludeVersion = INCLUDE_VERSION.get()
+        MAPPING_CONTEXTS.set(mappingContexts)
+        INCLUDE_VERSION.set(includeVersion)
+        try {
+            return work.call()
+        }
+        finally {
+            MAPPING_CONTEXTS.set(previous)
+            INCLUDE_VERSION.set(previousIncludeVersion)
+        }
+    }
+
+    /**
+     * Resolves with every class named apart from the others sharing its name, 
through the names
+     * of the document being described.
+     */
+    static <T> T withSchemaNames(SchemaNames names, Closure<T> work) {
+        SchemaNames previous = SCHEMA_NAMES.get()
+        SCHEMA_NAMES.set(names)
+        try {
+            return work.call()
+        }
+        finally {
+            SCHEMA_NAMES.set(previous)
+        }
+    }
+
+    /**
+     * @return the entity mapped for the type, if a mapping context in scope 
maps it
+     */
+    static PersistentEntity entityFor(Class<?> type) {
+        if (type == null) {
+            return null
+        }
+        for (MappingContext context : (MAPPING_CONTEXTS.get() ?: 
Collections.<MappingContext> emptyList())) {
+            PersistentEntity entity = context.getPersistentEntity(type.name)
+            if (entity != null) {
+                return entity
+            }
+        }
+        null
+    }
+
+    /**
+     * Whether a type has a property a file is bound to, so it is bound from a 
multipart request.
+     */
+    static boolean hasFileProperty(Class<?> type) {
+        if (type == null) {
+            return false
+        }
+        BeanUtils.getPropertyDescriptors(type).any { PropertyDescriptor 
property -> isFile(property.propertyType) }
+    }
+
+    /**
+     * An uploaded file: a {@code MultipartFile} or a servlet {@code Part}.
+     */
+    private static boolean isFile(Class<?> type) {
+        type != null && FILE_TYPES.any { Class<?> fileType -> 
fileType.isAssignableFrom(type) }
+    }
+
+    @Override
+    Schema resolve(AnnotatedType annotatedType, ModelConverterContext context, 
Iterator<ModelConverter> chain) {
+        Class<?> type = rawClass(annotatedType.type)
+        if (isFile(type)) {
+            // A file is sent as the binary part of a multipart request.
+            return new StringSchema().format('binary')
+        }
+
+        if (annotatedType.propertyName != null) {
+            if (type != null && (MetaClass.isAssignableFrom(type) || 
Errors.isAssignableFrom(type))) {
+                return null
+            }
+            Schema reference = associationReference(annotatedType.propertyName)
+            if (reference != null) {
+                return reference
+            }
+        }
+
+        if (!chain.hasNext()) {
+            return null
+        }
+        JavaType javaType = javaType(annotatedType.type)
+        if (type == null || javaType == null || 
!SchemaNames.isDescribedAsItself(annotatedType, javaType)) {
+            // Another type is described in its place, and named and described 
as itself.
+            return chain.next().resolve(annotatedType, context, chain)
+        }
+        nameApart(annotatedType)
+
+        Deque<Class<?>> resolving = RESOLVING.get()
+        resolving.push(type)
+        Schema resolved
+        try {
+            resolved = chain.next().resolve(annotatedType, context, chain)
+        }
+        finally {
+            resolving.pop()
+        }
+
+        Schema model = modelOf(resolved, context)
+        if (model?.properties != null) {
+            try {
+                describe(type, model)
+            }
+            catch (RuntimeException | LinkageError e) {
+                // The schema swagger-core resolved stands without what Grails 
declares of the
+                // type, rather than failing a document springdoc serves for 
its own endpoints.
+                LOG.warn("Could not describe what Grails declares of 
[${type.name}], such as its constraints", e)
+            }
+        }
+        resolved
+    }
+
+    /**
+     * Names a class that shares its name with another apart from it, before 
swagger-core names the
+     * class and refers to it.
+     */
+    private static void nameApart(AnnotatedType annotatedType) {
+        SchemaNames names = SCHEMA_NAMES.get()
+        if (names == null || annotatedType.name) {
+            return
+        }
+        JavaType type = javaType(annotatedType.type)
+        if (type == null || !SchemaNames.isNamed(annotatedType, type)) {
+            return
+        }
+        String natural = SchemaNames.naturalName(annotatedType, type)
+        String name = names.claim(type, natural)
+        if (name != natural) {
+            annotatedType.setName(name)
+        }
+    }
+
+    /**
+     * A property the enclosing entity maps as an association to another 
entity is described by
+     * the associated identifier, which is what Grails renders for it and 
accepts to bind it.
+     */
+    private static Schema associationReference(String propertyName) {
+        Class<?> owner = RESOLVING.get().peek()
+        PersistentEntity entity = entityFor(owner)
+        if (entity == null) {
+            return null
+        }
+        PersistentProperty property = entity.getPropertyByName(propertyName)
+        if (!(property instanceof Association) || property instanceof Embedded 
|| property instanceof EmbeddedCollection) {
+            return null
+        }
+        Association association = (Association) property
+        PersistentEntity associated = association.associatedEntity
+        if (associated == null) {
+            return null
+        }
+
+        // Grails renders the identifier in XML as an attribute of the 
association's element, and a
+        // to-many association as an element holding one for each, named for 
the associated class.
+        Schema reference = new ObjectSchema()
+                .addProperty(associated.identity?.name ?: 'id', 
identifierSchema(associated).xml(new XML().attribute(true)))
+                .description("The identifier of the associated 
${associated.javaClass.simpleName}".toString())
+        reference.addRequiredItem(associated.identity?.name ?: 'id')
+
+        if (association instanceof ToMany) {
+            reference.setXml(new 
XML().name(GrailsNameUtils.getPropertyName(associated.javaClass)))
+            return new ArraySchema().items(reference).xml(new 
XML().wrapped(true))
+        }
+        reference
+    }
+
+    /**
+     * The schema of the identifier an entity is addressed and associated by: 
an integer of the
+     * width it is declared with, a UUID, or a string for any other type.
+     */
+    static Schema identifierSchema(PersistentEntity entity) {
+        Class<?> identifierType = entity.identity?.type
+        Schema schema = identifierType != null ? 
PrimitiveType.createProperty(identifierType) : null
+        schema ?: new StringSchema()
+    }
+
+    private static Schema modelOf(Schema resolved, ModelConverterContext 
context) {
+        if (resolved?.$ref) {
+            String name = resolved.$ref.startsWith(REFERENCE_PREFIX)
+                    ? resolved.$ref.substring(REFERENCE_PREFIX.length())
+                    : resolved.$ref
+            return context.definedModels?.get(name)
+        }
+        resolved
+    }
+
+    private static void describe(Class<?> type, Schema model) {
+        PersistentEntity entity = entityFor(type)
+        boolean validateable = Validateable.isAssignableFrom(type)
+        if (entity == null && !validateable && 
!declaresBindableProperties(type)) {
+            // Grails neither renders nor binds it by its own rules, so it is 
described as it is.
+            return
+        }
+
+        // What Grails declares of the type is read before the schema is 
changed, each declaration
+        // on its own, so one that cannot be read is left out without leaving 
the schema half changed.
+        PropertyNames propertyNames = PropertyNames.of(type)
+        Map<String, Constrained> constraints = read("the constraints of 
[${type.name}]") {
+            entity != null ? entityConstraints(entity) : (validateable ? 
validateableConstraints(type) : null)
+        } ?: Collections.<String, Constrained> emptyMap()
+        Set<String> readOnly = entity == null ? readOnlyConstrained(type, 
constraints.keySet()) : Collections.<String> emptySet()
+        List<String> bindable = read("what data binding binds of 
[${type.name}]") {
+            DataBindingUtils.getBindingIncludeListForType(type)
+        }
+        Set<String> beanProperties = 
BeanUtils.getPropertyDescriptors(type)*.name.toSet()
+
+        Map<String, String> names = propertyNames.applyTo(model)
+        if (model.xml == null) {
+            // Grails renders a type in XML as an element named for its class.
+            model.setXml(new XML().name(GrailsNameUtils.getPropertyName(type)))
+        }
+        String versionName = null
+        if (entity != null) {
+            versionName = entity.versioned ? entity.version?.name : null
+            describeEntity(entity, model, names, versionName)
+        }
+        readOnly.each { String name -> property(model, names, 
name)?.setReadOnly(true) }
+        applyConstraints(model, constraints.findAll { String name, Constrained 
constrained -> !(name in readOnly) },
+                versionName, names)
+        if (bindable != null) {
+            markUnbound(model, names, bindable, beanProperties)
+        }
+    }
+
+    /**
+     * Reads a declaration of a type, logging rather than throwing where it 
cannot be read.
+     */
+    private static <T> T read(String what, Closure<T> declaration) {
+        try {
+            return declaration.call()
+        }
+        catch (RuntimeException | LinkageError e) {
+            LOG.warn("Could not read ${what}; it is described without them", e)
+            return null
+        }
+    }
+
+    /**
+     * The name a request parameter binding a property of a type is sent 
under: the name Grails
+     * uses for it, whatever name it is described under.
+     */
+    static String boundName(Class<?> type, String described) {
+        type != null ? (PropertyNames.of(type).nameOf(described) ?: described) 
: described
+    }
+
+    /**
+     * The name in the type of a property the document describes under the 
given name.
+     */
+    private static String propertyNamed(Map<String, String> names, String 
described) {
+        names.find { String name, String describedAs -> describedAs == 
described }?.key ?: described
+    }
+
+    private static Schema property(Schema model, Map<String, String> names, 
String name) {
+        (Schema) model.properties?.get(names[name] ?: name)
+    }
+
+    /**
+     * A property data binding does not bind - one the server assigns, such as 
{@code dateCreated},
+     * one constrained {@code bindable: false}, or a transient - is not 
something a client sends,
+     * so it is read only.
+     */
+    private static void markUnbound(Schema model, Map<String, String> names, 
List<String> bindable,
+                                    Set<String> beanProperties) {
+        ((Map<String, Schema>) model.properties)?.each { String described, 
Schema property ->
+            String name = propertyNamed(names, described)
+            if (!(name in bindable) && name in beanProperties) {
+                property.setReadOnly(true)
+            }
+        }
+    }
+
+    /**
+     * Grails declares the properties it binds on a command object an action 
takes.
+     */
+    private static boolean declaresBindableProperties(Class<?> type) {
+        try {
+            type.getField(DATABINDING_WHITELIST)
+            return true
+        }
+        catch (NoSuchFieldException | SecurityException ignored) {
+            return false
+        }
+    }
+
+    /**
+     * A command property that can only be read - one with a getter and 
nothing to bind it to - is
+     * not something a client sends, so it is read only and its constraints 
are not required of a
+     * request.
+     */
+    private static Set<String> readOnlyConstrained(Class<?> type, 
Collection<String> constrained) {
+        constrained.findAll { String name -> !isWritable(type, name) }.toSet()
+    }
+
+    private static boolean isWritable(Class<?> type, String name) {
+        String setter = 'set' + name.capitalize()
+        type.methods.any { Method method -> method.name == setter && 
method.parameterCount == 1 }
+    }
+
+    /**
+     * Grails renders an entity as its identifier, its persistent properties 
and, where it is
+     * configured to, its version. Anything else swagger-core finds - a 
transient, a getter with
+     * nothing persisted behind it, a public field, or the accessor GORM adds 
for the foreign key of
+     * an association - is not part of it.
+     */
+    private static void describeEntity(PersistentEntity entity, Schema model, 
Map<String, String> names,
+                                       String versionName) {
+        String identityName = entity.identity?.name
+        ((Map<String, Schema>) model.properties)?.keySet()?.removeAll { String 
described ->
+            String name = names.find { String candidate, String describedAs -> 
describedAs == described }?.key
+            name == null || (name != identityName && (name == versionName || 
entity.getPropertyByName(name) == null))
+        }
+
+        markReadOnly(model, names[identityName] ?: identityName)
+        if (INCLUDE_VERSION.get()) {
+            markReadOnly(model, names[versionName] ?: versionName)
+        }
+    }
+
+    /**
+     * GORM adds the version through a transform swagger-core does not see, so 
a property the
+     * server assigns is added where it is missing rather than only flagged. 
Grails renders it in XML
+     * as an attribute.
+     */
+    private static void markReadOnly(Schema model, String propertyName) {
+        if (!propertyName) {
+            return
+        }
+        Schema property = (Schema) model.properties[propertyName]
+        if (property == null) {
+            property = new IntegerSchema().format(INT64_FORMAT)
+            model.addProperty(propertyName, property)
+        }
+        property.setReadOnly(true)
+        property.setXml(new XML().attribute(true))
+    }
+
+    private static Map<String, Constrained> entityConstraints(PersistentEntity 
entity) {
+        Validator validator = entity.mappingContext?.getEntityValidator(entity)
+        if (validator instanceof ConstrainedEntity) {
+            Map<String, ConstrainedProperty> declared = ((ConstrainedEntity) 
validator).constrainedProperties
+            return declared ? new LinkedHashMap<String, Constrained>(declared) 
: Collections.<String, Constrained> emptyMap()
+        }
+        Collections.<String, Constrained> emptyMap()
+    }
+
+    private static Map<String, Constrained> validateableConstraints(Class<?> 
type) {
+        Map<String, Constrained> declared = (Map<String, Constrained>) 
InvokerHelper.invokeStaticMethod(
+                type, 'getConstraintsMap', null)
+        declared ?: Collections.<String, Constrained> emptyMap()
+    }
+
+    private static void applyConstraints(Schema model, Map<String, 
Constrained> constraints, String versionName,
+                                         Map<String, String> names) {
+        constraints.each { String name, Constrained constrained ->
+            Schema property = property(model, names, name)
+            if (property == null) {
+                return
+            }
+            applyConstraints(property, constrained)
+            String described = names[name] ?: name
+            if (!constrained.nullable && name != versionName && 
!model.required?.contains(described)) {
+                model.addRequiredItem(described)
+            }
+        }
+    }
+
+    private static void applyConstraints(Schema schema, Constrained 
constrained) {
+        if (constrained.inList && !schema.enum) {
+            Schema<Object> target = (Schema<Object>) schema
+            constrained.inList.each { target.addEnumItemObject(it) }
+        }
+
+        // The string constraints throw rather than return null when read from 
a property of
+        // another type, so they are consulted only where the property is a 
string. The test is
+        // the one the constraint itself applies - the property type, not the 
schema, which
+        // describes a date, a UUID and a byte array as a string too.
+        Class<?> type = constrained instanceof ConstrainedProperty ? 
((ConstrainedProperty) constrained).propertyType : null
+        if (type != null && CharSequence.isAssignableFrom(type)) {
+            applyStringConstraints(schema, constrained)
+        }
+        else if (type != null && (Number.isAssignableFrom(type) || 
type.primitive)) {
+            applyNumericConstraints(schema, constrained)
+        }
+        else if (type != null && (Collection.isAssignableFrom(type) || 
type.array)) {
+            applyCollectionConstraints(schema, constrained)
+        }
+
+        if (constrained.nullable && !schema.$ref) {

Review Comment:
   3fa7e888e7: a nullable property described by a reference is `oneOf: [$ref, 
{type: null}]` in 3.1 and `allOf: [$ref]`, `nullable: true` in 3.0; 12689ebe28 
keeps such a property out of a GET command's query parameters.
   



##########
grails-doc/src/en/guide/REST/openApi.adoc:
##########
@@ -0,0 +1,749 @@
+////
+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.
+////
+Grails applications can publish an https://www.openapis.org/[OpenAPI] 
description of their REST
+endpoints. The optional `grails-openapi` module derives it from the 
application itself - its URL
+mappings, its controllers, its domain classes and command objects, and their 
constraints - and
+lets the standard OpenAPI annotations correct or enrich what is derived.
+
+The description can be produced two ways, from the same configuration:
+
+* At build time, by the `generate-open-api` command, which writes it to a 
file. The file can be
+  packaged and served as a static resource, reviewed in a change, or handed to 
a client code
+  generator, and the running application exposes nothing it does not choose to 
serve.
+* At runtime, by https://springdoc.org[springdoc-openapi], which serves it at 
`/v3/api-docs` and
+  can serve Swagger UI beside it. springdoc builds its document from Spring 
MVC handler methods,
+  which Grails does not use, so the module contributes the Grails endpoints to 
it.
+
+==== Getting Started
+
+[source,groovy]
+----
+dependencies {
+    implementation 'org.apache.grails:grails-openapi'
+}
+----
+
+That is all the build-time command needs. To serve the description at runtime 
as well, add
+springdoc, and optionally Swagger UI:
+
+[source,groovy]
+----
+dependencies {
+    // the document, served at /v3/api-docs
+    implementation 'org.springdoc:springdoc-openapi-starter-webmvc-api'
+
+    // or the document and Swagger UI, served at /swagger-ui/index.html
+    implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui'
+}
+----
+
+==== Generating the Description at Build Time
+
+[source,shell]
+----
+./gradlew generateOpenApi
+----
+
+The command starts the application context, without a web server, and writes 
the default document
+to `build/openapi/openapi.yaml` and each <<openApiGroups,group>> to 
`build/openapi/openapi-<group>.yaml`.
+The `--output-directory` and `--format` options, or the 
`grails.openapi.output-directory` and
+`grails.openapi.output-format` settings, choose where and whether YAML or JSON 
is written:
+
+[source,shell]
+----
+./gradlew runCommand -Pargs="generate-open-api --format=json 
--output-directory=build/api"
+----
+
+Because the command starts the application, it needs whatever the application 
needs to start in
+the environment it runs in, such as a datasource.
+
+Where springdoc is configured, the command applies the method filters, 
operation customizers and
+OpenAPI customizers springdoc applies to the documents it serves, so each file 
describes what
+springdoc serves. A filter or customizer that fails there, such as one that 
reads the current
+request, is skipped and logged, rather than the operations it was applied to.
+
+To package the description and serve it as a static file, include it in the 
application's
+static resources when the application is assembled:
+
+[source,groovy]
+----
+def openApi = layout.buildDirectory.dir('openapi')
+
+tasks.named('bootJar') {
+    dependsOn 'generateOpenApi'
+    from(openApi) { into 'BOOT-INF/classes/static/api' }
+}
+----
+
+It is then served at `/static/api/openapi.yaml`, and any viewer can render it. 
For example,
+https://github.com/Redocly/redoc[Redoc] needs only a page that points at the 
file:
+
+[source,html]
+----
+<redoc spec-url="/static/api/openapi.yaml"></redoc>
+<script 
src="https://cdn.redoc.ly/redoc/latest/bundles/redoc.standalone.js";></script>
+----
+
+==== Serving the Description at Runtime
+
+With springdoc on the classpath no further configuration is required. 
springdoc registers its own
+resource handlers, so Swagger UI is served even though Grails leaves Spring 
Boot's catch-all
+resource handler disabled, and the springdoc paths fall through the Grails URL 
mappings because
+they resolve to no controller.
+
+The Grails operations are added to each document before any other springdoc 
customizer runs, so an
+`OpenApiCustomizer` bean, a `GlobalOpenApiCustomizer` bean, or a customizer a 
`GroupedOpenApi` adds
+sees them and can change them.
+
+An application whose URL mappings include a catch-all that resolves to a view 
or a controller - a
+single page application forwarding unmatched paths to an index view, or an API 
answering unmatched
+paths with a `404` of its own - does need to let the springdoc paths through:
+
+[source,groovy]
+----
+class UrlMappings {
+    static excludes = ['/swagger-ui/**', '/v3/api-docs/**']
+}
+----
+
+WARNING: The runtime description is served to anyone who can reach the 
application, and describes
+every endpoint, resource and constraint it documents. Secure `/v3/api-docs/**` 
and
+`/swagger-ui/**` the way the application secures anything else, or disable 
them where they should
+not be served, for example in production with `springdoc.api-docs.enabled: 
false` and
+`springdoc.swagger-ui.enabled: false`, which an application generated with the 
`openapi` feature
+sets for production. An application that only needs the description as a file
+can generate it at build time and leave springdoc out altogether. With the 
Spring Security plugin,
+whose default is to reject a request no rule matches, the paths need a rule of 
their own, such as
+an entry in `grails.plugin.springsecurity.controllerAnnotations.staticRules`.
+
+==== Configuration
+
+[cols="2,1,4"]
+|===
+| Setting | Default | Meaning
+
+| `grails.openapi.enabled` | `true` | Whether the description is generated at 
all
+| `grails.openapi.base-document` | | A YAML or JSON document the description 
starts from, see <<openApiBaseDocument,The Base Document>>
+| `grails.openapi.display-name` | `info.app.name` | The title of the default 
document
+| `grails.openapi.annotated-only` | `false` | Whether only annotated actions 
are described, see <<openApiSelecting,Choosing What Is Described>>
+| `grails.openapi.include-form-actions` | `false` | Whether the `create` and 
`edit` actions of a `RestfulController` are described
+| `grails.openapi.paths-to-match`, `grails.openapi.paths-to-exclude` | | Ant 
patterns of the paths the default document describes, or leaves out
+| `grails.openapi.packages-to-scan`, `grails.openapi.packages-to-exclude` | | 
The packages of the controllers the default document describes, or leaves out
+| `grails.openapi.produces-to-match`, `grails.openapi.consumes-to-match` | | 
The media types an operation the default document describes must produce, or 
consume
+| `grails.openapi.headers-to-match` | | The header conditions an operation 
must declare, such as `Accept-Version=1.0`
+| `grails.openapi.groups.<name>.*` | | A group and what it selects, see 
<<openApiGroups,Groups>>
+| `grails.openapi.output-directory` | `build/openapi` | Where 
`generate-open-api` writes
+| `grails.openapi.output-format` | `yaml` | Whether `generate-open-api` writes 
`yaml` or `json`
+|===
+
+Where springdoc is used, its own `springdoc.paths-to-match`, 
`springdoc.paths-to-exclude`,
+`springdoc.packages-to-scan`, `springdoc.packages-to-exclude`, 
`springdoc.produces-to-match`,
+`springdoc.consumes-to-match` and `springdoc.headers-to-match` apply to the 
Grails endpoints of the
+default document too, and `springdoc.api-docs.version` chooses between OpenAPI 
3.1, the default,
+and 3.0 for both the build-time and the runtime description.
+
+[[openApiSelecting]]
+==== Choosing What Is Described
+
+By default every action a URL mapping reaches is described. An application 
whose published API is
+only part of what it serves can narrow that down:
+
+* `@Hidden` on a controller or an action, or `@Operation(hidden = true)`, 
withholds it.
+* `grails.openapi.annotated-only: true` describes only an action that declares 
`@Operation`, or
+  whose controller declares `@Tag`, so the published API is exactly what the 
application annotated.
+* The path and package settings above select by where an endpoint is served 
and which controller
+  serves it:
+
+[source,yaml]
+----
+grails:
+    openapi:
+        paths-to-match: /api/**
+        packages-to-exclude: com.example.internal
+----
+
+The media type settings select the way springdoc selects a Spring MVC handler 
method: an operation
+is described only where it produces, or consumes, exactly the media types a 
setting lists. An
+operation produces the media types of its controller's `responseFormats`, 
`application/json` where
+it declares none, and consumes the same media types where it binds a body. A 
mapping declared for a
+version declares the `Accept-Version` header it is matched on, so 
`headers-to-match:
+Accept-Version=1.0` selects the operations of version `1.0`. Any other header 
criterion leaves every
+Grails endpoint out, as it leaves out a Spring handler method that declares no 
header condition:
+
+[source,yaml]
+----
+grails:
+    openapi:
+        produces-to-match: application/json
+----
+
+Where springdoc is used, its method filters decide for the Grails actions too. 
Each filter is
+called with the method the action is declared as, and an action a filter 
excludes is left out, at
+runtime and by the `generate-open-api` command. An `OpenApiMethodFilter` bean 
applies to the default
+document, a `GlobalOpenApiMethodFilter` bean to every document, and a filter a 
`GroupedOpenApi` adds
+to that group:
+
+[source,groovy]
+----
+@Bean
+OpenApiMethodFilter internalActionFilter() {
+    { Method action -> !action.isAnnotationPresent(Internal) } as 
OpenApiMethodFilter
+}
+----
+
+The `create` and `edit` actions of a `RestfulController` answer the forms an 
HTML client renders,
+so they are left out unless `grails.openapi.include-form-actions` is set.
+
+[[openApiGroups]]
+==== Groups
+
+A group is a document of its own, describing part of the application for one 
audience:
+
+[source,yaml]
+----
+grails:
+    openapi:
+        groups:
+            sales:
+                display-name: Sales API
+                paths-to-match: /api/v1/**
+            depots:
+                display-name: Depot Lifecycle API
+                paths-to-match: /api/v2/**
+----
+
+Each group takes the same `paths-to-match`, `paths-to-exclude`, 
`packages-to-scan`,
+`packages-to-exclude`, `produces-to-match`, `consumes-to-match` and 
`headers-to-match` settings as
+the default document, and `display-name` titles it. The
+`generate-open-api` command writes each group to a file of its own, and at 
runtime springdoc serves
+each at `/v3/api-docs/<name>`, applying the same criteria to any Spring MVC 
endpoints the
+application also has. A group declared to springdoc, in 
`springdoc.group-configs` or as a
+`GroupedOpenApi` bean, is honored the same way, both at runtime and by the 
command:
+
+[source,yaml]
+----
+springdoc:
+    group-configs:
+        - group: sales
+          paths-to-match: /api/v1/**
+----
+
+[[openApiBaseDocument]]
+==== The Base Document
+
+What describes the API as a whole - its title and description, its servers, 
how it is secured,
+the descriptions of its tags, vendor extensions for a viewer, and endpoints 
the application does
+not map itself, such as a login endpoint a security plugin provides - is 
written once, in a base
+document the description starts from:
+
+[source,yaml]
+----
+grails:
+    openapi:
+        base-document: classpath:openapi-base.yml
+----
+
+[source,yaml]
+.src/main/resources/openapi-base.yml
+----
+openapi: 3.1.0
+info:
+  title: Sales API
+  description: |
+    Inventory, orders and invoices for sales customers.
+servers:
+  - url: https://api.example.com
+security:
+  - Bearer: []
+tags:
+  - name: orders
+    description: Retrieving and creating orders
+components:
+  securitySchemes:
+    Bearer:
+      type: http
+      scheme: bearer
+      bearerFormat: JWT
+----
+
+The information, servers, security and external documentation of the base 
document are used as they
+are. Its tags, extensions and components are kept beside what is derived, and 
so are its paths, in
+each document whose path settings select them. Where the base
+document declares no version, the application's `info.app.version` is used, 
and without a base
+document the description is titled with `info.app.name`.
+
+==== What Is Documented
+
+Every URL mapping that names a controller statically becomes a path. A 
`resources` mapping
+contributes each of its generated HTTP methods:
+
+[source,groovy]
+----
+class UrlMappings {
+    static mappings = {
+        "/books"(resources: 'book')
+    }
+}
+----
+
+produces `GET` and `POST` on `/books`, and `GET`, `PUT`, `PATCH` and `DELETE` 
on `/books/{id}`, with
+`POST`, which Grails maps to `update` as well, where the controller's 
`allowedMethods` allows it.
+
+A `RestfulController` constructed read only answers `405` from its write 
actions, so only what it
+serves is described:
+
+[source,groovy]
+----
+class PublisherController extends RestfulController<Publisher> {
+
+    PublisherController() {
+        super(Publisher, true)
+    }
+}
+----
+
+mapped with `"/publishers"(resources: 'publisher')`, produces only `GET` on 
`/publishers` and
+`/publishers/{id}`. A write action the controller overrides
+is described, because it does whatever the override does.
+
+URL variables become OpenAPI path parameters, so `"/books/$id"` is documented 
as `/books/{id}`. The
+optional `.format` extension Grails appends to REST mappings is omitted, 
because OpenAPI describes
+response formats through content types rather than the path. An OpenAPI path 
parameter is always
+required, so a mapping with an optional variable, such as `"/photos/$id?"`, is 
documented at both
+`/photos/{id}` and `/photos`. A mapping with a wildcard that captures nothing, 
such as a catch-all
+`"/**"`, cannot be written as an OpenAPI path and is not documented.
+
+A mapping that names a controller but no action is documented as the 
controller's default action.
+One that takes the action from the path, such as 
`"/topics/$action"(controller: 'topic')`, is
+documented for each action the controller declares, and one that chooses the 
action by the method
+of the request, such as `action: [GET: 'show', DELETE: 'delete']`, for each 
method. A mapping whose
+action is decided by a closure as each request is made is skipped, and logged.
+
+A mapping declared for an HTTP method is documented for that method, unless 
the controller's
+`allowedMethods` refuses it for the action, which Grails answers with `405`. A 
mapping that accepts
+any method is documented for the methods `allowedMethods` declares for the 
action, or, where it
+declares none, for the method a `RestfulController` action of that name 
answers, and `GET` for any
+other action.
+A mapping that names a namespace is documented with that namespace's 
controller, so versioned
+controllers of the same name are each described at their own routes.
+
+==== Versions
+
+A mapping declared for a version is matched on the `Accept-Version` header a 
request sends:
+
+[source,groovy]
+----
+"/books"(version: '1.0', resources: 'book', namespace: 'v1')
+"/books"(version: '2.0', resources: 'book', namespace: 'v2')
+----
+
+A request asking for no version is answered by the highest version, so that is 
the one the default
+document describes, with an optional `Accept-Version` parameter naming it. 
Grails also takes the
+version from a `v` parameter of the `Accept` media type, which is not 
described. A group describes another
+version by selecting its header, and there the parameter is required:
+
+[source,yaml]
+----
+grails:
+    openapi:
+        groups:
+            v1:
+                headers-to-match: Accept-Version=1.0
+----
+
+==== Resource Schemas
+
+A `RestfulController` is described as serving the type it declares: 
`BookController extends
+RestfulController<Book>` responds with, and accepts, a `Book`, whatever the 
controller is named.
+The type may be a domain class or any other class, such as a command object 
the controller renders
+in place of the domain class. A controller that is not a `RestfulController` 
declares no response
+type, so its responses are described without a body unless an annotation 
declares one.
+
+A type is described in `components/schemas` through swagger-core, so a 
`@Schema` or Jackson
+annotation on the class or on one of its properties is honored, including one 
that renames the
+property. The `metaClass` every Groovy object has and the `errors` a 
validateable object has are
+not part of it. The constraints, and whether it is required or read only, 
follow a
+renamed property. Grails' converters, JSON views and data binding use the 
property's own name, so a
+Jackson rename describes a type the application renders and binds with 
Jackson. A property of a
+domain class or a command object is otherwise described by the name Grails 
uses, even where Jackson
+would write it another way, such as `ISBN`, which Jackson writes as `isbn`, 
and a command object's
+request parameters are described by the names Grails binds them by.
+
+A schema is named after its class, or after the name a `@Schema` annotation on 
the class declares.
+Classes sharing a name in different packages are each named by their package 
as well, such as
+`com.example.v1.Book` and `com.example.v2.Book`, so neither stands in for the 
other and the names do
+not depend on which is described first. A class sharing the name of a schema 
the base document
+declares, or one the document derives, such as `ValidationErrors` or a `Patch` 
schema, is named by
+its package in the same way. An enum is described where it is used, and so 
shares no name, unless
+`@Schema(enumAsRef = true)` describes it as a schema of its own.
+
+[source,groovy]
+----
+import io.swagger.v3.oas.annotations.media.Schema
+
+@Schema(description = 'A book in the catalog')
+class Book {
+
+    @Schema(description = 'Full title as printed', example = 'Dune')
+    String title
+
+    static constraints = {
+        title blank: false, nullable: false, maxSize: 255
+    }
+}
+----
+
+The declared constraints of a domain class or a `Validateable` class are 
applied over that result,
+because swagger-core cannot see them. The two combine, so the annotation above 
supplies the prose
+and the constraints block the validation:
+
+[source,json]
+----
+"title": {
+  "type": "string",
+  "description": "Full title as printed",
+  "example": "Dune",
+  "maxLength": 255
+}
+----
+
+The constraints are carried across as follows:
+
+[cols="1,1"]
+|===
+| Constraint | OpenAPI
+
+| `nullable: false` | listed in `required`
+| `nullable: true` | `nullable` in OpenAPI 3.0, a `null` type in 3.1, and 
`null` in any `enum`
+| `blank: false` | `minLength` of 1
+| `maxSize` / `minSize` / `size` of a string | `maxLength` and `minLength`
+| `maxSize` / `minSize` / `size` of a collection | `maxItems` and `minItems`
+| `min` / `max` / `range` | `minimum` and `maximum`
+| `inList` | `enum`
+| `matches` | `pattern`, anchored, since Grails matches the whole value
+| `email` / `url` | `format`
+|===
+
+A domain class is described as Grails renders it: its identifier and its 
persistent properties. A
+transient, or a getter with nothing persisted behind it, is not rendered and 
is not described. The
+version is not rendered either unless 
`grails.converters.domain.include.version` is set, and is

Review Comment:
   Covered in 7f430bc071, which names both keys and says the XML setting alone 
does not describe it.
   



##########
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
+    }
+
+    /**
+     * Whether a controller serves a resource the way a RestfulController 
does: it is one, or its
+     * save and update actions bind the same domain class, as the controllers 
the rest-api profile
+     * generates do.
+     */
+    boolean isResourceController(GrailsControllerClass controller) {
+        controller != null && 
(RestfulController.isAssignableFrom(controller.clazz) || 
boundResource(controller) != null)
+    }
+
+    /**
+     * The type a resource controller serves: the type argument a 
RestfulController declares, or
+     * the resource it was constructed with, or the domain class a generated 
controller binds.
+     */
+    Class<?> resourceType(GrailsControllerClass controller) {
+        if (!RestfulController.isAssignableFrom(controller.clazz)) {
+            return boundResource(controller)
+        }
+        Class<?> declared = 
GenericTypeResolver.resolveTypeArgument(controller.clazz, RestfulController)
+        if (declared != null && declared != Object) {
+            return declared
+        }
+        Object instance = instance(controller)
+        instance instanceof RestfulController ? ((RestfulController) 
instance).resource : null
+    }
+
+    /**
+     * Whether a RestfulController was constructed read only, which is decided 
by its constructor
+     * rather than declared on the class, so is read from the controller 
itself.
+     */
+    boolean isReadOnly(GrailsControllerClass controller) {
+        Object instance = controller != null ? instance(controller) : null
+        instance instanceof RestfulController && ((RestfulController) 
instance).readOnly
+    }
+
+    /**
+     * Whether an action addresses one resource, and so takes the identifier a 
mapping may leave
+     * optional: the actions of a resource controller that address one, or an 
action declaring an
+     * {@code id} parameter.
+     */
+    boolean takesId(GrailsControllerClass controller, String actionName) {
+        if (isResourceController(controller)) {
+            return RestfulControllerActions.takesId(actionName)
+        }
+        Method action = ActionAnnotations.actionMethod(controller.clazz, 
actionName)
+        action != null && action.parameters.any { Parameter parameter -> 
parameter.name == ID }
+    }
+
+    /**
+     * The controller the application context serves requests with, if it 
holds one.
+     */
+    Object instance(GrailsControllerClass controller) {
+        if (!instances.containsKey(controller.clazz)) {
+            instances[controller.clazz] = lookUp(controller)
+        }
+        instances[controller.clazz]
+    }
+
+    private Class<?> boundResource(GrailsControllerClass controller) {
+        if (!boundResources.containsKey(controller.clazz)) {
+            Class<?> saved = 
ActionAnnotations.commandObjectType(controller.clazz, 'save')
+            Class<?> updated = 
ActionAnnotations.commandObjectType(controller.clazz, 'update')
+            boundResources[controller.clazz] = saved != null && saved == 
updated && GrailsModelConverter.entityFor(saved) != null
+                    ? saved : null
+        }
+        boundResources[controller.clazz]
+    }
+
+    /**
+     * Grails registers a controller under its class name, which a subclass 
controller does not
+     * share, where a lookup by type would find both.
+     */
+    private Object lookUp(GrailsControllerClass controller) {
+        ApplicationContext context = grailsApplication?.mainContext
+        if (context == null) {
+            return null
+        }
+        try {
+            return context.containsBean(controller.fullName)
+                    ? context.getBean(controller.fullName)

Review Comment:
   7160cf2d1b: a controller is looked up only where it is a singleton, and 
springdoc's customizers get a `HandlerMethod` naming the controller bean, as 
Spring MVC registers one, rather than an instance. A prototype 
`RestfulController` is therefore described with its write actions, which the 
guide lists.
   



##########
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,

Review Comment:
   c44d8ffa07: `types` is set from `type` on the 3.1 schemas the annotations 
produce, and the test now asserts on the written document.
   



-- 
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]

Reply via email to