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


##########
grails-openapi/src/main/groovy/org/grails/openapi/GrailsModelConverter.groovy:
##########
@@ -0,0 +1,818 @@
+/*
+ *  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.annotations.media.Schema as SchemaAnnotation
+import io.swagger.v3.oas.annotations.media.Schema.AccessMode
+import io.swagger.v3.oas.models.SpecVersion
+import io.swagger.v3.oas.models.media.ArraySchema
+import io.swagger.v3.oas.models.media.ComposedSchema
+import io.swagger.v3.oas.models.media.IntegerSchema
+import io.swagger.v3.oas.models.media.JsonSchema
+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.core.ResolvableType
+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 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.Basic
+import org.grails.datastore.mapping.model.types.Embedded
+import org.grails.datastore.mapping.model.types.EmbeddedCollection
+import org.grails.datastore.mapping.model.types.ToMany
+import org.grails.web.databinding.BindingIncludeLists
+
+/**
+ * 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 describes a type 
so only where a
+ * Grails description is resolving it, which supplies the GORM metadata it 
uses through
+ * {@link #withMappingContexts}. Anywhere else, as springdoc resolves the 
types of its own
+ * endpoints, which Jackson renders rather than Grails, it passes the type on 
as it is, and records,
+ * while springdoc builds a document, the class it resolves under each name. 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()
+
+    /**
+     * The name Grails renders and binds an identifier by, whatever the 
identity is named.
+     */
+    private static final String IDENTITY = 'id'
+
+    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<Boolean> TYPES_ONLY = 
ThreadLocal.withInitial { false }
+
+    /**
+     * The class resolved under each name outside a Grails description while 
springdoc builds a
+     * document on the thread.
+     */
+    private static final ThreadLocal<Map<String, Class<?>>> RESOLVED_ELSEWHERE 
= 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)
+        }
+    }
+
+    /**
+     * Resolves the types a description reaches without reading what Grails 
declares of them, as a
+     * build processing the application ahead of time does, while the 
application is not running.
+     */
+    static <T> T withTypesOnly(Closure<T> work) {
+        Boolean previous = TYPES_ONLY.get()
+        TYPES_ONLY.set(true)
+        try {
+            return work.call()
+        }
+        finally {
+            TYPES_ONLY.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) }
+    }
+
+    /**
+     * Starts recording the class resolved under each name outside a Grails 
description on this
+     * thread, as springdoc starts building a document, before it resolves the 
types of its own
+     * endpoints. What an earlier document recorded is dropped.
+     */
+    static void recordResolvedNames() {
+        RESOLVED_ELSEWHERE.set([:])
+    }
+
+    /**
+     * Stops recording, as the Grails description of a document springdoc 
builds starts.
+     *
+     * @return the class resolved under each name outside a Grails description 
since recording
+     * started on this thread, which is none where it was not started
+     */
+    static Map<String, Class<?>> takeResolvedNames() {
+        Map<String, Class<?>> recorded = RESOLVED_ELSEWHERE.get()
+        RESOLVED_ELSEWHERE.remove()
+        recorded ?: Collections.<String, Class<?>> emptyMap()
+    }
+
+    @Override
+    Schema resolve(AnnotatedType annotatedType, ModelConverterContext context, 
Iterator<ModelConverter> chain) {
+        if (MAPPING_CONTEXTS.get() == null && !TYPES_ONLY.get()) {
+            // Resolved outside a Grails description, as springdoc resolves 
the types of its own
+            // endpoints, which Jackson renders rather than Grails.
+            recordResolvedName(annotatedType)
+            return chain.hasNext() ? chain.next().resolve(annotatedType, 
context, chain) : null
+        }
+        resolveDescribed(annotatedType, context, chain)
+    }
+
+    private static void recordResolvedName(AnnotatedType annotatedType) {
+        Map<String, Class<?>> recorded = RESOLVED_ELSEWHERE.get()
+        Class<?> type = recorded != null ? rawClass(annotatedType.type) : null
+        JavaType javaType = type != null ? javaType(annotatedType.type) : null
+        if (javaType != null && SchemaNames.isDescribedAsItself(annotatedType, 
javaType)
+                && SchemaNames.isNamed(annotatedType, javaType)) {
+            recorded.putIfAbsent(annotatedType.name ?: 
SchemaNames.naturalName(annotatedType, javaType), type)
+        }
+    }
+
+    private Schema resolveDescribed(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 && !TYPES_ONLY.get()) {
+            try {
+                describe(type, model)
+            }
+            catch (RuntimeException | LinkageError e) {
+                // The schema swagger-core resolved stands without what Grails 
declares of the
+                // type, rather than failing the document.
+                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 and binds the associated identifier as id, whatever 
the identity is named:
+        // 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(IDENTITY, identifierSchema(associated).xml(new 
XML().attribute(true)))
+                .description("The identifier of the associated 
${associated.javaClass.simpleName}".toString())
+        reference.addRequiredItem(IDENTITY)
+
+        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)
+        PropertyNames propertyNames = PropertyNames.of(type)
+        if (entity == null && !validateable && 
!declaresBindableProperties(type)) {
+            // Grails renders it by the names of its properties, and declares 
nothing else of it.
+            declareReferenceSiblings(model, propertyNames.applyTo(model), 
propertyNames)
+            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.
+        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}]") {
+            BindingIncludeLists.propertyNames(type)
+        }
+        Set<String> beanProperties = 
BeanUtils.getPropertyDescriptors(type)*.name.toSet()
+
+        Map<String, String> names = propertyNames.applyTo(model)
+        declareReferenceSiblings(model, names, propertyNames)
+        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 -> describable(model, names[name] ?: 
name)?.setReadOnly(true) }
+        describeCollectionsInXml(type, model, names, entity)
+        applyConstraints(model, constraints.findAll { String name, Constrained 
constrained -> !(name in readOnly) },
+                versionName, names)
+        if (bindable != null) {
+            markUnbound(model, names, bindable, beanProperties)
+        }
+    }
+
+    /**
+     * Grails renders a collection of values in XML as an element holding one 
for each, named for
+     * the class of the value, such as {@code 
<lines><string>a</string></lines>}. A to-many
+     * association is described so where it is referred to, and a value 
described as a schema of its
+     * own is named for its class there.
+     */
+    private static void describeCollectionsInXml(Class<?> type, Schema model, 
Map<String, String> names,
+                                                 PersistentEntity entity) {
+        ((Map<String, Schema>) model.properties)?.each { String described, 
Schema property ->
+            if (property.xml != null || !(property.type == 'array' || 
property.types?.contains('array'))) {
+                return
+            }
+            property.setXml(new XML().wrapped(true))
+            Class<?> element = elementType(type, propertyNamed(names, 
described), entity)
+            if (element != null && property.items != null && 
!property.items.$ref) {
+                property.items.setXml(new 
XML().name(GrailsNameUtils.getPropertyName(element)))
+            }
+        }
+    }
+
+    /**
+     * The class of the values a collection property holds: the one GORM maps 
it with, or the one
+     * its type declares.
+     */
+    private static Class<?> elementType(Class<?> type, String name, 
PersistentEntity entity) {
+        PersistentProperty persistent = entity?.getPropertyByName(name)
+        if (persistent instanceof Basic && ((Basic) persistent).componentType 
!= null) {
+            return ((Basic) persistent).componentType
+        }
+        Method getter = BeanUtils.getPropertyDescriptor(type, name)?.readMethod
+        if (getter == null) {
+            return null
+        }
+        ResolvableType declared = ResolvableType.forMethodReturnType(getter)
+        declared.array ? declared.componentType.resolve() : 
declared.asCollection().resolveGeneric(0)
+    }
+
+    /**
+     * 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 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) {
+        if (!model.properties) {
+            return
+        }
+        new ArrayList<String>(((Map<String, Schema>) 
model.properties).keySet()).each { String described ->
+            String name = propertyNamed(names, described)
+            if (!(name in bindable) && name in beanProperties) {
+                describable(model, described).setReadOnly(true)
+            }
+        }
+    }
+
+    /**
+     * The schema a property is described by: its own, or, where it is a 
reference in an OpenAPI
+     * 3.0 document, which ignores anything beside a {@code $ref}, one holding 
all of the reference,
+     * which describes the property from then on.
+     */
+    private static Schema describable(Schema model, String described) {
+        Schema property = (Schema) model.properties?.get(described)
+        if (property?.$ref && model.specVersion != SpecVersion.V31) {
+            ComposedSchema reference = new ComposedSchema()
+            reference.setAllOf([property])
+            model.properties[described] = reference
+            return reference
+        }
+        property
+    }
+
+    /**
+     * OpenAPI 3.0 ignores anything beside a {@code $ref}, so swagger-core 
leaves out of a property
+     * described by one what its {@code @Schema} says of the property. It is 
said of all of the
+     * reference instead.
+     */
+    private static void declareReferenceSiblings(Schema model, Map<String, 
String> names, PropertyNames propertyNames) {
+        if (model.specVersion == SpecVersion.V31 || !model.properties) {
+            return
+        }
+        new ArrayList<String>(((Map<String, Schema>) 
model.properties).keySet()).each { String described ->
+            SchemaAnnotation declared = ((Schema) 
model.properties[described]).$ref
+                    ? propertyNames.declaredSchema(propertyNamed(names, 
described)) : null
+            AccessMode access = declared?.accessMode()
+            boolean readOnly = access == AccessMode.READ_ONLY
+            boolean writeOnly = access == AccessMode.WRITE_ONLY
+            if (declared == null || !(declared.description() || 
declared.deprecated() || readOnly || writeOnly)) {
+                return
+            }
+            Schema property = describable(model, described)
+            if (declared.description()) {
+                property.setDescription(declared.description())
+            }
+            if (declared.deprecated()) {
+                property.setDeprecated(true)
+            }
+            if (readOnly) {
+                property.setReadOnly(true)
+            }
+            if (writeOnly) {
+                property.setWriteOnly(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 (identityName && identityName != IDENTITY) {
+            // Grails renders the identifier in XML as the id attribute, 
whatever it is named.
+            ((Schema) model.properties[names[identityName] ?: 
identityName]).xml.setName(IDENTITY)
+        }
+        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 && property.$ref) {
+                if (model.specVersion == SpecVersion.V31) {
+                    model.properties[described] = nullableReference(property)
+                }
+                else {
+                    describable(model, described).setNullable(true)
+                }
+            }
+            if (!constrained.nullable && name != versionName && 
!model.required?.contains(described)) {
+                model.addRequiredItem(described)

Review Comment:
   Fixed in 0140963957. Where a property's `@Schema` says whether it must be 
sent, that now decides `required`:
   - `nullable = true` or `requiredMode = NOT_REQUIRED` keeps it out;
   - `requiredMode = REQUIRED` keeps it in, even where the constraint allows 
null.
   
   It wins over an explicit constraint as well as a defaulted one, so the rule 
doesn't depend on where the constraint came from. The other keywords still 
combine.
   
   `AnnotatedPropertySpec` uses your `InventorySummaryCommand` as its fixture 
and checks the written 3.0 and 3.1 documents (`required == ['allowBooking']`). 
It also covers a command whose explicit constraints the annotations contradict. 
The guide says so under the constraints table.
   



##########
grails-openapi/src/main/groovy/org/grails/openapi/GrailsModelConverter.groovy:
##########
@@ -0,0 +1,818 @@
+/*
+ *  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.annotations.media.Schema as SchemaAnnotation
+import io.swagger.v3.oas.annotations.media.Schema.AccessMode
+import io.swagger.v3.oas.models.SpecVersion
+import io.swagger.v3.oas.models.media.ArraySchema
+import io.swagger.v3.oas.models.media.ComposedSchema
+import io.swagger.v3.oas.models.media.IntegerSchema
+import io.swagger.v3.oas.models.media.JsonSchema
+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.core.ResolvableType
+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 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.Basic
+import org.grails.datastore.mapping.model.types.Embedded
+import org.grails.datastore.mapping.model.types.EmbeddedCollection
+import org.grails.datastore.mapping.model.types.ToMany
+import org.grails.web.databinding.BindingIncludeLists
+
+/**
+ * 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 describes a type 
so only where a
+ * Grails description is resolving it, which supplies the GORM metadata it 
uses through
+ * {@link #withMappingContexts}. Anywhere else, as springdoc resolves the 
types of its own
+ * endpoints, which Jackson renders rather than Grails, it passes the type on 
as it is, and records,
+ * while springdoc builds a document, the class it resolves under each name. 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()
+
+    /**
+     * The name Grails renders and binds an identifier by, whatever the 
identity is named.
+     */
+    private static final String IDENTITY = 'id'
+
+    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<Boolean> TYPES_ONLY = 
ThreadLocal.withInitial { false }
+
+    /**
+     * The class resolved under each name outside a Grails description while 
springdoc builds a
+     * document on the thread.
+     */
+    private static final ThreadLocal<Map<String, Class<?>>> RESOLVED_ELSEWHERE 
= 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)
+        }
+    }
+
+    /**
+     * Resolves the types a description reaches without reading what Grails 
declares of them, as a
+     * build processing the application ahead of time does, while the 
application is not running.
+     */
+    static <T> T withTypesOnly(Closure<T> work) {
+        Boolean previous = TYPES_ONLY.get()
+        TYPES_ONLY.set(true)
+        try {
+            return work.call()
+        }
+        finally {
+            TYPES_ONLY.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) }
+    }
+
+    /**
+     * Starts recording the class resolved under each name outside a Grails 
description on this
+     * thread, as springdoc starts building a document, before it resolves the 
types of its own
+     * endpoints. What an earlier document recorded is dropped.
+     */
+    static void recordResolvedNames() {
+        RESOLVED_ELSEWHERE.set([:])
+    }
+
+    /**
+     * Stops recording, as the Grails description of a document springdoc 
builds starts.
+     *
+     * @return the class resolved under each name outside a Grails description 
since recording
+     * started on this thread, which is none where it was not started
+     */
+    static Map<String, Class<?>> takeResolvedNames() {
+        Map<String, Class<?>> recorded = RESOLVED_ELSEWHERE.get()
+        RESOLVED_ELSEWHERE.remove()
+        recorded ?: Collections.<String, Class<?>> emptyMap()
+    }
+
+    @Override
+    Schema resolve(AnnotatedType annotatedType, ModelConverterContext context, 
Iterator<ModelConverter> chain) {
+        if (MAPPING_CONTEXTS.get() == null && !TYPES_ONLY.get()) {
+            // Resolved outside a Grails description, as springdoc resolves 
the types of its own
+            // endpoints, which Jackson renders rather than Grails.
+            recordResolvedName(annotatedType)
+            return chain.hasNext() ? chain.next().resolve(annotatedType, 
context, chain) : null
+        }
+        resolveDescribed(annotatedType, context, chain)
+    }
+
+    private static void recordResolvedName(AnnotatedType annotatedType) {
+        Map<String, Class<?>> recorded = RESOLVED_ELSEWHERE.get()
+        Class<?> type = recorded != null ? rawClass(annotatedType.type) : null
+        JavaType javaType = type != null ? javaType(annotatedType.type) : null
+        if (javaType != null && SchemaNames.isDescribedAsItself(annotatedType, 
javaType)
+                && SchemaNames.isNamed(annotatedType, javaType)) {
+            recorded.putIfAbsent(annotatedType.name ?: 
SchemaNames.naturalName(annotatedType, javaType), type)
+        }
+    }
+
+    private Schema resolveDescribed(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 && !TYPES_ONLY.get()) {
+            try {
+                describe(type, model)

Review Comment:
   Fixed in a327508307. In a 3.1 document, a property whose `@Schema` declares 
`type` without `types` now has `types` set from it, keeping the `null` a 
`nullable = true` annotation allows. This happens:
   - before the constraints are applied, so a constraint's `nullable: true` 
adds `null` to the declared type;
   - before the early return, so your `Money` class, which is neither an entity 
nor a `Validateable`, is covered.
   
   `AnnotatedPropertySpec` asserts on the written documents. In 3.1, `typed` is 
`{type: number, format: double}`, `typedLong` is `{type: integer, format: 
int64}` and `types31` is unchanged; the 3.0 document is unchanged.
   



##########
grails-openapi/src/main/groovy/grails/openapi/GrailsOpenApiGenerator.groovy:
##########
@@ -0,0 +1,697 @@
+/*
+ *  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.ActionHooks
+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)
+
+    /**
+     * The name the validation errors are described under where the document 
already describes
+     * something else as {@link #VALIDATION_ERRORS_SCHEMA}: that of the class 
Grails renders them from.
+     */
+    private static final String QUALIFIED_VALIDATION_ERRORS_SCHEMA = 
'grails.validation.ValidationErrors'
+
+    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 ActionHooks hooks
+        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
+            // What springdoc decides by the action itself, where the 
selection is springdoc's.
+            this.hooks = selection instanceof ActionHooks ? (ActionHooks) 
selection : null
+            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() {
+            Map<String, Class<?>> resolvedBySpringdoc = 
GrailsModelConverter.takeResolvedNames()
+            OpenAPI base = BaseDocument.read(settings.baseDocument, 
resourceLoader, openapi31)
+            if (base != null) {
+                BaseDocument.merge(base, openApi, paths, components) { String 
path -> selection.selectsPath(path) }
+            }
+            describeApplication()
+
+            // 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. One springdoc
+            // resolved from a class for its own endpoints is that class's: a 
Grails endpoint using
+            // the class is described by it, as Grails renders the class.
+            Set<String> declared = base?.components?.schemas?.keySet() ?: 
Collections.<String> emptySet()
+            components.schemas?.keySet()?.each { String name ->
+                schemas.reserve(name, name in declared ? null : 
resolvedBySpringdoc[name])
+            }
+            // Validation errors the base document declares describe them in 
place of those derived.
+            // A schema springdoc already has under the name describes 
something else, so the errors
+            // are described under their qualified name.
+            boolean errorsDeclared = VALIDATION_ERRORS_SCHEMA in declared
+            String errorsSchema = schemas.reserve(errorsDeclared || 
!components.schemas?.containsKey(VALIDATION_ERRORS_SCHEMA)
+                    ? VALIDATION_ERRORS_SCHEMA : 
QUALIFIED_VALIDATION_ERRORS_SCHEMA)
+            responses = new OperationResponses(schemas, new 
ValidationErrorsContent(components,
+                    ErrorsViews.of(grailsApplication?.mainContext), 
errorsSchema, errorsDeclared))
+            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.
+                for (String action : controller.actions) {
+                    DocumentParts.describe("action 
[${controllerName}.${action}]".toString()) {
+                        addExpandedOperation(mapping, controller, action, true)
+                    }
+                }
+                if (UrlMappingPaths.isOptional(mapping, ACTION_TOKEN)) {
+                    DocumentParts.describe("action 
[${controllerName}.${controller.defaultAction}]".toString()) {
+                        addDefaultActionOperation(mapping, controller, 
controllerName)
+                    }
+                }
+                return
+            }
+            // A mapping that names only the controller dispatches to its 
default action.
+            addMappedOperation(mapping, controller, controllerName, actionName 
?: controller?.defaultAction, mapping.httpMethod)
+        }
+
+        /**
+         * A mapping whose action is optional reaches the controller's default 
action where the path
+         * leaves the action out, and every variable after it, which Grails 
would take for the action.
+         */
+        private void addDefaultActionOperation(UrlMapping mapping, 
GrailsControllerClass controller, String controllerName,
+                                               Map<String, String> 
substitutions = [:], Set<String> omitted = [] as Set) {
+            String actionName = controller.defaultAction
+            if (!actionName || !controller.actions.contains(actionName) || 
!isDescribed(controller, controller.clazz, actionName)) {
+                return
+            }
+            List<String> names = UrlMappingPaths.variableNames(mapping)
+            Set<String> left = new HashSet<String>(omitted)
+            left.addAll(names.subList(names.indexOf(ACTION_TOKEN), 
names.size()))
+            List<String> described = UrlMappingPaths.paths(mapping, 
substitutions, left).take(1)
+            for (PathItem.HttpMethod method : httpMethods(mapping.httpMethod, 
controller, actionName)) {
+                for (String path : described) {
+                    addOperation(mapping, path, method, controller, 
controller.clazz, controllerName, actionName,
+                            operationId(controller, controllerName, 
actionName, method))
+                }
+            }
+        }
+
+        private void addMappedOperation(UrlMapping mapping, 
GrailsControllerClass controller, String controllerName,
+                                        String actionName, String 
mappedMethod) {
+            Class<?> controllerType = controller?.clazz
+            if (!isDescribed(controller, controllerType, actionName)) {
+                return
+            }
+            for (PathItem.HttpMethod method : httpMethods(mappedMethod, 
controller, actionName)) {
+                for (String path : UrlMappingPaths.paths(mapping)) {
+                    addOperation(mapping, path, method, controller, 
controllerType, controllerName, actionName,
+                            operationId(controller, controllerName, 
actionName, method))
+                }
+            }
+        }
+
+        /**
+         * The methods an action is described as answering: the one the 
mapping declares, unless the
+         * controller's {@code allowedMethods} refuses it for the action, 
which Grails answers with
+         * 405; or, where the mapping accepts any method, those {@code 
allowedMethods} declares for the
+         * action, or the one its kind of action answers.
+         */
+        private List<PathItem.HttpMethod> httpMethods(String mappedMethod, 
GrailsControllerClass controller,
+                                                      String actionName) {
+            List<String> allowed = 
RestfulControllerActions.allowedMethods(actionName, 
controllers.allowedMethods(controller))
+            List<String> methods
+            if (mappedMethod && mappedMethod != UrlMapping.ANY_HTTP_METHOD) {
+                String declared = mappedMethod.toUpperCase(Locale.ENGLISH)
+                methods = allowed == null || declared in allowed ? [declared] 
: []
+            }
+            else {
+                methods = allowed ?: 
[RestfulControllerActions.defaultMethod(actionName, 
controllers.isResourceController(controller))]
+            }
+            methods.collect { String method -> toHttpMethod(method) 
}.findAll().unique()
+        }
+
+        /**
+         * Describes the REST controllers a mapping reaches without naming, 
which is how a REST
+         * application is mapped: {@code get "/$controller"(action: 'index')} 
names the action but
+         * leaves the controller to the request, and the default
+         * {@code "/$controller/$action?/$id?"} mapping leaves both.
+         *
+         * <p>Expansion follows the mapping rather than the controllers, so 
only routes the
+         * application actually serves are described.</p>
+         */
+        private void addExpandedMappings() {
+            List<GrailsControllerClass> reached = 
controllers.controllers.findAll { GrailsControllerClass it ->
+                controllers.isRestController(it) && 
!ActionAnnotations.isHidden(it.clazz)
+            }.toList()
+            if (!reached) {
+                return
+            }
+
+            for (UrlMapping mapping : urlMappingsHolder.urlMappings) {
+                if (asStaticName(mapping.controllerName) || mapping.viewName 
|| isResponseCode(mapping)) {
+                    continue
+                }
+                List<String> names = UrlMappingPaths.variableNames(mapping)
+                if (!names.contains(CONTROLLER_TOKEN)) {
+                    continue
+                }
+                String mappedAction = asStaticName(mapping.actionName)
+                boolean expandsAction = mappedAction == null && 
names.contains(ACTION_TOKEN)
+                if (mappedAction == null && !expandsAction) {
+                    continue
+                }
+
+                boolean capturesNamespace = names.contains(NAMESPACE_TOKEN)
+                boolean optionalAction = expandsAction && 
UrlMappingPaths.isOptional(mapping, ACTION_TOKEN)
+                for (GrailsControllerClass controller : reached) {
+                    // A mapping that leaves the namespace out reaches the 
controller Grails resolves for
+                    // the name alone; one that captures it reaches each 
controller at its own.
+                    if (!capturesNamespace && 
!controllers.controllerFor(controller.logicalPropertyName, 
null).is(controller)) {
+                        continue
+                    }
+                    Collection<String> actions = expandsAction ? 
controller.actions : [mappedAction]
+                    for (String actionName : actions) {
+                        if (!controllers.isRestAction(controller, actionName)) 
{
+                            continue
+                        }
+                        DocumentParts.describe("action 
[${controller.logicalPropertyName}.${actionName}]".toString()) {
+                            addExpandedOperation(mapping, controller, 
actionName, expandsAction)
+                        }
+                    }
+                    if (optionalAction && controllers.isRestAction(controller, 
controller.defaultAction)) {
+                        // As for a mapping naming the controller, the path 
without the action reaches
+                        // the default action: GET /book is the index of 
"/$controller/$action?/$id?".
+                        DocumentParts.describe("action 
[${controller.logicalPropertyName}.${controller.defaultAction}]".toString()) {
+                            Map<String, String> substitutions = [:]
+                            Set<String> omitted = [] as Set
+                            if (reachedAt(mapping, controller, substitutions, 
omitted)) {
+                                addDefaultActionOperation(mapping, controller, 
controller.logicalPropertyName,
+                                        substitutions, omitted)
+                            }
+                        }
+                    }
+                }
+            }
+        }
+
+        private void addExpandedOperation(UrlMapping mapping, 
GrailsControllerClass controller,
+                                          String actionName, boolean 
expandsAction) {
+            // A mapping that names the action reaches every controller; one 
that leaves the action to
+            // the request only reaches the actions that controller declares.
+            if (!actionName || !controller.actions.contains(actionName)
+                    || !isDescribed(controller, controller.clazz, actionName)) 
{
+                return
+            }
+
+            String controllerName = controller.logicalPropertyName
+            Map<String, String> substitutions = [:]
+            Set<String> omitted = [] as Set
+            if (!reachedAt(mapping, controller, substitutions, omitted)) {
+                return
+            }
+            boolean takesId = controllers.takesId(controller, actionName)
+            if (expandsAction) {
+                substitutions[ACTION_TOKEN] = actionName
+                if (!takesId) {
+                    omitted << ID_TOKEN
+                }
+            }
+
+            List<String> described = UrlMappingPaths.paths(mapping, 
substitutions, omitted)
+            for (PathItem.HttpMethod method : httpMethods(mapping.httpMethod, 
controller, actionName)) {
+                String operationId = operationId(controller, controllerName, 
actionName, method)
+                if (expandsAction) {
+                    operationId += '_byAction'
+                }
+                // Only the form that addresses a resource is described where 
the action takes one;
+                // the shorter forms an optional identifier allows do not 
reach it.
+                for (String path : (expandsAction && takesId ? 
described.take(1) : described)) {
+                    addOperation(mapping, path, method, controller, 
controller.clazz, controllerName, actionName, operationId)
+                }
+            }
+        }
+
+        /**
+         * Fixes the controller, and the namespace where the mapping captures 
it, to those of the
+         * controller a mapping is expanded for.
+         *
+         * @return false where the mapping does not reach the controller: it 
captures a namespace the
+         * controller does not have
+         */
+        private boolean reachedAt(UrlMapping mapping, GrailsControllerClass 
controller, Map<String, String> substitutions,
+                                  Set<String> omitted) {
+            substitutions[CONTROLLER_TOKEN] = controller.logicalPropertyName
+            if 
(!UrlMappingPaths.variableNames(mapping).contains(NAMESPACE_TOKEN)) {
+                return true
+            }
+            if (controller.namespace) {
+                substitutions[NAMESPACE_TOKEN] = controller.namespace
+            }
+            else if (UrlMappingPaths.isOptional(mapping, NAMESPACE_TOKEN)) {
+                omitted << NAMESPACE_TOKEN
+            }
+            else {
+                return false
+            }
+            true
+        }
+
+        private boolean isDescribed(GrailsControllerClass controller, Class<?> 
controllerType, String actionName) {
+            if (ActionAnnotations.isHidden(controllerType)
+                    || (actionName && 
ActionAnnotations.isHidden(controllerType, actionName))) {
+                return false
+            }
+            boolean restful = controllerType != null && 
RestfulController.isAssignableFrom(controllerType)
+            if (controllers.isResourceController(controller) && 
!settings.includeFormActions
+                    && RestfulControllerActions.isFormAction(actionName)) {
+                return false
+            }
+            if (restful && 
RestfulControllerActions.refusedWhenReadOnly(controllerType, actionName)
+                    && controllers.isReadOnly(controller)) {
+                return false
+            }
+            !settings.annotatedOnly || 
ActionAnnotations.isAnnotated(controllerType, actionName)
+        }
+
+        private void addOperation(UrlMapping mapping, String path, 
PathItem.HttpMethod method,
+                                  GrailsControllerClass controller, Class<?> 
controllerType, String controllerName,
+                                  String actionName, String operationId) {
+            Method action = ActionAnnotations.actionMethod(controllerType, 
actionName)
+            if (!selection.selects(path, controllerType) || (hooks != null && 
!hooks.selectsAction(action))) {
+                return
+            }
+            PathItem pathItem = paths.get(path) ?: new PathItem()
+            if (pathItem.readOperationsMap().containsKey(method)) {
+                return
+            }
+            List<String> produces = mediaTypes.responseMediaTypes(controller, 
actionName).keySet().toList()
+            List<String> consumes = bindsBody(method, controller, 
controllerType, actionName)
+                    ? mediaTypes.bodyMediaTypes(controller, controllerType, 
actionName)
+                    : Collections.<String> emptyList()
+            String version = MappingVersions.versionOf(mapping)
+            List<String> headers = version != null
+                    ? ["${HttpHeaders.ACCEPT_VERSION}=${version}".toString()]
+                    : Collections.<String> emptyList()
+            if (!selection.selectsConditions(produces, consumes, headers)) {
+                return
+            }
+
+            Operation operation = buildOperation(mapping, path, method, 
controller, controllerType, controllerName,
+                    actionName, operationId)
+            if (version != null) {
+                OperationParameters.addVersionParameter(operation, version, 
!versions.isLatest(mapping, version))
+            }
+            schemas.addedBy { ActionAnnotations.apply(operation, 
controllerType, actionName, components, openapi31) }
+            if (hooks != null) {
+                operation = hooks.customize(operation, components, controller, 
action)
+                if (operation == null) {
+                    return
+                }
+            }
+            pathItem.operation(method, operation)
+            paths.addPathItem(path, pathItem)
+        }
+
+        private Operation buildOperation(UrlMapping mapping, String path, 
PathItem.HttpMethod method,
+                                         GrailsControllerClass controller, 
Class<?> controllerType,
+                                         String controllerName, String 
actionName, String operationId) {
+            boolean restful = controllers.isResourceController(controller)
+            Class<?> resourceType = restful ? 
controllers.resourceType(controller) : null
+            List<String> pathNames = UrlMappingPaths.templateVariables(path)
+
+            Operation operation = new Operation()
+            List<TagAnnotation> tags = 
ActionAnnotations.declaredTags(controllerType)
+            operation.setTags(tags ? tags*.name().toList() : [controllerName])
+            operation.setOperationId(operationId)
+
+            parameters.addPathParameters(operation, mapping, pathNames, 
controllerType, actionName, resourceType)
+            if (restful && actionName && 
RestfulControllerActions.paginates(actionName)) {

Review Comment:
   Thanks. This one changed the design more than the fix you suggested, so 
here's the reasoning. It's in three commits: ab803da22f, 2aba219a9e and 
a38216a7f2.
   
   **Why an override isn't described as a plain action.** Overriding is the 
only way to put an annotation on an inherited action. If only an inherited 
action were derived, then
   
   ```groovy
   @Operation(summary = 'List the catalog')
   @Override
   Object index(Integer max) { super.index(max) }
   ```
   
   would lose its paging and its list body because a summary was added, and a 
`save` annotated the same way would lose its `201` and `422`. The example app's 
`BookController` and the guide's `OrderController` are written exactly like 
this. Not all of it can be declared back, either:
   - no annotation can describe the XML `list` wrapper;
   - the `422` content depends on the renderer: the converters' shape, the 
JSON-view errors shape, XML, and `grails.validation.ValidationErrors` where 
springdoc already holds the name.
   
   A default the application can't restore is worse than one it has to trim.
   
   **Where the line is drawn instead: the action's role vs RestfulController's 
code.** The controllers Grails generates for a REST application don't extend 
`RestfulController`, yet they answer the same way. The scaffold template does 
`params.max = Math.min(max ?: 10, 100)` and `list(params)`, `respond x, 
[status: CREATED]`, `respond x.errors` and `render status: NO_CONTENT`. So the 
statuses, bodies and paging belong to the action's role in the resource, not to 
RestfulController's code. They're derived by action name, whether the action is 
inherited, overridden or generated (ab803da22f).
   
   What only RestfulController's code does is derived only where that code 
runs: `isInherited`, with `patch` following `update`. That's the read-only 
`405`, which is the rule the guide already states, and now the `Location` 
header (2aba219a9e), which generated controllers never send. It also closed a 
gap: `RestfulController.update` sends `Location` on its `200` as well, and that 
wasn't described.
   
   **Your two cases.**
   - A `save` override declaring `@ApiResponse(responseCode = '200')` is now 
`200` only. A success status an action declares replaces the success statuses 
derived for it, along with their headers (a38216a7f2). That's springdoc's own 
rule: once a method declares responses, 
`GenericResponseService.buildApiResponses` doesn't add the one it would derive. 
Here it's narrowed to success statuses, so declaring a `409` doesn't drop the 
`200`. It also fixes plain controllers, where declaring `201` used to leave the 
derived `200` beside it.
   - An `index` override that doesn't page withdraws the four parameters with 
`@Parameter(hidden = true)`. The guide shows how, and `ResourceActionSpec` pins 
it. That's the one cost of this design, and I think it's in the right place. A 
listing normally pages, through `super.index` or by passing `params` to a 
service as generated controllers do, so the exception pays with four standard 
annotations. The other default would make the common case pay more, and some of 
that can't be paid at all.
   
   **Custom actions.** On the same line, an action RestfulController doesn't 
declare, such as `search`, is now described as any controller's action is: 
`200`, a `404` where its path has a variable, and no guessed body. Before, it 
got the resource as its response and request body. The default mapping also now 
reaches it at the `id` it declares, where it used to be described without one.
   
   The one derived thing an annotation still can't withdraw is the `422` on a 
replacing `save` that doesn't validate. Error statuses stay additive, the same 
way springdoc adds `@ControllerAdvice` responses to every operation.
   



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