Skip to content

Add optional grails-openapi module to describe an application with OpenAPI - #16275

Merged
jdaugherty merged 132 commits into
apache:8.0.xfrom
codeconsole:feat/openapi-springdoc-8.0.x
Sep 28, 2026
Merged

jdaugherty merged 132 commits into
apache:8.0.xfrom
codeconsole:feat/openapi-springdoc-8.0.x

Conversation

@codeconsole

@codeconsole codeconsole commented Aug 30, 2026 •

Copy link
Copy Markdown
Contributor

Adds an optional grails-openapi module that describes a Grails application's REST endpoints as an OpenAPI document. The description is derived from the application's URL mappings, controllers, domain classes, command objects and their constraints. The standard OpenAPI annotations correct or enrich what is derived, and none of them are required. Also adds an openapi forge feature.

The document is produced two ways from the same configuration:

  • At build time, by the generate-open-api command, which writes it to a file that can be packaged, reviewed in a change, or handed to a client code generator. springdoc is not needed for this.
  • At runtime, by springdoc, which serves it at /v3/api-docs, optionally with Swagger UI. springdoc builds its document from Spring MVC handler methods, which Grails does not dispatch through, so on its own it serves no Grails paths. The module contributes them, and excludes the paths springdoc serves from the URL mappings, so a catch-all mapping, such as a single page application's, does not answer them.

At runtime the document describes every documented endpoint and constraint to anyone who can reach it. Secure /v3/api-docs/** and /swagger-ui/**, or disable them with springdoc.api-docs.enabled: false and springdoc.swagger-ui.enabled: false, as the forge feature does in production.

Usage

dependencies {
    implementation 'org.apache.grails:grails-openapi'

    // optional: serve it at runtime, with Swagger UI at /swagger-ui/index.html
    implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui'
}
./gradlew generateOpenApi

writes build/openapi/openapi.yaml, and build/openapi/openapi-<group>.yaml for each group, with a character a file name cannot hold, such as the / of admin/v1, written as -; where two groups would share a file, it writes nothing and names them. The command starts the application context, so it needs what the application needs to start, such as a datasource, and applies springdoc's groups, filters and customizers where springdoc is enabled in the environment it runs in, warning where it is not. The format and directory are options of the command, or grails.openapi.output-format and grails.openapi.output-directory:

./gradlew runCommand -Pargs="generate-open-api --format=json --output-directory=build/api"

Given:

class Book {
    String title
    String genre
    Author author

    static constraints = {
        title blank: false, nullable: false, maxSize: 120
        genre nullable: true, inList: ['scifi', 'history']
        author nullable: true
    }
}

class BookController extends RestfulController<Book> {
    static responseFormats = ['json', 'xml']

    BookController() {
        super(Book)
    }
}

class UrlMappings {
    static mappings = {
        '/books'(resources: 'book')
    }
}

the document describes the operations the mapping generates, each in application/json and text/xml, and:

"Book": {
  "type": "object",
  "properties": {
    "title": { "type": "string", "maxLength": 120, "minLength": 1 },
    "genre": { "type": ["string", "null"], "enum": ["scifi", "history", null] },
    "author": {
      "type": ["object", "null"],
      "description": "The identifier of the associated Author",
      "properties": { "id": { "type": "integer", "format": "int64", "xml": { "attribute": true } } },
      "required": ["id"]
    },
    "id": { "type": "integer", "format": "int64", "readOnly": true, "xml": { "attribute": true } }
  },
  "required": ["title"],
  "xml": { "name": "book" }
}

The identifier is readOnly because the server assigns it, so one schema describes both directions. An association is described by the identifier Grails renders and binds it from, a to-many association by an array of them, and an embedded association in full. The xml object describes the XML the converters render: <book id="1"><title>…</title><author id="2"/></book>.

What is described

  • Every mapping that names its controller: resources mappings, "/books/$id"(controller: 'book', action: 'show'), namespaced controllers at their own routes, a mapping that takes the action from the path (for each action the controller declares, and, where the action is optional, the default action at the path without it), and one that chooses the action by HTTP method (for each method). URL variables become path parameters, the optional .format suffix is left out, and a mapping with an optional variable is described at both paths.

  • A mapping declared for an HTTP method is described for that method unless the controller's allowedMethods refuses it; one that accepts any method is described for the methods allowedMethods declares, or the method a RestfulController action of that name answers.

  • The REST controllers a mapping reaches without naming them, at the URLs that mapping serves: RestfulController subclasses, and controllers declaring responseFormats without html, so a login controller responding in html and json is not described. responseFormats is read as respond reads it, and where it is a map by action, each action is decided by its own entry. This covers the generated REST API form get "/$controller(.$format)?"(action: 'index') and the default "/$controller/$action?/$id?" mapping, which also answers GET /book with the default action, so scaffolded and generated controllers are described:

    GET  /book            POST /book            GET  /book/{id}
    PUT  /book/{id}       DELETE /book/{id}
    
  • A controller generated for a REST application, which is not a RestfulController but whose save and update bind the domain class, answers the actions RestfulController declares as a RestfulController of that class does, so they are described the same way: its statuses, the domain class as its responses and bodies, and the paging of its listing. Its other actions are described as any controller's are.

  • A RestfulController constructed read only answers 405 from its write actions, so only index and show are described.

  • create and edit answer HTML forms, so they are left out unless grails.openapi.include-form-actions: true.

Versions

A mapping declared for a version is matched on the Accept-Version header:

"/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 the default document describes that one, with an optional Accept-Version parameter. A group describes another version by selecting its header, where the parameter is required:

grails:
    openapi:
        groups:
            v1:
                headers-to-match: Accept-Version=1.0

Schemas

A type is resolved through swagger-core, so @Schema and Jackson annotations are honored. A property of a class the Grails operations use, a domain class, a command object or a class an @ApiResponse names, is described by the name Grails' converters, JSON views and data binding render and bind it by, its own: String ISBN is ISBN, not Jackson's isbn, and @JsonProperty, which Grails ignores, does not rename it. @Schema(name) renames the description on purpose, and the constraints, and whether it is required or read only, follow it. A nullable property described by a reference, such as an embedded association, is the reference or null. OpenAPI 3.0 ignores anything beside a $ref, so there a reference that is nullable, read only, or given a description by @Schema is allOf the reference, which says it. The constraints of a domain class or Validateable class are applied over that:

Constraint OpenAPI
nullable: false required, unless the property's @Schema declares it nullable or NOT_REQUIRED
nullable: true nullable in 3.0, a null type in 3.1, and null in any enum
blank: false minLength of 1
maxSize / minSize / size maxLength, minLength of a string; maxItems, minItems of a collection
min / max / range minimum, maximum
inList enum
matches pattern, anchored, since Grails matches the whole value
email / url format

Where the annotation and the constraints both say whether a property must be sent, the property's @Schema wins: nullable = true or requiredMode = NOT_REQUIRED leaves it out of required, and requiredMode = REQUIRED keeps it in, whatever it is constrained to. So a Validateable declaring no constraints, whose every property Grails constrains nullable: false, can still describe a property as optional. A type a property's @Schema declares is written in a 3.1 document as it is in 3.0.

@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
    }
}
"title": {
  "type": "string",
  "description": "Full title as printed",
  "example": "Dune",
  "maxLength": 255,
  "minLength": 1
}

A domain class is described as Grails renders it: its identifier and persistent properties, not a transient or a derived getter. The version is described only where grails.converters.domain.include.version makes Grails render it. What data binding does not bind is readOnly: the identifier and version, dateCreated and lastUpdated, a property constrained bindable: false, and a property a command object can only read.

The identifier path parameter, and an association's reference, take the type the domain class declares for its identifier:

Identifier OpenAPI
Long integer, int64
Integer integer, int32
UUID string, uuid
String or any other type string

A schema is named after its class. Classes sharing a name in different packages are each named with their package, such as com.example.v1.Book and com.example.v2.Book, whichever is described first. A class with the same name as a schema the base document declares, ValidationErrors, or a Patch schema is named with its package the same way. Only a class described as a schema of its own takes a name: an enum is described inline unless @Schema(enumAsRef = true) makes it one, and a class described in another's place, by @Schema(implementation) or @JsonValue, leaves the name to that other.

A class swagger-core cannot introspect is left out and logged, rather than failing the document, and an operation that referred to it is described without a schema. A class whose constraints cannot be evaluated is described without them, and logged.

Request bodies

An operation that accepts a body describes what the action binds: the command object it takes, in preference to the resource the controller serves.

class OrderController {
    def submit(OrderCommand cmd) { }
}

patch binds only what it is sent, so its body is BookPatch: the resource's properties with nothing required. A body with a MultipartFile or Part property is described as multipart/form-data, with the file as a binary string. A body is described in the data formats of the controller's responseFormats, or application/json.

Responses

The actions RestfulController declares are described by the part each plays in the resource, whether the controller inherits one or overrides it, since overriding is how an inherited action is annotated:

Action Status Body
index 200 a collection of the resource
show, edit 200, 404 the resource
create 200 the resource
save 201, 422 the resource, and a Location header where RestfulController's own save runs
update, patch 200, 404, 422 the resource, and a Location header where RestfulController's own update runs
delete 204, 404 none

The Location header is something only RestfulController's code sends, like the 405 of a read-only controller, so an action the controller overrides, and a generated controller, are described without it. Any other action of the controller, such as a search, is described as any controller's action is: 200, a 404 where its path has a variable, and what its annotations declare.

Bodies are described in the media types of the formats the controller declares in responseFormats, for the action or for every action, each as the first media type the application configures for the format, and in application/json where it declares none. Only json and xml carry the shape; a format that renders a view or a HAL document, such as html or hal, is listed without one. The XML is described as the converters render it, with the xml object: an element named for the class, the identifier and version as attributes, and a listing as a list element holding one for each.

A 422 answers with the errors Grails renders for a request that fails validation, described once as ValidationErrors, or as grails.validation.ValidationErrors where the document already describes something else under that name, such as a class a Spring MVC endpoint returns:

Rendered by JSON XML
the converters {"errors": [{"object", "field", "rejected-value", "message"}]} <errors><error object=".." field=".."><rejected-value/><message/></error></errors>
the JSON views errors view a generated REST application has one error with its message, path and link, or several embedded with their total the converters'
a controller's own _errors.gson described under 422 without a shape; the view answers with the status it sets the converters'
JSON views with no errors view not a 422: the view for any object renders the errors and answers with success the converters'

An application rendering its errors another way declares ValidationErrors in the base document, which then describes them in every media type.

Parameters

index accepts max (capped at 100, default 10), offset, sort and order, which RestfulController passes to GORM, and so does an index the controller overrides; one that does not page withdraws them with @Parameter(hidden = true). An action parameter of a simple type is bound from the request by name, so it is described as a query parameter of that type, under the name @RequestParameter gives it:

def lookup(@RequestParameter('q') String query, Integer limit) { }

A command object an action binds on a request without a body, such as a GET, is bound from the request parameters, so each property it binds is described as a query parameter under the name Grails binds it by, with its constraints:

def search(BookSearch search) { }

A path variable takes the type of what it is bound to (the resource's identifier, the identifier of the resource a nested mapping names, or the action parameter of the same name), and the pattern or list of values the mapping constrains it to.

Correcting what is derived

Annotation Effect
@Operation summary, description, operation id, tags, deprecation, external docs, parameters, responses, request body and security of an action
@ApiResponse a response of an action, or on the controller of each of its actions; one for a status already described replaces it, and a success status an action declares replaces the success statuses derived for it, so a save declaring 200 is not also described with 201
@Parameter a parameter of an action; one already described, such as the identifier, is refined and keeps its type unless the annotation names one
@RequestBody the body an action binds
@SecurityRequirement the security of an action, or on the controller of each of its actions
@Tag the groups a controller's operations appear under, and their descriptions
@Hidden withholds a controller or an action
@Tag(name = 'orders', description = 'Retrieving and creating orders')
@ApiResponse(responseCode = '401', description = 'Invalid API token')
class OrderController extends RestfulController<OrderCommand> {

    OrderController() {
        super(OrderCommand)
    }

    @Operation(summary = 'Fetch all orders')
    @Parameter(name = 'profile', in = ParameterIn.QUERY, description = 'The customer profile to act for',
            schema = @Schema(implementation = Long))
    @Override
    Object index(Integer max) {
        super.index(max)
    }

    @ApiResponse(responseCode = '200', description = 'The CSC letter',
            content = @Content(mediaType = 'application/pdf', schema = @Schema(type = 'string', format = 'binary')))
    def cscLetter() { }
}

Configuration

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
grails.openapi.display-name info.app.name The title of the default document
grails.openapi.annotated-only false Describe only actions that declare @Operation, or whose controller declares @Tag
grails.openapi.include-form-actions false Describe the create and edit actions of a RestfulController
grails.openapi.paths-to-match, paths-to-exclude Ant patterns of the paths the default document describes, or leaves out
grails.openapi.packages-to-scan, packages-to-exclude Packages of the controllers the default document describes, or leaves out
grails.openapi.produces-to-match, consumes-to-match Media types an operation must produce, or consume
grails.openapi.headers-to-match Header conditions an operation must declare, such as Accept-Version=1.0
grails.openapi.groups.<name>.* A group and what it selects
grails.openapi.output-directory build/openapi Where generate-open-api writes
grails.openapi.output-format yaml yaml or json

The settings ship with Spring Boot configuration metadata, so an IDE completes and documents them.

Where springdoc is used, its springdoc.paths-to-match, paths-to-exclude, packages-to-scan, packages-to-exclude, produces-to-match, consumes-to-match and headers-to-match apply to the Grails endpoints too, and springdoc.api-docs.version chooses OpenAPI 3.1, the default, or 3.0 for both the build-time and the runtime document.

The media type criteria match the way springdoc matches a handler method: an operation is selected only where it produces, or consumes, exactly the media types listed. An operation produces the media types of its controller's responseFormats and consumes them where it binds a body. A versioned mapping declares the Accept-Version header it is matched on; any other header criterion selects no Grails endpoint, as it selects no Spring handler that declares none.

Groups

A group is a document of its own for one audience, written to its own file by the command and served by springdoc at /v3/api-docs/<name>:

grails:
    openapi:
        groups:
            sales:
                display-name: Sales API
                paths-to-match: /api/v1/**

A group declared to springdoc, in springdoc.group-configs or as a GroupedOpenApi bean, is honored the same way at runtime and by the command. springdoc's top-level criteria, such as springdoc.paths-to-match, apply to every group ahead of the group's own, criterion by criterion, as springdoc applies them to its Spring MVC endpoints, and a group name declared more than once is described by its first declaration, with a warning:

springdoc:
    group-configs:
        - group: depots
          paths-to-match: /api/v2/**

springdoc filters and customizers

What springdoc applies to a Spring MVC handler method is applied to the Grails actions too, at runtime and by the generate-open-api command:

springdoc Applied to the Grails endpoints as
pathsToMatch, pathsToExclude the mapped path
packagesToScan, packagesToExclude the controller's package
producesToMatch the media types of the controller's responseFormats
consumesToMatch the same media types, where the action binds a body; multipart/form-data for a body with a file
headersToMatch the Accept-Version header of a versioned mapping
addOpenApiMethodFilter, OpenApiMethodFilter and GlobalOpenApiMethodFilter beans called with the method the action is declared as
addOperationCustomizer, OperationCustomizer and GlobalOperationCustomizer beans called for each operation with a HandlerMethod of the controller and the action
addOpenApiCustomizer, OpenApiCustomizer and GlobalOpenApiCustomizer beans run after the Grails operations are added, so they see them
@Bean
GroupedOpenApi bookReads() {
    GroupedOpenApi.builder()
            .group('book-reads')
            .pathsToMatch('/books/**')
            .addOpenApiMethodFilter { Method action -> !(action.name in ['save', 'update', 'patch', 'delete']) }
            .addOperationCustomizer { Operation operation, HandlerMethod handlerMethod ->
                operation.addExtension('x-action', "${handlerMethod.beanType.simpleName}.${handlerMethod.method.name}")
                operation
            }
            .build()
}

An operation customizer returning null leaves the operation out. A filter or customizer that throws is skipped and logged once, rather than failing the document, so at build time one that reads the current request is skipped.

Base document

What describes the API as a whole, and endpoints the application does not map itself, is written once in a base document. Its info, servers, security and external docs are used as they are, and its tags, extensions, paths and components are kept beside what is derived:

grails:
    openapi:
        base-document: classpath:openapi-base.yml
openapi: 3.1.0
info:
  title: Sales API
servers:
  - url: https://api.example.com
security:
  - Bearer: []
components:
  securitySchemes:
    Bearer:
      type: http
      scheme: bearer
      bearerFormat: JWT

Forge feature

The openapi feature adds grails-openapi and springdoc with Swagger UI, and sets springdoc.api-docs.enabled: false and springdoc.swagger-ui.enabled: false for production, so a generated application does not publish its API description there unless it chooses to.

Changes outside the module

  • grails-core, configuration binding: a list of objects in application.yml or application.groovy is now presented to Spring element by element (springdoc.group-configs[0].group), the way Spring Boot's own YAML loader presents it, so a @ConfigurationProperties list of objects is bound with instances of its declared type. It used to be bound as a list of maps and fail with a ClassCastException, which is how springdoc.group-configs stopped an application from starting. Environment.getProperty on such a list, or on a list holding lists of objects, now returns null and containsProperty returns false, as in Spring Boot; grailsApplication.config is unchanged, and a list of plain values is presented as before. Within such a list, an empty object, an empty list or a null is presented as an empty string under its own name, as Spring Boot presents empty: [{}] as empty[0]. The 8.0 upgrade notes cover it.
  • grails-web-databinding: the properties data binding binds on a type are now read from the class, the generated include lists and the bindable constraints, and kept for each class, by an internal org.grails.web.databinding.BindingIncludeLists that DataBindingUtils binds through, where they used to be read from an instance. The description reads what it leaves out from the same place, without creating an instance.
  • grails-web-common: @RequestParameter is retained at runtime, so the name a request sends can be read from a compiled action.

Ahead-of-time processing

The Grails converter is declared as a ModelConverter bean, which springdoc registers with swagger-core as the application starts, closest to swagger-core's own resolution so springdoc's converters and the application's see what it described; nothing is registered as a side effect of registering beans, which an application processed ahead of time would not repeat. The converter holds nothing of the application, and describes a type only where a Grails document is being described. springdoc's own Spring MVC endpoints are described as springdoc describes them alone, since Jackson renders what they return. While springdoc builds a document, the classes it resolves for them are recorded, so a class a Grails endpoint in that document uses too is described once, as Grails renders it, and a class that only shares its name is named apart, in that document alone. The module also declares, for a native image, the reflection the description needs: the controllers and the classes they extend, with the actions each declares, and every type swagger-core reaches from what they serve and bind, with the fields Groovy puts a property's annotations on, found by resolving them through swagger-core as the build runs.

Limitations

  • A mapping that accepts any HTTP method for an action allowedMethods does not restrict is described as GET, because OpenAPI requires a concrete operation. A mapping for an HTTP method OpenAPI has no operation for is skipped and logged, as is a mapping whose action a closure decides per request.
  • A controller that is neither a RestfulController nor one generated for a REST application declares no response type. Name one with @ApiResponse(content = @Content(schema = @Schema(implementation = ...))).
  • A resource is described as Grails renders a domain class or an object by default. A JSON view other than the errors view, or a custom renderer, is described with @ApiResponse.
  • A parameter an action reads from params rather than declaring is described only with @Parameter. Declared parameters need the parameter names, which the Grails Gradle plugin preserves by default.
  • A variable capturing several segments, such as $path**, is described as a path parameter, which OpenAPI allows only one segment of.
  • A date is described as a date-time. Grails binds it with the formats grails.databinding.dateFormats lists, which by default read a time with an offset or Z only with milliseconds, such as 2024-05-01T10:00:00.000+02:00; without them, as in 2024-05-01T10:00:00Z, the time is read in the zone of the server. 8.0 (fix): Render dates and times in JSON the same way as Spring Boot, and bind them back as rendered #16411 makes binding read ISO 8601 date-times as they are written.
  • springdoc runs OpenApiLocaleCustomizers before the Grails operations are added, so one does not see them.
  • A read-only RestfulController, and the resource of one that declares no type argument, is known from the controller the application serves requests with, which is not created to be described. So one in a scope other than singleton, which is every controller under grails.controllers.defaultScope: prototype, is described with its write actions, and one passing its resource only to the constructor, as extends RestfulController with super(Book), without a schema. extends RestfulController<Book> describes the resource in any scope.
  • The native-image hints are verified against Spring's hint predicates, not in an image: Grails 8 cannot build one (see Add an end-to-end module verifying message bundles resolve #16180). An application compiled to a native image can serve the document generate-open-api writes as a static file.

springdoc-openapi builds its OpenAPI document by scanning Spring MVC
handler methods. Grails dispatches through UrlMappingsHandlerMapping
rather than @RequestMapping handler methods, so a Grails application
that adds springdoc gets a served but empty document.

This adds an optional grails-openapi module that contributes the
application's URL mappings through the springdoc OpenApiCustomizer SPI:

- statically mapped controllers become OpenAPI paths, including every
  HTTP method a `resources` mapping generates
- URL variables become path parameters, so "/books/$id" is documented
  as /books/{id}
- the optional Grails .format extension is stripped from the path and
  excluded from parameters, since OpenAPI expresses response formats
  through content types
- mappings whose controller is resolved per request are skipped, and a
  mapping accepting any HTTP method is documented as GET

Applications add the module and, when they also want Swagger UI, the
springdoc webmvc-ui starter. The guide documents the two Grails-specific
settings that integration needs: re-enabling Spring Boot's resource
handler and excluding the springdoc paths from a catch-all mapping.
The customizer previously emitted paths with no schemas, so Swagger UI
showed an empty Schemas section and no request or response shapes.

Domain classes are now described from the GORM mapping model:

- every mapped entity gets a response schema plus a Request schema that
  omits the identifier and version, which a client does not supply
- associations are referenced rather than inlined, so a bidirectional
  relationship resolves both ways
- declared constraints are carried across: nullable to required, maxSize
  and size to maxLength/minLength, min/max/range to minimum/maximum,
  inList to enum, matches to pattern, and email/url to format
- index responds with an array of the resource, the remaining actions
  with a single one, and operations addressed by an identifier document
  a 404

The mapping context is injected optionally, so an application without
GORM still gets a document describing its paths.

The String-only constraint accessors throw rather than return null when
read from a property of another type, so they are consulted only for a
string schema.
@codecov

codecov Bot commented Aug 30, 2026 •

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 73.84208% with 593 lines in your changes missing coverage. Please review.
✅ Project coverage is 58.2072%. Comparing base (c482791) to head (9ab310d).
⚠️ Report is 165 commits behind head on 8.0.x.

Files with missing lines Patch % Lines
...ovy/org/grails/openapi/GrailsModelConverter.groovy 69.7802% 17 Missing and 93 partials ⚠️
...groovy/org/grails/openapi/ActionAnnotations.groovy 64.5000% 13 Missing and 58 partials ⚠️
...roovy/grails/openapi/GrailsOpenApiGenerator.groovy 80.1418% 4 Missing and 52 partials ⚠️
...rg/grails/web/databinding/BindingIncludeLists.java 72.2892% 32 Missing and 14 partials ⚠️
.../groovy/org/grails/openapi/SchemaReferences.groovy 48.1013% 8 Missing and 33 partials ⚠️
...penApiBeanFactoryInitializationAotProcessor.groovy 66.2791% 5 Missing and 24 partials ⚠️
...n/groovy/org/grails/openapi/UrlMappingPaths.groovy 64.3836% 4 Missing and 22 partials ⚠️
...roovy/org/grails/openapi/DocumentCompletion.groovy 66.6667% 6 Missing and 13 partials ⚠️
.../main/groovy/org/grails/openapi/SchemaNames.groovy 71.6418% 3 Missing and 16 partials ⚠️
...main/groovy/org/grails/openapi/BaseDocument.groovy 60.0000% 3 Missing and 13 partials ⚠️
... and 22 more
Additional details and impacted files

Impacted file tree graph

@@                Coverage Diff                 @@
##                8.0.x     #16275        +/-   ##
==================================================
+ Coverage     57.8656%   58.2072%   +0.3416%     
- Complexity      22870      23963      +1093     
==================================================
  Files            2137       2170        +33     
  Lines          104677     106815      +2138     
  Branches        18811      19451       +640     
==================================================
+ Hits            60572      62174      +1602     
- Misses          35742      35826        +84     
- Partials         8363       8815       +452     
Files with missing lines Coverage Δ
...ain/groovy/org/grails/openapi/DocumentParts.groovy 100.0000% <100.0000%> (ø)
...roovy/org/grails/openapi/OperationResponses.groovy 100.0000% <100.0000%> (ø)
...s/openapi/springdoc/GrailsOpenApiCustomizer.groovy 100.0000% <100.0000%> (ø)
...ache/grails/openapi/aot/OpenApiRuntimeHints.groovy 75.0000% <75.0000%> (ø)
...ils/openapi/springdoc/ResolvedNamesRecorder.groovy 80.0000% <80.0000%> (ø)
.../main/groovy/org/grails/openapi/ErrorsViews.groovy 71.4286% <71.4286%> (ø)
.../grails/plugins/openapi/OpenApiGrailsPlugin.groovy 84.6154% <84.6154%> (ø)
...roovy/grails/web/databinding/DataBindingUtils.java 55.9471% <77.7778%> (-7.6383%) ⬇️
...rg/grails/config/NavigableMapPropertySource.groovy 86.9565% <91.4286%> (+10.0334%) ⬆️
.../org/grails/openapi/ValidationErrorsContent.groovy 94.5454% <94.5454%> (ø)
... and 25 more

... and 15 files with indirect coverage changes

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

Adds an "openapi" application feature so the module can be selected when
generating a Web or REST API application. It contributes the grails-openapi
module and the springdoc Swagger UI starter, both of which the BOM manages.

The guide previously required two settings that testing shows are not
needed, and the feature therefore contributes neither:

- spring.web.resources.add-mappings does not need re-enabling, because
  springdoc registers its own resource handlers rather than relying on
  the Spring Boot catch-all handler Grails disables
- the springdoc paths do not need a UrlMappings exclude, because they
  resolve to no controller and fall through to Spring MVC

Both were verified by removing them from a running application and
confirming /v3/api-docs and /swagger-ui/index.html still respond. The
exclude is still documented for an application whose mappings include a
catch-all resolving to a view or URI, which would otherwise match.
Schemas were registered for every mapped domain class, so a domain class
whose controller has no documented URL mapping appeared in the document
with no operation referring to it.

Schemas are now collected while the paths are built and registered
afterwards, covering only the resources a documented operation serves.
Associations are followed transitively, so a class reached only as
another schema's property is still defined and no reference dangles. A
request schema is registered only where an operation actually accepts a
body.
A RestfulController is served by the default "/$controller/$action?/$id?"
mapping whether or not a mapping names it, so a controller with no
mapping of its own was reachable but absent from the document. That
includes every scaffolded controller, since scaffolding generates
RestfulController subclasses.

Those routes are now described, with the id segment on the actions that
address a single resource and the method each action declares through
allowedMethods, so save is documented as POST and delete as DELETE
rather than everything as GET.

A controller a mapping also names is described twice, once at /books/{id}
and once at /book/show/{id}. Both answer, and an endpoint that responds
while missing from the document is worse than one described twice: the
document is used to review the surface an application exposes, not only
to browse it. An application wanting only the first form removes the
default mapping.

Only RestfulController subclasses are described this way, which keeps
controllers rendering GSP views out of the document and makes the action
set, id placement, and methods known rather than guessed.
…chema

A domain class produced two schemas: one for responses and a Request
variant that omitted the identifier and version. That doubled the schema
count of every application using the module and introduced a naming
convention of our own, which generated clients would inherit as type
names and which an application with a domain class named for the suffix
would collide with.

OpenAPI already expresses this per property. The identifier and version
are now marked readOnly on the single schema, and request bodies
reference it directly, so a client is still told not to send them.
@zyro23

zyro23 commented Aug 31, 2026

Copy link
Copy Markdown
Contributor

looks promising! will it be possible to customize paths/schemas by annotating actions/domain classes/command objects/response objects with swagger annotations?

Schemas were built by hand from the GORM mapping model, which meant a
@Schema annotation on a domain class or one of its properties was
silently ignored: the module replaced swagger-core's model building
rather than feeding it, so nothing an application declared could reach
the document.

The type is now resolved through swagger-core, which reads the
annotations and describes the classes an entity is associated with, and
the GORM constraints are applied over that result because swagger-core
cannot see them. The two combine on the same property, so an annotation
supplies the description and example while the constraints block still
supplies maxLength and the required members.

This also removes the hand written type mapping and the association walk,
both of which duplicated what swagger-core already does. Two differences
are handled explicitly: the foreign key accessor GORM adds beside a
to-one association is dropped as redundant, and the version property,
which swagger-core does not surface, is described so it can be marked
readOnly.
@codeconsole
codeconsole force-pushed the feat/openapi-springdoc-8.0.x branch from 43b1c6f to dbe1cc7 Compare August 31, 2026 19:25
Everything in the document was derived, so an application had no way to
correct or enrich it, and no way to withhold an endpoint at all. An
annotation an application added was silently ignored, because springdoc
reads annotations from handler methods and Grails has none for it to
find.

The annotations declared on an action are now read directly:

- @operation supplies the summary, description, operation id, tags, and
  deprecation, leaving anything it does not set as derived, so an action
  can be given a summary without restating the rest
- @apiresponse and @ApiResponses add a response alongside the derived
  ones rather than replacing them
- @hidden withholds an action, and on the controller withholds all of
  it, which is how a reachable endpoint that is not part of the
  published API is kept out; @operation(hidden = true) does the same

Both routes into the document honor them, whether the operation came
from a declared URL mapping or from the default mapping. An action Grails
compiles into more than one method, as it does where the action takes a
command object, is read from every method of that name.
An operation that accepted a body was described as taking the domain
class the controller is named for, whether or not the action bound it.
For an action taking a command object the document was wrong rather than
incomplete, and wrong in a way that fails quietly: a client sending the
documented body gets a 200 with nothing bound.

The type an action binds is now read from its parameters, following the
rule the controller transform applies, and that type describes the body.
A Validateable command carries its declared constraints across the same
way a domain class does. Where an action binds no command object, as on
a RestfulController, the body remains the domain class.

A command schema is reduced to the properties the command declares.
Validateable contributes errors and every Groovy object contributes
metaClass, and left in each drags its whole object graph into the
document - sixty schemas of compiler and metaclass internals for one
command with two fields.
Every operation was described as answering 200 with the resource, which
is wrong for the controller the module most often documents. Save answers
CREATED and delete answers NO_CONTENT with no body at all, so the two
endpoints a client is most likely to generate from were both misdescribed.

The statuses are now read from the controller: 201 from save, 204 with no
body from delete, 404 where an action is addressed by an identifier, and
422 where an action validates what it binds, which save, update and patch
all do because patch delegates to update. A mapping that names the
controller and the default mapping describe the same responses, rather
than the two disagreeing.

Any other controller is still described as responding with the resource
it is named for. That is a convention rather than something that can be
determined, because an action's return type is not declared, so an action
responding with anything else can name it:

    @apiresponse(responseCode = '200',
            content = @content(schema = @Schema(implementation = BookSummary)))

The declared type replaces the convention and is described alongside the
other schemas.
The two dependencies read as one recipe, so the viewer looked mandatory.
The module produces the document, which is equally useful to a code
generator or a contract test, and Swagger UI is not the only viewer that
reads it - the forge itself serves RapiDoc alongside it - so the module
does not pre-decide one.
@codeconsole

Copy link
Copy Markdown
Contributor Author

looks promising! will it be possible to customize paths/schemas by annotating actions/domain classes/command objects/response objects with swagger annotations?

Thanks @zyro23 - please take a look at the updates in the description. LMK if there is anything else you think might be beneficial.

Expansion synthesized a "/$controller/$action/$id" shape from the
controller artefacts without checking that any mapping served it. A
generated REST API application is mapped the other way round - the action
is named and the controller left to the request - so it got a document
whose every operation was wrong in both directions: the paths it serves
were absent, and the paths described returned 404. The guide's advice to
remove the default mapping had no effect either, because the mapping was
never consulted.

Expansion now follows the mappings. A mapping that names the action is
described at the URL that mapping serves, one that names neither is
expanded across the controller's actions as before, and an application
whose mappings name every controller gets neither. The default mapping
form carries a distinct operation identifier, so a controller reached
both ways no longer produces two operations with the same identifier for
springdoc to disambiguate by order.

Also from the same review:

- a zero bound was dropped, because Groovy reads it as falsy: min: 0 and
  maxSize: 0 are real constraints
- a nested command object was neither pruned nor constrained, so one
  nested command reintroduced the sixty schemas pruning exists to remove
- a schema named through @Schema(name) was registered under that name but
  referred to and overlaid under the class name, losing its constraints
- the array form of @apiresponse content was ignored, which is the form
  used to declare a collection response
- a greedy parameter was always called path while the declared parameter
  kept the constraint's name, so the two did not agree
- a mapping declared for a status code became a path
- schemas were resolved with the 3.0 converter into a 3.1 document, and
  the string constraints were applied by Java class rather than declared
  type, so they were silently skipped under 3.1
- the mapping context is resolved through a provider, so an application
  with more than one datastore starts
- an unsupported HTTP method skips its mapping and logs, rather than
  failing the document
- grails-datamapping-validation and grails-validation are declared, being
  used from src/main rather than only from tests

Three tests asserted nothing and were replaced: the greedy path test
passed on an empty collection, the idempotency test compared a schema
with itself because the second pass short-circuits, and the expansion
tests passed mapping closures that did not contain the mapping whose
behavior they described.
A class that could not be introspected threw out of the customizer, so a
single command object whose constraints cannot be read returned a 500
from /v3/api-docs and the application lost its entire API description.

Each mapping, expanded action, domain class and command object is now
described on its own. One that cannot be is skipped and logged at warn,
with the cause at debug, and the rest of the document is served.

Verified while checking a reported risk that did not exist: a Map or a
nested collection of command objects resolves its element type through
the generic argument already, so no reference is left undefined.
The annotation tests all went through the default mapping, so the path
that describes a mapping naming its controller was never exercised with
an annotation on it, even though both routes read them.
A listing answers to max, offset, sort and order, because RestfulController
passes the request parameters to GORM. None of them were described, so a
client generated from the document had no way to page an endpoint where
paging is the first thing it needs. They are described on the listing now,
with the ceiling of 100 the controller enforces and the two directions it
accepts, and not on the actions that address one resource.

@tag on a controller names and describes the group its operations appear
under, and @parameter on an action describes a parameter the module
derived. Both are read the same way the other annotations are.

Adds grails-test-examples/openapi, which boots an application and asserts
the document it serves. Everything until now was verified by hand against
a running application while every test built the customizer directly, so
the plugin registering the bean, the bean being wired and springdoc
serving what it contributes were covered by nothing. The functional test
asserts the paths, the statuses, the paging, the schema built from the
constraints, that every reference resolves, that no two operations share
an identifier, and that a described endpoint answers as described.
The largest is a regression from branching the string constraints on the
schema type: swagger describes a date, a UUID and a byte array as strings
too, and reading a string constraint from one of those throws. A domain
class with a Date property - dateCreated and lastUpdated are in most of
them - lost its schema entirely while every operation kept referring to
it. The branch is now on the property type, which is the test the
constraint itself applies.

Also fixed:

- expansion built the path from a convention whenever the mapping named
  neither controller nor action, so a mapping declared under a group
  prefix was described without the prefix, at paths the application does
  not serve. Both forms follow the mapping's own pattern now.
- an expanded operation declared a parameter named id whatever the path
  actually contained, so a mapping carrying another variable declared one
  parameter that was absent from the template and omitted one that was
  present.
- an optional token left its marker in the path key, which is not a path
  template.
- the identifier was qualified only for the action-expanding form, so a
  resources mapping and a mapping naming the action produced two
  operations with one identifier. The qualifier is now derived from the
  path, and applied wherever it is needed.
- a request body still referred to a command by its class name while it
  was registered under the name its @Schema declares.
- @tag named a group nothing was in: the operations kept the controller
  name. A hidden controller published its tag as well.
- a nested generic argument was not followed, so List<List<Command>> left
  the element type undefined.
- a reference whose schema could not be built is dropped rather than left
  dangling, which for a code generator is worse than an operation with no
  shape.

Two tests asserted the behavior rather than the intent and were corrected
with the code: one expected the controller name where a tag was declared,
and one expected the identifier before it was qualified.
@zyro23

zyro23 commented Sep 1, 2026

Copy link
Copy Markdown
Contributor

looks promising! will it be possible to customize paths/schemas by annotating actions/domain classes/command objects/response objects with swagger annotations?

Thanks @zyro23 - please take a look at the updates in the description. LMK if there is anything else you think might be beneficial.

in fact, i think there is. but first: hats off for the changes. awesome.

springdoc allows defining GroupedOpenApi beans to define one or multiple "grouped" openapi definitions (e.g. for separate apis):

https://springdoc.org/#how-can-i-define-multiple-openapi-definitions-in-one-spring-boot-project

GroupedOpenApi (or rather GroupedOpenApi.Builder) supports filtering (at least for spring(-mvc) apps) by:

  • pathsToMatch
  • packagesToScan
  • packagesToExclude
  • pathsToExclude
  • producesToMatch
  • headersToMatch
  • consumesToMatch
  • methodFilters

to what extend is/could that be supported by the grails impl.?

thanks & regards.

@codeconsole
codeconsole requested review from jamesfredley, jdaugherty and matrei and removed request for jamesfredley, jdaugherty and matrei September 2, 2026 03:46

@matrei matrei left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Third round, reviewed at head d844899201 against 8.0.x (merge base 0dab8a7263). Everything from my second round is addressed as described in the reply, and I checked each fix against the code. Keeping NavigableMapPropertySource in this PR is fine with me.

Run locally at the head:

Module Tests Result
grails-openapi (check: test, testCli, CodeNarc) 263 + 9 pass, no violations
grails-test-examples-openapi (integrationTest) 39 pass
grails-test-examples-openapi-rest-api (integrationTest) 9 pass
grails-databinding-core 84 pass (1 skipped by the spec)
grails-databinding 41 pass
grails-web-databinding 62 pass
grails-test-suite-web (org.grails.web.binding.*) 105 pass
grails-core (org.grails.config.*) 33 pass (1 skipped by the spec)
validateDependencyVersions for the example app pass

Follow-up on the second round

  • Bare /<controller> path (1). reachedAt is now shared by the expanded actions and the default action, so the namespace handling is the same for both, and the default action is only described where isRestAction holds for it.
  • responseFormats as a map (2). ControllerCatalog.responseFormats(controller, action) reads the declaration exactly as RestResponder.calculateFormats does: a List, or a List per action. I agree that a String[] should count as undeclared, since respond treats it that way.
  • 3.0 references (3), plain types (4), prototype wording (5). These are fixed as described. The guide now covers both prototype cases.
  • springdoc's own endpoints (6, 8, 9). Passing the type through outside a Grails description and recording names per build removes the JVM-wide state. The shared-class trade-off is documented in the guide. See 3 below for a small leak.
  • ValidationErrors (7), AOT (10), group file names (11), actuator and test nits (12). These are fixed as described. isApplicationType excludes only java.* and javax.*, so RestfulController is kept for reflection too.
  • NavigableMapPropertySource nit. An empty map, an empty list and null now flatten to "" only inside an object list, which matches Spring's YAML loader.

New findings

1. Please move the date binding change out of this PR.

b8e558f, 6f38447 and d844899 change how every 8.0 application binds a Date and the java.time types. None of that is specific to OpenAPI. It fixes a regression from d3c8279 ("JDK ISO formatters with chained constructors"). That commit switched the JSON DateMarshaller from FastDateFormat (yyyy-MM-dd'T'HH:mm:ss.SSS'Z', milliseconds always written) to ISO_INSTANT. ISO_INSTANT leaves out milliseconds when they are zero, so the yyyy-MM-dd'T'HH:mm:ss'Z' format now reads the rendered value in the server's zone.

The fix is welcome, but it also includes a breaking change: parseAll turns a partially read value into a binding error. Both parts deserve their own issue, changelog entry and review record. They will be hard to find under an OpenAPI feature, and so far the only reason they are here is a limitation that used to be listed in openApi.adoc. Unlike NavigableMapPropertySource, nothing in the module depends on them. Could this go into a separate PR against 8.0.x?

If it stays, please address these:

  • Reading pre-1582 dates as Julian breaks the round trip through the converters. The JSON DateMarshaller and CalendarMarshaller now render through date.toInstant(), which uses the proleptic Gregorian calendar. JSON views use SimpleDateFormat, and Jackson uses its own StdDateFormat; both use the Julian calendar before 1582. I checked this with a Date holding Julian 1500-01-01T00:00:00Z:

    • the converters render 1500-01-10T00:00:00Z and the views render 1500-01-01T00:00:00.000Z.
    • binding the converters' output back gives a date 9 days later than the original.

    DateTimeRoundTripBindingSpec renders with as JSON, which is the converters, but it only uses a 2025 date, so it does not catch this. The Javadoc on offsetDateTime says "as Grails and Jackson render a date", which is only true for the views. The real inconsistency is between the two renderers, and it came in with d3c8279. That again points to a separate PR, where the marshallers and binding can be made to agree.

  • GraphQL still binds the old way. grails-data-graphql's DateCoercion and the java.time coercions read the same grails.databinding.dateFormats, but they get neither the ISO-first read nor the whole-value rule. The upgrade note says "Data binding", so it should either say this applies to request binding only, or GraphQL should follow.

  • The RFC 3339 claim is broader than what is implemented. The upgrade note says values are read "as ISO 8601 and RFC 3339 write it". RFC 3339 allows a space in place of the T, but ZonedDateTime.parse('2024-05-01 10:00:00Z') fails. None of the default formats reads that value in full either, so it used to bind as midnight and is now an error. Saying ISO 8601 only, or adding that case to the note, would be accurate.

2. OpenApiUrlMappings (88cfff4) looks good.

The excludes follow springdoc.api-docs.path, springdoc.swagger-ui.path and spring.mvc.webjars-path-pattern, and they are left out where springdoc or Swagger UI is disabled. That way an application that maps /v3/api-docs itself, for example to serve the generated file, keeps its mapping. The catch-all example in the functional spec covers it. A nit: getExcludes reads Holders.findApplication(), so with no application registered (a unit test that builds the mappings by hand) it quietly excludes nothing. That is fine, but a line in the Javadoc would help.

3. The per-build record can stay on a request thread.

recordResolvedNames() sets RESOLVED_ELSEWHERE at the start of springdoc's build, and only contribute() clears it through takeResolvedNames(). If the build fails in between, for example because a Spring MVC handler cannot be resolved or another customizer throws before the Grails one runs, the map stays on a pooled Tomcat thread. It keeps its Class references until the next build on that thread, and any swagger-core resolution on that thread records into it in the meantime. The next build resets it, so the practical effect is small. Still, clearing it in an OpenApiCustomizer ordered last, or making takeResolvedNames the only reader and clearing it in a finally around the Grails contribution, would make it airtight.

Verified as correct

  • The ISO-first read in Jsr310ConvertersConfiguration changes nothing for values the configured formats already read. DateTimeFormatter.parse already rejected unread trailing text, so for the java.time types only the ISO read is new.
  • @BindingFormat properties go through FormattedDateValueConverter and are not affected by the date change.
  • In offsetDateTime, ISO year 0 and negative years map correctly to the BC era (-0043 is 44 BC), and a region-qualified value such as ...+02:00[Europe/Paris] is read at its offset.
  • recordResolvedNames runs from an OpenApiBuilderCustomizer, so it starts before springdoc resolves its handler types and on the same thread as the group's OpenApiCustomizers, including for each group and the actuator resource.
  • No wildcard imports, javax, @author tags or section-separator comments, and every new file has the license header.

@codeconsole

Copy link
Copy Markdown
Contributor Author

@matrei thanks. All three are addressed at 714a897.

  1. Date binding moved to 8.0 (fix): Render dates and times in JSON the same way as Spring Boot, and bind them back as rendered #16411.
  2. 5e2296f: the getExcludes Javadoc says nothing is excluded where no application is found.
  3. 49931ac: the recorder registers a request destruction callback that drops the record as the request completes. A build that fails before the Grails contribution now leaves nothing on a pooled thread. The test fails a build part-way, then checks that a later contribution on the same thread doesn't take the stale record.

Both PRs add an upgrade section 75, so whichever merges second renumbers. Once both are in, the OpenAPI date limitation can go.

…gdoc-8.0.x

# Conflicts:
#	grails-doc/src/en/guide/upgrading/upgrading80x.adoc
@codeconsole
codeconsole requested a review from matrei September 26, 2026 20:24

@matrei matrei left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fourth round, reviewed at head ed94a43249 against 8.0.x. All three items from my third round are addressed, and moving the date binding change to #16411 is exactly what I was hoping for. Thanks.

Run locally at the head:

Module Tests Result
grails-openapi (check: test, testCli, CodeNarc) 264 + 9 pass, no violations
grails-test-examples-openapi (integrationTest) 39 pass
grails-test-examples-openapi-rest-api (integrationTest) 9 pass
grails-databinding-core 74 pass (1 skipped by the spec)
grails-databinding 33 pass

Follow-up on the third round

  1. Date binding. 714a897 reverts it completely. The databinding modules, grails-test-suite-web and dataBinding.adoc are identical to the merge base, and the upgrade section is gone. The test counts above are back to what 8.0.x has.
  2. getExcludes Javadoc. Fixed in 5e2296f.
  3. The record left on a thread. Fixed in 49931ac. I checked that the destruction callback runs for springdoc's requests. FrameworkServlet.processRequest builds its own ServletRequestAttributes over a GrailsWebRequest and completes them in its finally, so the callback runs on the same thread even when the build throws. The callback replaces itself by name, so several builds in one request register it only once.

Remaining

1. The branch conflicts with 8.0.x again. #16410 (sitemesh 3.3.0-RC2) was merged ten minutes after ed94a43 and changes the lines right next to the springdoc and swagger versions in dependencies.gradle. Keeping both sides resolves it.

2. Nit: the restored date limitation in openApi.adoc is slightly off. Checked against the default grails.databinding.dateFormats with the server in America/Denver:

Sent Bound as Read by
2024-05-01T10:00:00Z 16:00Z (server zone) yyyy-MM-dd'T'HH:mm:ss'Z'
2024-05-01T10:00:00+02:00 16:00Z (server zone) yyyy-MM-dd'T'HH:mm:ss
2024-05-01T10:00:00+0200 08:00Z (correct) yyyy-MM-dd'T'HH:mm:ssZ
2024-05-01T10:00:00.000+02:00 08:00Z (correct) yyyy-MM-dd'T'HH:mm:ss.SSSX
2024-05-01T10:00:00.5Z 10:00:00.005Z yyyy-MM-dd'T'HH:mm:ss.SSSX

So the rule is closer to "with three-digit milliseconds, or with an offset written without a colon". The limitation goes away once #16411 is in, so the wording hardly matters, but if you touch the guide again, "three digits of milliseconds" is the part a client is most likely to get wrong.

CI: the only failure so far is "Neo4j Functional Tests (Java 25, indy=false)", which failed after 36s. It's the same kind of early job failure as the Spring Security job in the previous run, and nothing from this PR runs in that job. The Groovy snapshot canary failure TestLens reported is a Postgres container that could not bind its port.

From my side this is ready once the conflict is resolved.

@codeconsole

Copy link
Copy Markdown
Contributor Author

@matrei thanks for the approval.

  1. Conflict. Resolved at ff450d3. It keeps the springdoc and swagger versions and takes the sitemesh 3.3.0-RC2 from Upgrade SiteMesh to 3.3.0-RC2 #16410.
  2. Date limitation. Reworded in 6195462 to follow your table. The offset or Z is read only with three digits of milliseconds or with an offset written without a colon. Z and +02:00 without milliseconds are read in the server's zone, and .5Z is read as 5 milliseconds.

@jdaugherty jdaugherty left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Round 3, at 6195462. Every item from the previous round is addressed in the commits named in the replies; I re-read each fix against the current head rather than the replies. From a clean worktree at this head: 264 unit and 9 cli tests of grails-openapi, the 39 and 9 functional tests of the two example apps, and the grails-core and grails-web-databinding suites pass, and codeStyle is clean for the touched modules.

Three new items below, found while checking the document a production API's annotated command objects produce. The first two change what a 3.1 document says about a property that carries a @Schema; the third is smaller.

}
}
if (!constrained.nullable && name != versionName && !model.required?.contains(described)) {
model.addRequiredItem(described)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A defaulted constraint overrides an explicit annotation here. A Validateable with no constraints block gets nullable: false on every property from defaultNullable(), so every property lands in required, including one the application annotated @Schema(nullable = true) or @Schema(requiredMode = NOT_REQUIRED). With

@Schema(name = 'InventorySummary')
class InventorySummaryCommand implements Validateable {
    @Schema(maxLength = 2, nullable = true) String stateAbbreviation
    @Schema(type = 'number', format = 'double', nullable = true) BigDecimal price
    @Schema(requiredMode = Schema.RequiredMode.NOT_REQUIRED) String note
    Boolean allowBooking
}

served by extends RestfulController<InventorySummaryCommand>, the 3.1 schema is

stateAbbreviation: { type: [string, "null"], maxLength: 2 }
price: { type: [string, "null"], format: double }
note: { type: string }
required: [allowBooking, note, price, stateAbbreviation]

so stateAbbreviation and price are both required and nullable, and note is required despite saying it is not. A DTO like this is rendered, never bound or validated, so the defaulted constraint says nothing the application meant.

The guide says the annotation and the constraints "combine", which holds for maxLength and the like, but required and nullable are the same statement made twice, and where the property's own @Schema makes it, the annotation should win: no required for a property whose @Schema says nullable = true or NOT_REQUIRED, and required where it says REQUIRED. PropertyNames.declaredSchema(name) already has the annotation for each property, so this needs no further introspection. A test with a Validateable that declares no constraints and annotates one property nullable = true and another NOT_REQUIRED would pin it.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 0140963. 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.

Schema model = modelOf(resolved, context)
if (model?.properties != null && !TYPES_ONLY.get()) {
try {
describe(type, model)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In a 3.1 document a property annotated @Schema(type = 'number', format = 'double') is written as a string. swagger-core 2.2.52 alone does this: resolving

class Money {
    BigDecimal bare
    @Schema(type = 'number', format = 'double') BigDecimal typed
    @Schema(type = 'integer', format = 'int64') Long typedLong
    @Schema(types = ['number'], format = 'double') BigDecimal types31
}

through new ModelConverters(true) with no Grails converter gives

bare:      { type: number }
typed:     { type: string, format: double }
typedLong: { type: string, format: int64 }
types31:   { type: number, format: double }

so the 3.0 attribute type is lost on a property in 3.1 mode, and only types is read. A 3.0 document writes typed as number, so the same class describes two different APIs depending on springdoc.api-docs.version, and 3.1 is the default.

c44d8ff already copies a declared type into types for the schemas the operation annotations produce, in declareTypes. The property schemas the converter resolves need the same: where the document is 3.1 and the property's @Schema declares type but no types, set types from it, before the constraints are applied (the nullable handling in allowNull builds on types). That belongs before the early return for a class that is neither an entity nor a Validateable too, since the example above is one. Worth a test on the written 3.1 document, as the c44d8ff test does for the operation schemas; the guide's statement that a @Schema on a property "is honored" then holds in 3.1.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in a327508. 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.

operation.setOperationId(operationId)

parameters.addPathParameters(operation, mapping, pathNames, controllerType, actionName, resourceType)
if (restful && actionName && RestfulControllerActions.paginates(actionName)) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Smaller: the paging parameters, and the RestfulController statuses below, are derived for an index the controller overrides. The guide takes the position for a read-only controller that "a write action the controller overrides is described, because it does whatever the override does", and the same holds here: an index() override that queries a service and renders a list accepts no max, offset, sort or order, but the document offers all four, and there is no annotation that removes a derived parameter short of four @Parameter(hidden = true) declarations. Likewise a save() override annotated @ApiResponse(responseCode = '200', ...) keeps the derived 201 with its Location header beside it, since an annotation adds or replaces a status and cannot withdraw one.

RestfulControllerActions.isInherited already tells whether the action is RestfulController's own. Deriving the paging, and arguably the statuses, only where it is, and describing an override with the plain 200 (and 404 where it takes an id) unless its annotations say more, would follow the rule the guide already states.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks. This one changed the design more than the fix you suggested, so here's the reasoning. It's in three commits: ab803da, 2aba219 and a38216a.

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

@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 (ab803da).

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 (2aba219), 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 (a38216a). 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.

A Validateable declaring no constraints has every property constrained
nullable: false by default, so each landed in required, including one
its @Schema declared nullable = true or requiredMode = NOT_REQUIRED.
Where the annotation says the property need not be sent, a constraint
no longer requires it; one it declares REQUIRED stays required.
swagger-core reads only types, not type, from a property's @Schema in
3.1 mode, so @Schema(type = 'number', format = 'double') on a BigDecimal
was written as a string, while a 3.0 document wrote it as a number. The
converter now sets types from a declared type, keeping the null the
annotation allows, before the constraints are applied, and for a class
that is neither an entity nor a Validateable too.
The actions RestfulController declares are described by their role in
the resource - listing, showing, creating, updating, deleting - whether
the controller inherits one, overrides it, or, as a generated REST
controller does, declares it itself: overriding is the only way to
annotate an inherited action, and the override still plays that part.

Any other action of a resource controller was described with the
resource as its response and request body, a guess, and was reached by
the default mapping without the id it declares. It is now described as
any controller's action is. The guide shows how an overridden listing
that does not page withdraws the paging parameters.
RestfulController's update answers with a Location header as its save
does, and its patch through update, but only the save's was described.
The header is something RestfulController's code sends rather than part
of what the action answers by its role - a controller generated for a
REST application sends none - so, like the read-only 405, it is now
described where RestfulController's own save, update or patch runs, and
not for an action the controller overrides.
An annotation added or replaced a status but could not withdraw one, so
a save override declaring @apiresponse(responseCode = '200') was still
described with the derived 201 and its Location header beside it, and
a plain action declaring 201 with the derived 200. An action answers
success with the statuses it declares, so those declared on the action,
or in its @operation, now replace the other derived success statuses,
as springdoc leaves out the response it derives once a method declares
its own. An error status declared is still added, so declaring a 409
keeps the derived success, and a status the controller declares is
added to each action as before.
@jdaugherty

Copy link
Copy Markdown
Contributor

Re-reviewed at 4065ea5372d6b46916e165db83246e5eb2f4c100, including the five OpenAPI fixes since my last review at 6195462424.

The exact scalar cases from that review are fixed. I also agree with the revised resource-action design: keeping the conventional defaults for an override that only adds annotations is reasonable, the way to withdraw paging is documented and tested, and Location now follows the inherited implementation. I would not reopen that design question.

I found three narrower annotation combinations that still produce incorrect documents, and reproduced them through the generator and its JSON serializer. Could we work through these before merging? The complete reproducer is included below so we can use the same cases.

1. [P2] An annotation-nullable reference still cannot describe the values it permits.

GrailsModelConverter.groovy:699-709 wraps a nullable reference only when the constraint permits null. With a Validateable declaring no constraints:

class ReviewContainer implements Validateable {
    @Schema(nullable = true)
    ReviewAddress address
}

address now correctly stays out of required, but its written 3.1 schema is:

type: "null"
$ref: "#/components/schemas/ReviewAddress"

Those requirements intersect rather than form a union: neither an address object nor null satisfies both. The written 3.0 property is just the bare $ref, with no nullability. This is a remaining gap in annotation handling, not a claim that the new required-property fix introduced it. Could annotation-declared nullability also take the reference-handling path, preserving the referenced object as well as null?

2. [P2] Correcting a 3.1 property's type leaves numeric annotation values as strings.

GrailsModelConverter.groovy:584-598 corrects types after swagger-core has processed the annotation. This property:

@Schema(type = 'integer', format = 'int32',
        allowableValues = ['1', '2'], defaultValue = '1')
Integer level

is written in 3.1 as:

{"type":"integer","format":"int32","default":"1","enum":["1","2"]}

No integer satisfies that enum. The same test passes in 3.0 with numeric enum: [1, 2] and default: 1. This starts with swagger-core's handling, but updating only the type leaves the resulting schema inconsistent. Could the workaround preserve correctly typed annotation values too, or resolve the property with its declared type honored before those values are processed?

3. [P2] Replacing derived success responses can remove an explicitly declared controller response.

ActionAnnotations.groovy:128-151 records the derived codes before applying controller annotations, and the final removal still treats those codes as only derived.

A controller declaring @ApiResponse(responseCode = '200', description = 'Completed synchronously') and an action declaring 202 therefore produces only 202. The explicit controller 200 is deleted. This reproduces with both a direct @ApiResponse and @Operation(responses = ...) on the action. A controller-declared 203 would survive instead, so preservation depends on whether the code happens to match the conventional default.

The guide says controller responses are added to each action. Could controller-declared codes be excluded from the derived-response cleanup?

Verification

On this head, before adding the reproducer:

./gradlew :grails-openapi:check :grails-test-examples-openapi:integrationTest :grails-test-examples-openapi-rest-api:integrationTest -PmaxTestParallel=2 --max-workers=4 --console=plain
  • grails-openapi: 283 unit tests and 9 CLI tests passed; main and CLI CodeNarc checks passed.
  • The two example applications: 39 and 9 integration tests passed.
  • This was scoped verification, not a fresh run of the entire repository's suites.

The additional spec below has six cases: five fail, confirming the three issues above; the numeric OpenAPI 3.0 control passes. It uses the existing OpenApiFixture and checks the serialized document rather than only the in-memory Swagger model.

Reproducer path: grails-openapi/src/test/groovy/grails/openapi/ReviewRegressionSpec.groovy.

./gradlew :grails-openapi:test --tests grails.openapi.ReviewRegressionSpec -PmaxTestParallel=1 --max-workers=4 --console=plain
Full ReviewRegressionSpec.groovy reproducer
/*
 *  Licensed to the Apache Software Foundation (ASF) under one
 *  or more contributor license agreements.  See the NOTICE file
 *  distributed with this work for additional information
 *  regarding copyright ownership.  The ASF licenses this file
 *  to you under the Apache License, Version 2.0 (the
 *  "License"); you may not use this file except in compliance
 *  with the License.  You may obtain a copy of the License at
 *
 *    https://www.apache.org/licenses/LICENSE-2.0
 *
 *  Unless required by applicable law or agreed to in writing,
 *  software distributed under the License is distributed on an
 *  "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
 *  KIND, either express or implied.  See the License for the
 *  specific language governing permissions and limitations
 *  under the License.
 */
package grails.openapi

import groovy.json.JsonOutput
import groovy.json.JsonSlurper

import io.swagger.v3.oas.annotations.Operation
import io.swagger.v3.oas.annotations.media.Content
import io.swagger.v3.oas.annotations.media.Schema
import io.swagger.v3.oas.annotations.responses.ApiResponse

import grails.artefact.Artefact
import grails.validation.Validateable

import spock.lang.Specification
import spock.lang.Unroll

class ReviewRegressionSpec extends Specification {

    @Unroll
    void 'numeric annotation values match their declared type in #version'() {
        when:
        Map document = written(version)
        Map level = document.components.schemas.ReviewNumeric.get('properties').level

        then:
        assert level.enum == [1, 2] : JsonOutput.toJson(level)
        assert level.default == 1 : JsonOutput.toJson(level)
        level.type == 'integer'

        where:
        version << ['openapi_3_0', 'openapi_3_1']
    }

    @Unroll
    void 'a nullable reference annotation permits null and the referenced object in #version'() {
        when:
        Map document = written(version)
        Map container = document.components.schemas.ReviewContainer
        Map address = container.get('properties').address

        then:
        !(container.required ?: []).contains('address')
        version == 'openapi_3_0'
                ? address.nullable == true && address.allOf == [['$ref': '#/components/schemas/ReviewAddress']]
                : address.oneOf == [['$ref': '#/components/schemas/ReviewAddress'], [type: 'null']]

        where:
        version << ['openapi_3_0', 'openapi_3_1']
    }

    @Unroll
    void 'an action success response preserves an explicitly declared controller success at #path'() {
        when:
        Map responses = written('openapi_3_1').paths[path].post.responses

        then:
        responses.keySet() == ['200', '202'] as Set
        responses['200'].description == 'Completed synchronously'

        where:
        path << ['/review/direct', '/review/operation']
    }

    private static Map written(String version) {
        def document = OpenApiFixture.document(['springdoc.api-docs.version': version],
                [ReviewSchemaController, ReviewDispatchController], []) {
            '/review/numeric'(controller: 'reviewSchema', action: 'numeric')
            '/review/container'(controller: 'reviewSchema', action: 'container')
            post '/review/direct'(controller: 'reviewDispatch', action: 'direct')
            post '/review/operation'(controller: 'reviewDispatch', action: 'operation')
        }
        (Map) new JsonSlurper().parseText(GrailsOpenApiGenerator.serialize(document, 'json'))
    }
}

class ReviewNumeric {
    @Schema(type = 'integer', format = 'int32', allowableValues = ['1', '2'], defaultValue = '1')
    Integer level
}

class ReviewContainer implements Validateable {
    @Schema(nullable = true)
    ReviewAddress address
}

class ReviewAddress {
    String street
}

@Artefact('Controller')
class ReviewSchemaController {
    @ApiResponse(responseCode = '200', content = @Content(schema = @Schema(implementation = ReviewNumeric)))
    def numeric() { }

    @ApiResponse(responseCode = '200', content = @Content(schema = @Schema(implementation = ReviewContainer)))
    def container() { }
}

@Artefact('Controller')
@ApiResponse(responseCode = '200', description = 'Completed synchronously')
class ReviewDispatchController {
    @ApiResponse(responseCode = '202', description = 'Queued')
    def direct() { }

    @Operation(responses = [@ApiResponse(responseCode = '202', description = 'Queued')])
    def operation() { }
}

The derived success statuses were recorded before the controller's
annotations were applied, so a 200 the controller declared for each
action was taken for the derived 200 and removed when an action declared
202; a 203 survived only because it was not the conventional status. A
status the controller declares is declared, not derived, so it is no
longer one a success status the action declares replaces.
swagger-core writes a property described by a reference whose @Schema
declares nullable = true as the reference and a null type together in
OpenAPI 3.1, which nothing satisfies, and as the bare reference in 3.0,
which says nothing of null. It now takes the path a nullable constraint
does: one of the reference and a null type in 3.1, and all of the
reference, nullable, in 3.0. The null type swagger-core leaves beside
the reference is removed too, which a property both the annotation and
its constraint made nullable still carried inside the oneOf.
A parameter declared on the action, rather than on a method parameter,
was resolved as a String unless its schema named an implementation, so
@parameter(schema = @Schema(type = 'integer')) was written as a string
in both OpenAPI versions, with its allowable and default values as
strings. It is now resolved as the class of the type and format its
schema declares, as swagger-core describes that type, and an array
schema as an array of it.
An annotation declares allowableValues, defaultValue and example as
strings. swagger-core converts them for a property of an OpenAPI 3.0
document, but in 3.1 its schema casts neither integers nor any enum, so
@Schema(allowableValues = ['1', '2']) on an Integer was written as
["1", "2"], which no integer is, and a BigDecimal default as "1.5"; and
a schema an annotation declares by its type alone, such as a response
content schema, kept them strings in 3.0 as well. The enum, default,
example and const of a schema describing one integer, number or boolean
type are now written as that type, for the properties of a described
class and the schemas of an operation.
@testlens-app

testlens-app Bot commented Sep 28, 2026 •

Copy link
Copy Markdown

🔎 No tests executed 🔎

🏷️ Commit: 9ab310d
▶️ Tests: 0 executed
🟡 Checks: 9/79 completed


Learn more about TestLens at testlens.app/docs.

@codeconsole

Copy link
Copy Markdown
Contributor Author

Thanks. All three are fixed at 9ab310d, and your ReviewRegressionSpec passes unmodified (6/6). Each of its cases is also in the module's specs, with the same annotations and assertions.

1. Nullable reference (3a71b8f). A reference its @Schema makes nullable now takes the path a nullable constraint does:

  • in 3.1, oneOf the reference and a null type;
  • in 3.0, allOf the reference with nullable: true.

That also fixed a reference both the annotation and the constraint make nullable. The reference inside the oneOf still carried swagger-core's type: "null", giving oneOf: [{type: null, $ref}, {type: null}]. AnnotatedPropertySpec compares whole property maps in both versions, for four cases: a Validateable, a plain class, one with a description, and one made nullable both ways.

2. Numeric values (9755d73, 9ab310d). This turned out broader than the declared type. In swagger-core's 3.1 path:

  • AnnotationsUtils.addTypeWhenSiblingsAllowed resolves the property as ctxSchema.type().getClass(), which is String;
  • JsonSchema.cast converts neither an integer nor a fractional number;
  • setEnum doesn't cast at all.

So resolving with the declared type honored wouldn't have been enough. A plain @Schema(allowableValues = ['1', '2']) Integer rank, with no type, was also written as enum: ["1", "2"] in 3.1, and a BigDecimal default as "1.5". Now the enum, default, example and const of a schema describing one integer, number or boolean type are written as that type. That applies to properties and to the schemas an operation annotation declares.

Two more turned up on the way:

  • A response or body schema an annotation declares by its type alone kept string values in 3.0 too, since swagger-core builds it as a generic Schema.
  • A parameter declared on the action, such as @Parameter(name = 'band', schema = @Schema(type = 'integer')), was written as type: string in both versions. It was resolved as String unless it named an implementation; now it's resolved as the class of its declared type and format (9755d73).

3. Controller success (ff6c850). A success status the controller declares is no longer counted among the derived codes. So the controller's 200 stays beside the action's 202, whether the action declares it directly or in @Operation(responses). The guide says so.

@jdaugherty

Copy link
Copy Markdown
Contributor

LGTM. I'm going to merge

@jdaugherty
jdaugherty merged commit c6b784e into apache:8.0.x Sep 28, 2026
78 of 79 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

Status: Done

Development

Successfully merging this pull request may close these issues.

4 participants