matrei commented on PR #16237:
URL: https://github.com/apache/grails-core/pull/16237#issuecomment-6209569838
## Review round 6
**Head:** `4608138a008f7efbafb8c41c967061048abe5bca`
(`codeconsole:feature/spring-media-type-negotiation-8.0.x`)
**Base:** `9.0.x`, merge-base `c948a638d9`. The branch is level with `9.0.x`.
**Delta since round 5 (`6fc4149bbb`):** the PR now targets `9.0.x`. There
are 30 PR commits, from `b98fe9612d` to `cea07d4272`, plus merges of `9.0.x`.
Together they cover jdaugherty's three reviews: Grails 9 keeps the legacy
defaults behind `grails.web.rendering.json.spring`,
`grails.web.rendering.xml.spring` and `grails.databinding.json.jackson`,
`grails-xml` is back in the web starter, and the upgrade notes moved to
`upgrading90x.adoc` as sections 2–10. I haven't re-raised anything from his
threads. Where his fixes touch the findings below, I checked them.
**What I ran locally:**
- `git show --remerge-diff` on every merge since round 5. Three of them
change files:
- `d7aa797479`: upgrade-guide add/add conflict, with GPars kept as section
1.
- `a94f9317dd`: restores `upgrading80x.adoc` to the `9.0.x` text now that
the notes live in the 9.0 guide.
- `e23a267116`: takes `9.0.x`'s `metadataJars` in
`grails-doc/build.gradle`, and switches `TableSpec` from `XML.parse` to
`XmlSlurper`.
All three are correct. `generateConfigReference` still lists the three new
settings.
- Module tests, all green:
| Module / suite | Tests |
|---|---|
| `grails-mimetypes` | 66 |
| `grails-web-common` | 127 |
| `grails-web-databinding` | 77 |
| `grails-controllers` | 224 |
| `grails-test-core` | 3 |
| `grails-testing-support-core` | 50 |
| `grails-converters` | 211 |
| `grails-rest-transforms` | 101 |
| `grails-xml` (`--no-build-cache`, `cleanTest`) | 77 |
| `grails-test-suite-web`, negotiation, JSON and XML specs
(`grails.rest.web.*`, `WithFormatSpec`, `ContentNegotiationSpec`,
`org.grails.web.mime.*`, `JSONConverterTests`, `JSONWriterSpec`) | 102 |
- `codeStyle` on the 12 touched production modules: 0 violations.
`projectHealth` on `grails-xml`, `grails-converters` and `grails-test-core`: no
duplicate classes.
- `:grails-doc:publishGuide`: builds with no xref warnings. The rendered
HTML is the source for the documentation findings.
- A differential spec that runs 34 `Accept` headers through the PR's
`DefaultAcceptHeaderParser` and through a copy of the `9.0.x` parser, against
three MIME configurations, as both `Accept` and `Content-Type`.
- Scratch specs reproducing each finding below. All of them have been
deleted since.
**Verdict: changes requested.** The default path is close to Grails 8, but
not quite there. Two findings change behaviour for an application that sets
nothing (5 and 6). One makes the opt-in write properties a renderer bean
excludes (1). CI is green, but none of these cases is covered by a test.
---
## Blocking
### 1. With the opt-in on, a renderer bean's own `includes`/`excludes` are
ignored
`JsonRenderer` applies its bean-level `includes`/`excludes` only in its
override of the legacy `renderJson(JSON, RenderContext)`
(`JsonRenderer.groovy:128`). Since `b355586f58`,
`DefaultRendererRegistry.addRenderer` (`:166`) gives renderer beans the
registry's `useSpringJson`. The Spring path in `DefaultJsonRenderer` reads only
`context.includes`/`context.excludes`, both for `JsonProjection.of` and for the
decision to stay on the legacy converter (`:275-281`). The bean's lists never
reach it.
`DefaultXmlRenderer.findSpringConverter` (`grails-xml`, `:171`) has the same
gap for `XmlRenderer` (`XmlRenderer.groovy:117`) with
`grails.web.rendering.xml.spring: true` and a non-domain type.
The test was a `ControllerUnitTest` with a nested `@Configuration` that
declares `new JsonRenderer<Account>(Account).tap { excludes = ['password'] }`.
The action calls `respond new Account(user: 'ada', password: 's3cret')`:
| | Body |
|---|---|
| `grails.web.rendering.json.spring` unset | `{"user":"ada"}` |
| `grails.web.rendering.json.spring: true` |
`{"password":"s3cret","user":"ada"}` |
XML shows the same with `XmlRenderer<Credentials>` and `excludes =
['password']`:
| | Body |
|---|---|
| `xml.spring` off |
`<scratchCredentials><user>a</user></scratchCredentials>` |
| `xml.spring: true` |
`<ScratchCredentials><password>secret</password><user>a</user></ScratchCredentials>`
|
`defaultRenderers.adoc` documents this bean pattern for hiding properties,
and section 3 says a `JsonRenderer` or `XmlRenderer` bean "follow[s] the same
settings" (`upgrading90x.adoc:121`). So an application that opts in, or every
application once Grails 10 flips the default, writes the properties it asked to
hide.
Suggested fix: add a protected hook on
`DefaultJsonRenderer`/`DefaultXmlRenderer` for the effective includes/excludes,
overridden in `JsonRenderer`/`XmlRenderer` the way `renderJson(JSON,
…)`/`renderXml(XML, …)` already are. Use it both for the projection and for the
stay-on-legacy check. Please add the excludes case to
`ControllerJsonSerializationSpec` and `SpringXmlRendererSpec`.
## Should fix
### 2. Mixed-case configured MIME types no longer match
Spring's `MediaType` lowercases type and subtype, so
`parseRequestedMimeTypes` (`DefaultAcceptHeaderParser.groovy:113-114`) now
produces lowercase names. `createMimeTypeAndAddToList` (`:131-135`) still
compares them with `mt.name == name` against the configured names, which keep
their case. The same parser resolves `Content-Type` for `request.format`, so
both directions are affected.
Test setup: `grails.mime.types` with `acme: 'application/vnd.Acme+json'`, a
request with `Accept` and `Content-Type` both set to
`application/vnd.Acme+json`, and `withFormat { json {…}; acme {…} }`:
| | Result |
|---|---|
| `9.0.x` | `acme`, for both the header and the body |
| PR | `response.format == 'json'`, `request.format == null`, the `json`
block runs, and `response.mimeTypes` is the full default list |
Registered types such as `application/vnd.ms-excel.sheet.macroEnabled.12`
are affected the same way. Section 9 (`upgrading90x.adoc:351`) and
`contentNegotiation.adoc:207` say names "are now matched case-insensitively",
but for these types an exact match that used to work now fails.
Suggested fix: use `mt.name.equalsIgnoreCase(name)` in both `find`s, which
also makes the documented claim true. Please add a mixed-case configured type
to `AcceptHeaderParserSpec` and `RequestAndResponseMimeTypesApiSpec`.
Related, for the same section: `Content-Type` matching is case-insensitive
now too. `Content-Type: Application/JSON` gives `request.format == 'json'` (it
was `null`), so the JSON binding source now binds a body that `9.0.x` ignored.
That's an improvement, but section 9 only mentions `Accept`.
### 3. The Jackson domain serializer loads lazy to-one proxies to write
their id
`writeAssociationReference` (`GrailsDomainJsonSerializer.java:216-218`)
reads `getProxyIdentifier(value)` and then always calls
`proxyHandler.unwrapIfProxy(value)`. Both Hibernate proxy handlers initialize
the proxy there. The legacy `DomainClassMarshaller.asShortObject` never unwraps.
With the opt-in on, `respond Book.list()` therefore runs one extra `SELECT`
per lazy `author`. A detached instance throws `LazyInitializationException`
where Grails 8 wrote `{"id": …}`.
I tested this with a KeyValue mapping context and an `EntityProxyHandler`
that counts unwraps:
| Path | Result |
|---|---|
| Jackson | `{"id":1,"title":"T","author":{"id":7}}`, 1 unwrap |
| Jackson, handler whose unwrap throws | `IllegalStateException: could not
initialize proxy - no Session` |
| Legacy marshaller, same throwing handler |
`{"id":1,"title":"T","author":{"id":7}}`, 0 unwraps |
Suggested fix: only unwrap when `identifier == null`, and add a spec that
asserts no unwrap happens.
### 4. An application `ValidationProblemDetailFactory` bean is ignored
unless it replaces the plugin's bean by name
`RestResponderGrailsPlugin` registers `validationProblemDetailFactory`
(`:70-77`) unconditionally. `DefaultRendererRegistry` autowires a single
`ValidationProblemDetailFactory` with `required = false` (`:93`). With two
beans of the type, Spring falls back to the field name, so the plugin's bean
wins.
I tested a nested `@Configuration` with `@Bean includingRejectedValues() {
new ValidationProblemDetailFactory(true) }` and the opt-in on. Both beans are
present, and `respond errors` has no `rejectedValue` in its `errors` entries.
Section 7 (`upgrading90x.adoc:332-333`) tells applications to "define their
own `new ValidationProblemDetailFactory(true)` bean". That works only under the
name `validationProblemDetailFactory`. Even then, the example has no
`MessageSource`, so messages come back as unresolved `{0}` templates instead of
the localized text the plugin's bean produces.
Suggested fix: either register the plugin's bean only when the application
has none, or have the guide say "override the `validationProblemDetailFactory`
bean" and show `new ValidationProblemDetailFactory(true, messageSource)`.
### 5. XML validation errors render an action-named `.xml.gsp` with status
200 (default path)
On `9.0.x`, the XML errors renderer was `new DefaultXmlRenderer(Errors)`,
created inside the registry. It wasn't a bean, so it had no locator and always
wrote the errors with `422`. Now `XmlErrorsRenderer` is a bean, so its
`groovyPageLocatorProvider` is injected. `DefaultXmlRenderer.render`
(`:124-135`) has no `!(object instanceof Errors)` guard; `DefaultJsonRenderer`
has always had one.
I tested a failed `save` with `Accept: application/xml`:
| Setup | Result |
|---|---|
| No `save.xml.gsp` | `422` with the `<errors>` body |
| `save.xml.gsp` present | `200` and the GSP |
| JSON with `save.json.gsp` | `422` with the errors body, as before |
In round 4 I listed "errors responses find GSP views the same way" as
verified. I was comparing against the round-3 branch rather than the base, and
against the base it's a behaviour change.
Suggested fix: add `&& !(object instanceof Errors)` to the view check, as in
the JSON renderer, and a row in `ControllerXmlRenderingSpec`.
### 6. `render` of a `JSONObject` that has a `json` key no longer renders
the object (default path)
`render(Map)` now checks `argMap.containsKey('json')`
(`ResponseRenderer.groovy:321`) before the existing `argMap instanceof
JSONElement` branch. `JSONObject` is a `Map`, so `render request.JSON` or
`render JSON.parse(upstream)` with a top-level `"json"` key now behaves
differently:
| Converters | Result |
|---|---|
| `9.0.x` | the whole object is written |
| Present | only the `json` value is written |
| Absent | `IllegalStateException: No JSON renderer is available.` |
Suggested fix: skip the named-JSON branch when `argMap instanceof
JSONElement`.
### 7. New startup cycle through `grailsMimeTypesWebMvcConfigurer`
`grailsMimeTypesWebMvcConfigurer` is an instance `@Bean` method on
`MimeTypesConfiguration` (`:128`).
`DelegatingWebMvcConfiguration.setConfigurers` therefore now creates
`MimeTypesConfiguration`. Its constructor takes the `List<MimeTypeProvider>`,
which includes every `Renderer`. So a renderer bean that depends on any Spring
MVC infrastructure bean (`LocaleResolver`, `mvcConversionService`, …) closes a
loop. Boot's default `allowCircularReferences=false` turns that loop into a
startup failure.
Repro: `@EnableWebMvc`, plus `MimeTypesConfiguration`, plus a
`MimeTypeProvider` bean that takes a `LocaleResolver`. It fails with
`BeanCurrentlyInCreationException: 'mimeTypesConfiguration'`. Removing only the
configurer bean definition lets the context start.
No renderer in this repo does this, so the chance is low, but there is no
opt-out. This is the same `MimeTypesConfiguration → MimeTypeProvider` path as
the round-4 follow-up, now with a new link added by this PR.
Suggested fix: make the bean method `static`, and resolve `MimeTypesHolder`
and the config through `ObjectProvider`s in
`configureContentNegotiation`/`getContentNegotiationStrategy`, memoised.
### 8. `vndError.adoc` describes the Grails 10 default as the current one
`vndError.adoc:20-55` says that "by default" `respond` sends validation
errors as an RFC 9457 problem, and that an application which "keeps the legacy
JSON converter" gets `{"errors": […]}`. For Grails 9 it's the other way round:
with `grails.web.rendering.json.spring` unset, `respond` writes the legacy
body, as section 7 says. Please show the legacy body as the default, and the
problem as what the opt-in gives (and the default from Grails 10). The line
"New applications should use the default RFC 9457 … response" needs the same
adjustment.
### 9. `openApi.adoc`: the new NOTE swallows the chapter's first paragraph
There's no blank line between the `NOTE:` (`openApi.adoc:20-22`) and "Grails
applications can publish…", so the built guide renders the chapter introduction
inside the note. Please add the blank line, and link the note to "XML Web
Support Moves to a Separate Module", as asked in round 5.
## Minor (none blocking)
- **`respond problem, jsonConfiguration: 'x'`** answers `200
application/json`. The named-configuration branch
(`DefaultJsonRenderer.groovy:170-177`) returns before the `ProblemDetail`
status, content type and `instance` handling at `:178`. Handle `ProblemDetail`
first.
- **Misleading fallback warning.** With `grails.web.rendering.json.spring:
true`, a response that still falls back to the legacy converter logs "…set
grails.web.rendering.json.spring=true…" (`:207-211`), which tells users to set
what they already set. This happens for a renderer with a `namedConfiguration`,
a projection on an application converter, or a type no converter can write.
When the setting is on, the message should give the actual reason.
`missingConvertersReported` (`:106`) is per renderer instance, unlike
`legacyFallbackReported`, so that warning repeats once per renderer.
- **Section 3 lists only pretty-print** as a legacy setting Jackson ignores
(`upgrading90x.adoc:119`). With the opt-in,
`grails.converters.json.default.deep` and `grails.converters.json.date` are
ignored too, and associations are always `{id}`. jdaugherty's thread on
`ConvertersConfigurationInitializer` raised both, but the reply covered
pretty-print only.
- **"A `ProblemDetail` is always written through Spring's message
converters"** (section 7) doesn't hold without an MVC adapter. With no
converters, `render(ProblemDetail.forStatus(422))` writes a legacy bean dump
(`{"detail":null,"instance":null,"properties":{…},…}`) and logs the "Legacy
respond() JSON rendering" warning. Please either qualify the sentence or log a
specific warning.
- **Excludes miss a narrowed proxy inside a collection.**
`GrailsDomainJsonSerializer.java:73-75` re-dispatches to the unwrapped
subclass, and `JsonProjection.appliesTo` matches exact classes. In `[plainBook,
proxyOfSpecialBook]` with `excludes: ['title']`, the proxy's `title` is
written. `respond` only unwraps the root, so this shows up for collection
elements and `render json:`.
- **Without `grails-xml`, XML requests fail quietly.**
- `respond errors` for XML answers `404` with no body:
`RestResponder.groovy:196` answers 404 when no errors renderer is found. That
branch predates the PR but is reachable now. A plain XML `respond` answers
`406`, which is right.
- An XML request body binds nothing and raises no error.
- `request.XML` and `encodeAsXML` fail with a bare
`MissingPropertyException`/`MissingMethodException`.
Either answer `406` for errors too, or list these outcomes in section 2
where it says JSON-only applications can drop the module. A `respond`-level
test without `grails-xml` would pin it down.
- **`XmlGrailsPlugin` depends on `dataBinding`** (`:49`), but
`grails-databinding`, which holds `DataBindingGrailsPlugin`, isn't on
`grails-xml`'s runtime classpath. An application without the web starter that
adds only `grails-xml`, as section 2 instructs, gets "cannot be loaded …
dependency [dataBinding] … is missing" and no XML support. Either add the
runtime dependency or use `loadAfter`.
- **`XML.java` logs "Legacy XML conversion is deprecated for removal in
Grails 11"** (`:133-136`), but `grails.converters.XML`, `XmlRenderer` and
`DefaultXmlRenderer` carry no `@Deprecated`. Only the HAL XML, Vnd.Error XML
and XML `ValidationErrorsMarshaller` classes do. Please either annotate the
others or soften the message.
- **`GrailsContentNegotiationStrategy` implements Spring's
`ContentNegotiationStrategy`**, but production only calls
`resolveAcceptedMimeTypes`. `resolveMediaTypes`/`resolveMimeTypes` are reached
only from `GrailsContentNegotiationStrategySpec`, which goes against rule 9.
- One of those features, "error dispatch resolves its format without
reading rejected request parameters" (`:90`), asserts something the real
`response.format` doesn't do.
`HttpServletResponseExtension.getMimeTypeForRequest` still reads
`params.format` on error dispatches, unchanged from `9.0.x`.
- Suggested fix: drop the interface and those two methods, and test
through `response.format`/`withFormat`. That also removes the risk the
configurer comment describes, should someone later register the class as a bean.
- **Smaller items:**
- `GrailsContentNegotiationStrategy.groovy:120` matches `X-Requested-With`
with `equalsIgnoreCase`, while `request.xhr` uses an exact match.
- The no-configurer fallback in `HttpServletResponseExtension`
(`:199-203`) builds a strategy, including the user-agent regex, on every
request.
- `Accept: *` now gives `response.mimeTypes == [*/*]` instead of the
default list. `response.format` and `withFormat` are unaffected.
- **Nits:**
- `selectedConfiguration ?: namedConfiguration`
(`DefaultJsonRenderer.groovy:213`) is dead; `selectedConfiguration` is always
empty there.
- The `MessageSource` import in `RestResponderGrailsPlugin` (`:36`) sits
among the `grails.*` imports.
- `RespondJsonConverterSelectionSpec`'s feature names still describe the
removed marshaller heuristic, and `RespondJsonMarshallerRegistererSpec` uses
the deprecated `doWithSpring`.
- `hal.adoc:133` and `atom.adoc:46` read as if `grails-xml` were missing,
though the 9 starter includes it.
- Stray blank lines are left in `ContentNegotiationSpec` and
`ContentFormatControllerTests` (`:36-38`) where the cache resets were removed.
## Round-5 and round-4 findings, status
| Finding | Status |
|---|---|
| R5 #1 `RestfulControllerRedirectTargetSpec` resets the removed cache |
Fixed in `4a583a3873` |
| R5 #2 `openapi-rest-api` example needs `grails-xml` | Fixed by
`b520a96840`, which puts `grails-xml` back in the web starter |
| R5 #2 note in `openApi.adoc` | Partly done: added, but without the link
and with the rendering problem in 9 |
| R5 #3 `grails-openapi` doesn't know about the Spring JSON opt-in | Open.
No mention in section 7 or `openApi.adoc`, and no follow-up issue.
`openApi.adoc:597` ("as the converters render it") also stops holding for beans
with `xml.spring: true` |
| R5 #4 `doWithSpring` wording | Fixed. Section 3 and `unitTesting.adoc`
point to a nested `@Configuration` |
| R4 `DefaultXmlRenderer` resolves its `ObjectProvider`s on every response |
Open (`:125`, `:129`). With `xml.spring`, `findSpringConverter` also resolves
the converters twice (`:171`, `:179`) |
| R4 `groovyPageLocator`/`rendererRegistry` no longer autowired | Open, and
not mentioned in section 2 |
| R4 `xmlRenderer` supplier sets the locator provider by hand | Open
(`XmlGrailsPlugin.groovy:75`) |
| R4 follow-up issue for the field-injected locator | Open. Finding 7 makes
the issue more relevant |
## Verified as correct
- **Negotiation on the default path matches `9.0.x`** except for finding 2
and the `Accept: *` note.
- Covered: `withFormat`, `response.format`, `response.mimeTypes`,
`request.format`, the `format` parameter and `RESPONSE_FORMAT`.
- The differential spec agrees on quality ordering, `v=`, the
`text/xml`/`application/xml` dedup, `+xml` priority, `charset`, invalid and
quoted `q`, empty tokens and multipart boundaries.
- `use.accept.header`, `disable.accept.header.userAgents` (as a pattern or
a list) and `userAgentsXhr` keep their semantics.
- `173e94f9b8` restores the Accept-header fallback for
`response.mimeTypes`.
- **The static MIME cache is gone and its replacement is immutable.** The
parser is created per call and never mutates the configured `MimeType`s.
- **Spring MVC negotiation is unaffected.** Only data-format aliases are
added, and Boot's or the application's `media-types` win. `produces` and 406 on
`@RestController` and actuator endpoints behave as before. The strategy isn't a
bean, so Spring Security doesn't adopt it.
- **Default `respond` JSON matches the legacy output, errors included.**
`new JSON(object)` is equivalent to the old `object as JSON` cast, and
`JSON.use(namedConfiguration)`, includes/excludes, the 422 status and the
content type are unchanged. The only intended difference is scalar roots
(section 5).
- **`WriterOutputStream` is correct.**
- Content: 2-, 3- and 4-byte characters, surrogate pairs, NUL and escapes.
- Chunking: 50 random chunkings from 1 to 9000 bytes, mixing `write(int)`,
`write(byte[])` and `flush()`.
- Encodings: UTF-8, UTF-16, UTF-16LE and GB18030 all round-trip.
- Edge cases: a truncated trailing sequence becomes U+FFFD on close, and
the writer is left open. An ISO-8859-1 response encoding gives the same result
as the legacy converter.
- **Domain JSON on the Jackson path matches `DomainClassMarshaller`**, apart
from findings 3 and 5:
- `id` and `version` are written only when set, and `version` only with
`include.version`.
- `class` comes from the entity name.
- Properties follow metadata order.
- Null associations are written as `null` and `Basic` collections as
values.
- The projection applies to `id`, `version` and `class`.
- **jdaugherty's implementation fixes hold.** Checked: the narrowed root
proxy, embedded-cycle `_ref`, single-pass `Iterable`, `text/json`,
`HttpStatus.resolve` for problem statuses, and `SpringMessageConverters` being
read when a response is written.
- **Shared state is scoped correctly.**
- `forGrails` caches per customizer bean, so per context, with nothing
shared across a devtools restart.
- `NamedJsonConfigurationRegistry` freezes configurations on registration
and caches the writer and mapper safely.
- The legacy warning fires once per registry.
- **`ProblemDetail` responses with the opt-in** set status and content type
before the body is written. A failed write restores the negotiated type, and
`instance` comes from the request URI.
- **The `grails-xml` move is clean.**
- No class exists in two modules, and the extension modules have distinct
names.
- `XmlConvertersConfigurationInitializer` is a faithful port: the
marshaller order, deep, encoding, circular-reference and pretty-print settings,
and the `ObjectMarshallerRegisterer` beans are all carried over.
- The XML binding creator beans keep their names.
- `findSpringConverter` isn't reached when `xml.spring` is off.
- **Packaging is complete.**
- `settings.gradle` and the publish list include `grails-xml`.
- `grails-base-bom` manages it, and the starter-web POM includes it.
- `generateConfigReference` lists the three new settings.
- The `ConfigurationMetadataPlugin` change has a regression spec.
- **Test-harness wiring matches section 10 and `unitTesting.adoc`.**
`GrailsMockHttpServletRequest` without `grails-xml` fails with a clear message
naming the module.
- **Upgrade guide sections 2–10** use the setting names and defaults the
code has, name Grails 10 for the flip and Grails 11 for removal, and mention no
internal classes. The PR description matches the code.
- **Source hygiene:** new files have license headers, and there are no
wildcard or `javax` imports. The only `@author` in an added file is on
`DefaultXmlRenderer`, a moved file that keeps its original tag, which is fine.
--
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.
To unsubscribe, e-mail: [email protected]
For queries about this service, please contact Infrastructure at:
[email protected]