Skip to content

Add optional grails-openapi module for springdoc integration - #16275

Open
codeconsole wants to merge 17 commits into
apache:8.0.xfrom
codeconsole:feat/openapi-springdoc-8.0.x
Open

Add optional grails-openapi module for springdoc integration#16275
codeconsole wants to merge 17 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 so a Grails application can publish an OpenAPI description of its REST endpoints and browse it with Swagger UI, plus an openapi forge feature that selects it.

springdoc builds its document by scanning Spring MVC handler methods. Grails dispatches through UrlMappingsHandlerMapping rather than @RequestMapping handler methods, so springdoc on its own serves /v3/api-docs with no paths. This module supplies them through springdoc's OpenApiCustomizer SPI, and describes domain classes and command objects through swagger-core.

Everything is derived. The annotations below are available to correct or enrich what is derived, and none of them are required.

Usage

Select openapi when generating an application, or add it to an existing one:

dependencies {
    // the OpenAPI document, served at /v3/api-docs
    implementation 'org.apache.grails:grails-openapi'

    // optional: Swagger UI, served at /swagger-ui/index.html
    implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui'
}

The module produces the document; Swagger UI is a separate choice. The document is equally useful to a code generator or a contract test, and Swagger UI is not the only viewer that reads it, so the module does not pre-decide one. The forge feature selects both.

No further configuration is required. Given:

class Book {
    String title
    String genre

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

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

/v3/api-docs describes the operations the mapping generates, and:

"Book": {
  "type": "object",
  "properties": {
    "id":      { "type": "integer", "format": "int64", "readOnly": true },
    "title":   { "type": "string", "maxLength": 255 },
    "genre":   { "type": "string", "enum": ["scifi", "fantasy", "history"] },
    "version": { "type": "integer", "format": "int64", "readOnly": true }
  },
  "required": ["title"]
}

The identifier and version are marked readOnly because the server assigns them, so one schema describes both directions.

Constraint mapping

Constraint OpenAPI
nullable: false required
maxSize / size maxLength, minLength
min / max / range minimum, maximum
inList enum
matches pattern
email / url format

Responses

A RestfulController action is described from what the controller does, rather than as a uniform 200:

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
update, patch 200, 404, 422 the resource
delete 204, 404 none

Request bodies

An operation that accepts a body describes the type the action binds. Where the action takes a command object that is the command, in preference to the resource the controller is named for:

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

class OrderCommand implements Validateable {
    String customerEmail
    Integer quantity

    static constraints = {
        customerEmail email: true, nullable: false
        quantity min: 1, nullable: true
    }
}

POST /orders/submit is described as accepting an OrderCommand, with the constraints the command declares carried across the same way a domain class's are.

Controllers reached through the default mapping

A RestfulController is also served by the default "/$controller/$action?/$id?" mapping, so those routes are documented too, including for controllers no mapping names, which covers scaffolded controllers:

GET    /book/index          POST   /book/save
GET    /book/show/{id}      PUT    /book/update/{id}
GET    /book/create         PATCH  /book/patch/{id}
GET    /book/edit/{id}      DELETE /book/delete/{id}

The method comes from the controller's allowedMethods. A controller a resources mapping also names appears at both /books/{id} and /book/show/{id}, because both answer; an application wanting only the first form removes the default mapping from UrlMappings.groovy.

Only RestfulController subclasses are described this way, so controllers rendering GSP views stay out of the document.

Describing a domain class

@Schema on the class or on a property supplies what the constraints cannot:

import io.swagger.v3.oas.annotations.media.Schema

@Schema(description = 'A book in the catalogue')
class Book {

    @Schema(description = 'Full title as printed', example = 'Dune')
    String title

    static constraints = {
        title blank: false, nullable: false, maxSize: 255
    }
}

The two combine on the same property:

"title": {
  "type": "string",
  "description": "Full title as printed",
  "example": "Dune",
  "maxLength": 255
}

Describing an action

import io.swagger.v3.oas.annotations.Operation

@Operation(summary = 'List the catalogue',
        description = 'Every book, most recently added first.',
        operationId = 'listBooks',
        tags = ['Catalogue'])
def index() { }

@Operation supplies the summary, description, operation id, tags, and whether the operation is deprecated. A value it does not set is left as derived, so an action can be given a summary without restating anything else.

Adding a response

import io.swagger.v3.oas.annotations.responses.ApiResponse

@ApiResponse(responseCode = '403', description = 'Not your book')
def show() { }

The declared response is added alongside the derived 200 and 404 rather than replacing them.

Naming a response type

An action's return type is not declared, so a controller responding with something other than the resource it is named for can say so:

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

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

The declared type replaces the convention and its schema is described alongside the others.

Withholding an endpoint

import io.swagger.v3.oas.annotations.Hidden

@Hidden
def internalAudit() { }

@Hidden on the controller withholds all of it, and @Operation(hidden = true) does the same as on an action. This is how an endpoint that is reachable but not part of the published API is kept out.

Limitations

  • A mapping that accepts any HTTP method is documented as GET, because OpenAPI requires a concrete operation.
  • A controller that is not a RestfulController is described as responding with the resource it is named for. That is a convention rather than something that can be determined; name the type with @ApiResponse where it differs.
  • The mapping context is injected optionally, so an application without GORM still gets a document describing its paths, without schemas.
  • An application whose URL mappings include a catch-all resolving to a view or URI needs static excludes = ['/swagger-ui/**', '/v3/api-docs/**'], which would otherwise match the springdoc paths.

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 68.49926% with 212 lines in your changes missing coverage. Please review.
✅ Project coverage is 54.8209%. Comparing base (2e88f24) to head (c239905).
⚠️ Report is 216 commits behind head on 8.0.x.

Files with missing lines Patch % Lines
...rails/openapi/PersistentEntitySchemaBuilder.groovy 54.4944% 36 Missing and 45 partials ⚠️
...grails/openapi/UrlMappingsOpenApiCustomizer.groovy 78.7062% 15 Missing and 64 partials ⚠️
...ain/groovy/grails/openapi/ActionAnnotations.groovy 56.1905% 9 Missing and 37 partials ⚠️
...ovy/grails/openapi/RestfulControllerActions.groovy 64.7059% 0 Missing and 6 partials ⚠️
Additional details and impacted files

Impacted file tree graph

@@                Coverage Diff                 @@
##                8.0.x     #16275        +/-   ##
==================================================
+ Coverage     54.7109%   54.8209%   +0.1100%     
- Complexity      20422      20742       +320     
==================================================
  Files            2101       2106         +5     
  Lines          100978     101651       +673     
  Branches        17907      18090       +183     
==================================================
+ Hits            55246      55726       +480     
- Misses          37866      37909        +43     
- Partials         7866       8016       +150     
Files with missing lines Coverage Δ
.../grails/plugins/openapi/OpenApiGrailsPlugin.groovy 100.0000% <100.0000%> (ø)
...ovy/grails/openapi/RestfulControllerActions.groovy 64.7059% <64.7059%> (ø)
...ain/groovy/grails/openapi/ActionAnnotations.groovy 56.1905% <56.1905%> (ø)
...grails/openapi/UrlMappingsOpenApiCustomizer.groovy 78.7062% <78.7062%> (ø)
...rails/openapi/PersistentEntitySchemaBuilder.groovy 54.4944% <54.4944%> (ø)

... and 12 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.
@testlens-app

testlens-app Bot commented Sep 1, 2026

Copy link
Copy Markdown

✅ All tests passed ✅

⚠️ TestLens detected flakiness ⚠️

Test Summary

CI / Functional Tests (Java 25, indy=false) > :grails-test-examples-scaffolding:integrationTest

Test Runs Flakiness
UserControllerSpec > User list ❌ ✅ 5% 🟠

🏷️ Commit: c239905
▶️ Tests: 30457 executed
⚪️ Checks: 91/91 completed

Test Failures

UserControllerSpec > User list (:grails-test-examples-scaffolding:integrationTest in CI / Functional Tests (Java 25, indy=false) | Attempt 1/2)
geb.waiting.WaitTimeoutException: condition did not pass in 30 seconds (failed with exception)
	at geb.waiting.Wait.waitFor(Wait.groovy:128)
	at geb.waiting.DefaultWaitingSupport.doWaitFor(DefaultWaitingSupport.groovy:55)
	at geb.waiting.DefaultWaitingSupport.waitFor(DefaultWaitingSupport.groovy:41)
	at geb.Page.waitFor(Page.groovy:120)
	at com.example.pages.LoginPage.login(LoginPage.groovy:39)
	at com.example.UserControllerSpec.User list(UserControllerSpec.groovy:48)
Caused by: Assertion failed: 

title != pageTitle && $('input', name: 'username').empty
|     |  |         |
|     |  |         false
|     |  'Please sign in'
|     false
'Please sign in'

	at com.example.pages.LoginPage.login_closure1(LoginPage.groovy:39)
	at com.example.pages.LoginPage.login_closure1(LoginPage.groovy)
	at geb.waiting.Wait.waitFor(Wait.groovy:117)
	... 5 more

Learn more about TestLens at testlens.app/docs.

@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
@codeconsole
codeconsole requested review from borinquenkid, jamesfredley, jdaugherty, matrei and sbglasius and removed request for jdaugherty September 2, 2026 03:46
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

Status: No status

Development

Successfully merging this pull request may close these issues.

2 participants