Add optional grails-openapi module to describe an application with OpenAPI - #16275
Conversation
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.
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.
|
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.
43b1c6f to
dbe1cc7
Compare
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.
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.
in fact, i think there is. but first: hats off for the changes. awesome. springdoc allows defining https://springdoc.org/#how-can-i-define-multiple-openapi-definitions-in-one-spring-boot-project
to what extend is/could that be supported by the grails impl.? thanks & regards. |
matrei
left a comment
There was a problem hiding this comment.
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).reachedAtis 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 whereisRestActionholds for it. responseFormatsas a map (2).ControllerCatalog.responseFormats(controller, action)reads the declaration exactly asRestResponder.calculateFormatsdoes: aList, or aListper action. I agree that aString[]should count as undeclared, sincerespondtreats 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.isApplicationTypeexcludes onlyjava.*andjavax.*, soRestfulControlleris kept for reflection too.NavigableMapPropertySourcenit. An empty map, an empty list andnullnow 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
DateMarshallerandCalendarMarshallernow render throughdate.toInstant(), which uses the proleptic Gregorian calendar. JSON views useSimpleDateFormat, and Jackson uses its ownStdDateFormat; both use the Julian calendar before 1582. I checked this with aDateholding Julian1500-01-01T00:00:00Z:- the converters render
1500-01-10T00:00:00Zand the views render1500-01-01T00:00:00.000Z. - binding the converters' output back gives a date 9 days later than the original.
DateTimeRoundTripBindingSpecrenders withas JSON, which is the converters, but it only uses a 2025 date, so it does not catch this. The Javadoc onoffsetDateTimesays "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. - the converters render
-
GraphQL still binds the old way.
grails-data-graphql'sDateCoercionand thejava.timecoercions read the samegrails.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, butZonedDateTime.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
Jsr310ConvertersConfigurationchanges nothing for values the configured formats already read.DateTimeFormatter.parsealready rejected unread trailing text, so for thejava.timetypes only the ISO read is new. @BindingFormatproperties go throughFormattedDateValueConverterand are not affected by the date change.- In
offsetDateTime, ISO year0and negative years map correctly to theBCera (-0043is 44 BC), and a region-qualified value such as...+02:00[Europe/Paris]is read at its offset. recordResolvedNamesruns from anOpenApiBuilderCustomizer, so it starts before springdoc resolves its handler types and on the same thread as the group'sOpenApiCustomizers, including for each group and the actuator resource.- No wildcard imports,
javax,@authortags or section-separator comments, and every new file has the license header.
… so a failed build leaves nothing on the thread
|
@matrei thanks. All three are addressed at 714a897.
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
matrei
left a comment
There was a problem hiding this comment.
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
- Date binding. 714a897 reverts it completely. The databinding modules,
grails-test-suite-webanddataBinding.adocare identical to the merge base, and the upgrade section is gone. The test counts above are back to what8.0.xhas. getExcludesJavadoc. Fixed in 5e2296f.- The record left on a thread. Fixed in 49931ac. I checked that the destruction callback runs for springdoc's requests.
FrameworkServlet.processRequestbuilds its ownServletRequestAttributesover aGrailsWebRequestand completes them in itsfinally, 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.
…gdoc-8.0.x # Conflicts: # dependencies.gradle
|
@matrei thanks for the approval.
|
jdaugherty
left a comment
There was a problem hiding this comment.
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) |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
Fixed in 0140963. Where a property's @Schema says whether it must be sent, that now decides required:
nullable = trueorrequiredMode = NOT_REQUIREDkeeps it out;requiredMode = REQUIREDkeeps 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) |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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: trueaddsnullto the declared type; - before the early return, so your
Moneyclass, which is neither an entity nor aValidateable, 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)) { |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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
listwrapper; - the
422content depends on the renderer: the converters' shape, the JSON-view errors shape, XML, andgrails.validation.ValidationErrorswhere 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
saveoverride declaring@ApiResponse(responseCode = '200')is now200only. 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.buildApiResponsesdoesn't add the one it would derive. Here it's narrowed to success statuses, so declaring a409doesn't drop the200. It also fixes plain controllers, where declaring201used to leave the derived200beside it. - An
indexoverride that doesn't page withdraws the four parameters with@Parameter(hidden = true). The guide shows how, andResourceActionSpecpins it. That's the one cost of this design, and I think it's in the right place. A listing normally pages, throughsuper.indexor by passingparamsto 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.
|
Re-reviewed at 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 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 class ReviewContainer implements Validateable {
@Schema(nullable = true)
ReviewAddress address
}
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 2. [P2] Correcting a 3.1 property's type leaves numeric annotation values as strings. GrailsModelConverter.groovy:584-598 corrects @Schema(type = 'integer', format = 'int32',
allowableValues = ['1', '2'], defaultValue = '1')
Integer levelis 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 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 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
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 Reproducer path: ./gradlew :grails-openapi:test --tests grails.openapi.ReviewRegressionSpec -PmaxTestParallel=1 --max-workers=4 --console=plainFull 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.
🔎 No tests executed 🔎🏷️ Commit: 9ab310d Learn more about TestLens at testlens.app/docs. |
|
Thanks. All three are fixed at 9ab310d, and your 1. Nullable reference (3a71b8f). A reference its
That also fixed a reference both the annotation and the constraint make nullable. The reference inside the 2. Numeric values (9755d73, 9ab310d). This turned out broader than the declared
So resolving with the declared type honored wouldn't have been enough. A plain Two more turned up on the way:
3. Controller success (ff6c850). A success status the controller declares is no longer counted among the derived codes. So the controller's |
|
LGTM. I'm going to merge |
Adds an optional
grails-openapimodule 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 anopenapiforge feature.The document is produced two ways from the same configuration:
generate-open-apicommand, 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./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 withspringdoc.api-docs.enabled: falseandspringdoc.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' }writes
build/openapi/openapi.yaml, andbuild/openapi/openapi-<group>.yamlfor each group, with a character a file name cannot hold, such as the/ofadmin/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, orgrails.openapi.output-formatandgrails.openapi.output-directory:./gradlew runCommand -Pargs="generate-open-api --format=json --output-directory=build/api"Given:
the document describes the operations the mapping generates, each in
application/jsonandtext/xml, and:The identifier is
readOnlybecause 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. Thexmlobject describes the XML the converters render:<book id="1"><title>…</title><author id="2"/></book>.What is described
Every mapping that names its controller:
resourcesmappings,"/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.formatsuffix 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
allowedMethodsrefuses it; one that accepts any method is described for the methodsallowedMethodsdeclares, or the method aRestfulControlleraction of that name answers.The REST controllers a mapping reaches without naming them, at the URLs that mapping serves:
RestfulControllersubclasses, and controllers declaringresponseFormatswithouthtml, so a login controller responding inhtmlandjsonis not described.responseFormatsis read asrespondreads it, and where it is a map by action, each action is decided by its own entry. This covers the generated REST API formget "/$controller(.$format)?"(action: 'index')and the default"/$controller/$action?/$id?"mapping, which also answersGET /bookwith the default action, so scaffolded and generated controllers are described:A controller generated for a REST application, which is not a
RestfulControllerbut whosesaveandupdatebind the domain class, answers the actionsRestfulControllerdeclares as aRestfulControllerof 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
RestfulControllerconstructed read only answers405from its write actions, so onlyindexandshoware described.createandeditanswer HTML forms, so they are left out unlessgrails.openapi.include-form-actions: true.Versions
A mapping declared for a version is matched on the
Accept-Versionheader:A request asking for no version is answered by the highest version, so the default document describes that one, with an optional
Accept-Versionparameter. A group describes another version by selecting its header, where the parameter is required:Schemas
A type is resolved through swagger-core, so
@Schemaand Jackson annotations are honored. A property of a class the Grails operations use, a domain class, a command object or a class an@ApiResponsenames, is described by the name Grails' converters, JSON views and data binding render and bind it by, its own:String ISBNisISBN, not Jackson'sisbn, 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 ornull. OpenAPI 3.0 ignores anything beside a$ref, so there a reference that is nullable, read only, or given a description by@SchemaisallOfthe reference, which says it. The constraints of a domain class orValidateableclass are applied over that:nullable: falserequired, unless the property's@Schemadeclares itnullableorNOT_REQUIREDnullable: truenullablein 3.0, anulltype in 3.1, andnullin anyenumblank: falseminLengthof 1maxSize/minSize/sizemaxLength,minLengthof a string;maxItems,minItemsof a collectionmin/max/rangeminimum,maximuminListenummatchespattern, anchored, since Grails matches the whole valueemail/urlformatWhere the annotation and the constraints both say whether a property must be sent, the property's
@Schemawins:nullable = trueorrequiredMode = NOT_REQUIREDleaves it out ofrequired, andrequiredMode = REQUIREDkeeps it in, whatever it is constrained to. So aValidateabledeclaring no constraints, whose every property Grails constrainsnullable: false, can still describe a property as optional. Atypea property's@Schemadeclares is written in a 3.1 document as it is in 3.0.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.versionmakes Grails render it. What data binding does not bind isreadOnly: the identifier and version,dateCreatedandlastUpdated, a property constrainedbindable: 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:
Longinteger,int64Integerinteger,int32UUIDstring,uuidStringor any other typestringA schema is named after its class. Classes sharing a name in different packages are each named with their package, such as
com.example.v1.Bookandcom.example.v2.Book, whichever is described first. A class with the same name as a schema the base document declares,ValidationErrors, or aPatchschema 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.
patchbinds only what it is sent, so its body isBookPatch: the resource's properties with nothing required. A body with aMultipartFileorPartproperty is described asmultipart/form-data, with the file as a binary string. A body is described in the data formats of the controller'sresponseFormats, orapplication/json.Responses
The actions
RestfulControllerdeclares 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:indexshow,editcreatesaveLocationheader whereRestfulController's ownsaverunsupdate,patchLocationheader whereRestfulController's ownupdaterunsdeleteThe
Locationheader is something onlyRestfulController's code sends, like the405of 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 asearch, is described as any controller's action is:200, a404where 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 inapplication/jsonwhere it declares none. Onlyjsonandxmlcarry the shape; a format that renders a view or a HAL document, such ashtmlorhal, is listed without one. The XML is described as the converters render it, with thexmlobject: an element named for the class, the identifier and version as attributes, and a listing as alistelement holding one for each.A
422answers with the errors Grails renders for a request that fails validation, described once asValidationErrors, or asgrails.validation.ValidationErrorswhere the document already describes something else under that name, such as a class a Spring MVC endpoint returns:{"errors": [{"object", "field", "rejected-value", "message"}]}<errors><error object=".." field=".."><rejected-value/><message/></error></errors>_errors.gson422without a shape; the view answers with the status it sets422: the view for any object renders the errors and answers with successAn application rendering its errors another way declares
ValidationErrorsin the base document, which then describes them in every media type.Parameters
indexacceptsmax(capped at 100, default 10),offset,sortandorder, whichRestfulControllerpasses to GORM, and so does anindexthe 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@RequestParametergives it: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: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
@Operation@ApiResponsesavedeclaring200is not also described with201@Parameter@RequestBody@SecurityRequirement@Tag@HiddenConfiguration
grails.openapi.enabledtruegrails.openapi.base-documentgrails.openapi.display-nameinfo.app.namegrails.openapi.annotated-onlyfalse@Operation, or whose controller declares@Taggrails.openapi.include-form-actionsfalsecreateandeditactions of aRestfulControllergrails.openapi.paths-to-match,paths-to-excludegrails.openapi.packages-to-scan,packages-to-excludegrails.openapi.produces-to-match,consumes-to-matchgrails.openapi.headers-to-matchAccept-Version=1.0grails.openapi.groups.<name>.*grails.openapi.output-directorybuild/openapigenerate-open-apiwritesgrails.openapi.output-formatyamlyamlorjsonThe 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-matchandheaders-to-matchapply to the Grails endpoints too, andspringdoc.api-docs.versionchooses 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
responseFormatsand consumes them where it binds a body. A versioned mapping declares theAccept-Versionheader 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>:A group declared to springdoc, in
springdoc.group-configsor as aGroupedOpenApibean, is honored the same way at runtime and by the command. springdoc's top-level criteria, such asspringdoc.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 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-apicommand:pathsToMatch,pathsToExcludepackagesToScan,packagesToExcludeproducesToMatchresponseFormatsconsumesToMatchmultipart/form-datafor a body with a fileheadersToMatchAccept-Versionheader of a versioned mappingaddOpenApiMethodFilter,OpenApiMethodFilterandGlobalOpenApiMethodFilterbeansaddOperationCustomizer,OperationCustomizerandGlobalOperationCustomizerbeansHandlerMethodof the controller and the actionaddOpenApiCustomizer,OpenApiCustomizerandGlobalOpenApiCustomizerbeansAn operation customizer returning
nullleaves 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:
Forge feature
The
openapifeature addsgrails-openapiand springdoc with Swagger UI, and setsspringdoc.api-docs.enabled: falseandspringdoc.swagger-ui.enabled: falsefor production, so a generated application does not publish its API description there unless it chooses to.Changes outside the module
application.ymlorapplication.groovyis now presented to Spring element by element (springdoc.group-configs[0].group), the way Spring Boot's own YAML loader presents it, so a@ConfigurationPropertieslist of objects is bound with instances of its declared type. It used to be bound as a list of maps and fail with aClassCastException, which is howspringdoc.group-configsstopped an application from starting.Environment.getPropertyon such a list, or on a list holding lists of objects, now returnsnullandcontainsPropertyreturnsfalse, as in Spring Boot;grailsApplication.configis unchanged, and a list of plain values is presented as before. Within such a list, an empty object, an empty list or anullis presented as an empty string under its own name, as Spring Boot presentsempty: [{}]asempty[0]. The 8.0 upgrade notes cover it.bindableconstraints, and kept for each class, by an internalorg.grails.web.databinding.BindingIncludeListsthatDataBindingUtilsbinds 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.@RequestParameteris 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
ModelConverterbean, 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
allowedMethodsdoes not restrict is described asGET, 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.RestfulControllernor one generated for a REST application declares no response type. Name one with@ApiResponse(content = @Content(schema = @Schema(implementation = ...))).@ApiResponse.paramsrather than declaring is described only with@Parameter. Declared parameters need the parameter names, which the Grails Gradle plugin preserves by default.$path**, is described as a path parameter, which OpenAPI allows only one segment of.date-time. Grails binds it with the formatsgrails.databinding.dateFormatslists, which by default read a time with an offset orZonly with milliseconds, such as2024-05-01T10:00:00.000+02:00; without them, as in2024-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.OpenApiLocaleCustomizers before the Grails operations are added, so one does not see them.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 undergrails.controllers.defaultScope: prototype, is described with its write actions, and one passing its resource only to the constructor, asextends RestfulControllerwithsuper(Book), without a schema.extends RestfulController<Book>describes the resource in any scope.generate-open-apiwrites as a static file.