Add optional grails-openapi module for springdoc integration - #16275
Add optional grails-openapi module for springdoc integration#16275codeconsole wants to merge 17 commits into
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.
Codecov Report❌ Patch coverage is Additional details and impacted files@@ 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
🚀 New features to boost your workflow:
|
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.
✅ All tests passed ✅Test SummaryCI / Functional Tests (Java 25, indy=false) > :grails-test-examples-scaffolding:integrationTest
🏷️ Commit: c239905 Test FailuresUserControllerSpec > User list (:grails-test-examples-scaffolding:integrationTest in CI / Functional Tests (Java 25, indy=false) | Attempt 1/2)Learn more about TestLens at testlens.app/docs. |
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. |
Adds an optional
grails-openapimodule so a Grails application can publish an OpenAPI description of its REST endpoints and browse it with Swagger UI, plus anopenapiforge feature that selects it.springdoc builds its document by scanning Spring MVC handler methods. Grails dispatches through
UrlMappingsHandlerMappingrather than@RequestMappinghandler methods, so springdoc on its own serves/v3/api-docswith no paths. This module supplies them through springdoc'sOpenApiCustomizerSPI, 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
openapiwhen 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:
/v3/api-docsdescribes the operations the mapping generates, and:The identifier and version are marked
readOnlybecause the server assigns them, so one schema describes both directions.Constraint mapping
nullable: falserequiredmaxSize/sizemaxLength,minLengthmin/max/rangeminimum,maximuminListenummatchespatternemail/urlformatResponses
A
RestfulControlleraction is described from what the controller does, rather than as a uniform200:indexshow,editcreatesaveupdate,patchdeleteRequest 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:
POST /orders/submitis described as accepting anOrderCommand, with the constraints the command declares carried across the same way a domain class's are.Controllers reached through the default mapping
A
RestfulControlleris 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:The method comes from the controller's
allowedMethods. A controller aresourcesmapping 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 fromUrlMappings.groovy.Only
RestfulControllersubclasses are described this way, so controllers rendering GSP views stay out of the document.Describing a domain class
@Schemaon the class or on a property supplies what the constraints cannot:The two combine on the same property:
Describing an action
@Operationsupplies 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
The declared response is added alongside the derived
200and404rather 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:
The declared type replaces the convention and its schema is described alongside the others.
Withholding an endpoint
@Hiddenon 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
GET, because OpenAPI requires a concrete operation.RestfulControlleris 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@ApiResponsewhere it differs.static excludes = ['/swagger-ui/**', '/v3/api-docs/**'], which would otherwise match the springdoc paths.