Repository navigation
Write grails.converters.JSON through Spring Boot's JsonMapper - #16414
codeconsole merged 35 commits into
Conversation
grails.converters.JSON now writes JSON with the application's Jackson JsonMapper, the one Spring Boot auto-configures, instead of Grails' own JSON writer and a marshaller per value type. Values render as Spring Boot renders them, and spring.jackson.* properties apply to render ... as JSON. - JacksonJSONWriter is a JSONWriter that writes to a JsonGenerator, keeping JSONWriter's state and value semantics, so existing object marshallers and the PathCapturingJSONWriterWrapper keep working - JsonMapperValueMarshaller renders the values the mapper has a dedicated single-value serializer for (dates and times, Month, UUID, Locale, byte[], @jsonvalue and Jackson module types); beans, enums, records, Optional and containers stay with Grails marshallers, so the values they contain are rendered as any other value is - RecordMarshaller and OptionalMarshaller render records and Optional as Spring Boot does - HtmlSafeJsonWriter keeps escaping </ and U+2028/U+2029 in every string - Map keys are written by the mapper's key serializers - Enums keep rendering by name(); a Month renders as its number and binds back from it - The per-type JSON marshallers for dates, times, Locale and byte arrays are no longer registered; the java.time ones are removed - PathCapturingJSONWriterWrapper handles null and Number values and single values The differences from Grails 8 are listed in the new 9.0 upgrade guide.
…shallers and parser JSONWriter itself now writes through a Jackson JsonGenerator, in place of the separate JacksonJSONWriter. It keeps its public methods and state machine, so marshallers and PathCapturingJSONWriterWrapper are unchanged. It never flushes or closes the caller's Writer, and closes the generator once the JSON text is complete. JsonMapperSupport, JsonMapperValue and HtmlSafeJsonWriter move to grails-web-common (org.grails.web.json). Removed: - PrettyPrintJSONWriter (use new JSONWriter(writer, new JsonMapperSupport(mapper), true)) - the JSON DateMarshaller, CalendarMarshaller, ToStringBeanMarshaller and ByteArrayMarshaller, which are no longer registered (XML ones stay) - the unused JavaCC parser in org.grails.web.json.parser and jsonparser.jj; JSON.parse and JSONObject parse with JSONTokener PathCapturingJSONWriterWrapper also passes flush() and append(Writable) on, and a JsonMapperValue is written by its own mapper. The 9.0 upgrade guide lists the removals and the JSONWriter changes.
|
Reviewed at head 1.
|
|
Following up on the custom-serializer gap above: this is 9.0.x, so the fix doesn't need to be bulletproofing `registerObjectMarshaller` against every nested custom Jackson serializer — that's a compatibility guarantee worth breaking on purpose here, not preserving by accident. The registered `JsonMapper` is the standard Spring pattern (that's what my repro used), and once an app hands Jackson a serializer, Jackson owns what's reachable from it — that's a reasonable 9.0 stance. It just needs to be a stated decision, not a silent one: a line in the 9.0 upgrade guide that a value nested inside a custom Jackson serializer renders as Jackson renders it, not through Grails marshallers, so apps relying on `registerObjectMarshaller` for something like redaction know to also handle it as a Jackson serializer if it can show up under a custom one. |
|
Proposal for the custom-serializer gap above: don't sandbox it, remove the reason it exists. Go fully through Spring Boot's own Jackson customization model instead of the hybrid "ask Jackson if it owns this type" approach, and `registerObjectMarshaller`/`rendersValue()`'s private-API dependency both disappear at the root. Verified against the real `spring-boot-jackson-4.1.1` sources (Boot 4 split this out of `spring-boot-autoconfigure`). `JacksonAutoConfiguration` already does this: @Bean
StandardJsonMapperBuilderCustomizer standardJsonMapperBuilderCustomizer(
ObjectProvider<JacksonModule> modules, AutowireCapableBeanFactory beanFactory) {
return new StandardJsonMapperBuilderCustomizer(this.jacksonProperties, modules.stream().toList(), beanFactory);
}Any `@Bean JacksonModule` in the app context is auto-collected and folded into the one `JsonMapper` Boot builds. Three standard, already-documented ways an app registers a custom marshaller once `ConvertersConfigurationInitializer` is consuming that same mapper (which it already does, via `getBeanProvider(JsonMapper.class).getIfUnique()`):
If `grails.converters.JSON` fully delegated instead of asking "does Jackson recognize this type," an app's custom marshaller becomes a plain `@Bean JacksonModule` or `@JacksonComponent` — no Grails API, and it composes correctly with everything else because there's exactly one serialization mechanism instead of two that have to interoperate. That removes the nested-custom-serializer bypass from above (nothing left to bypass) and the `_serializationContext()` dependency (no "does Jackson own this" question to answer — it owns everything). The part that doesn't fall out for free: `DomainClassMarshaller` — proxies, circular references, `includes`/`excludes`, `deep` associations. No Jackson equivalent exists for that today. Going fully Spring Boot means it has to become a `JacksonModule` Grails ships (GORM-aware serializers registered the same way), not stay a separate `ObjectMarshaller` SPI bolted on the side. That's a bigger scope than this PR currently takes on, but it's the architecturally coherent version of the same goal, and 9.0.x is the right place to make that call rather than carry the hybrid forward. |
Keep Jackson-backed converter rendering while retaining the upstream date-binding fixes and strict month validation. Reconcile date/time tests with the mapper output, share the upstream fixtures, and update the Grails 9 upgrade notes.
JsonMapperSupport called ObjectMapper._serializationContext(), which Jackson documents as public only for testing. The mapper's acceptJsonFormatVisitor passes the same context to the visitor before it visits a type, so take it from there. The application mapper's date format now also has a map key test.
A serializer that a Jackson module provides writes the values nested in its type through the mapper, so a Grails marshaller registered for a nested type does not apply there. State this in the 9.0 upgrade guide and the converters guide, and test it, along with a marshaller for the outer type taking precedence over the module serializer.
A serializer that a Jackson module provides wrote the values nested in its type through the mapper, so a marshaller registered for a nested type, such as one that redacts it, did not apply there, and a cycle through it was not detected. The converter now writes with a copy of the application's JsonMapper whose serializers offer each nested value back to the converter, which renders it as any other value unless it would render it with the mapper anyway. The application's own mapper is unchanged. JSONWriter.writeNested writes such a value and returns the writer to its state, and the PATH writer records the value's location in the mapper's output.
DomainClassSerializer renders a domain class instance as DomainClassMarshaller
did: the id, the version and class name when they are included, the persistent
properties, and associated instances as {"id": ...} references or, rendering
deep, in full. Grails registers it with the application's JsonMapper as the
domainClassJacksonModule bean, so writing a domain class with the mapper,
including returning one from a Spring MVC controller, renders it as
render ... as JSON does. grails.converters.json.domain.jackson.enabled: false
keeps Jackson's bean serialization.
DomainClassMarshaller and DeepDomainClassMarshaller hand the instance to the
serializer, with the converter's includes and excludes and their protected
hooks, and the converter renders the property values. They, and
ProxyUnwrappingMarshaller, are consulted before the mapper's value serializers.
A property annotated @JsonIgnore or write-only is no longer written. The
mapper also leaves out the properties @JsonIgnoreProperties names, as Jackson
does; the converter still renders those, because GORM adds that annotation,
naming the version and tenantId, to every domain class.
A to-many association whose value is a Map failed to render; it is now an
object of references.
|
The parser behaviors are very different between jackson / openjson / the vendored json. I'm ok supplementing it, but I'm not sold on replacing it wholesale. I've mentioned this before on #15958 - the behavior differences have real consequences. Can you make a case on why we should do this? The behaviors are very different and much more strict on jackson. |
|
I asked AI to summarize some of the differences to help this discussion, it's included below (it was done against the 7.x branch, but I think this comparison brings to light several of the problems): Grails JSON: OpenJSON versus JacksonJackson is the stronger long-term option, but neither replacement preserves Grails behavior automatically. OpenJSON is closer to the original API; Jackson offers more control over parsing, numeric precision, and resource limits. I compared the Grails The syntax differences are substantial. Grails deliberately accepts several nonstandard forms, documented in its API. Jackson rejects most by default, with optional features for selected extensions. Sources: Grails JSONObject API and Jackson JsonReadFeature API.
The trailing-content difference is a documented Jackson 3 default change: That exposes a weakness in the “OpenJSON is a compatible replacement” argument: it can both reject previously accepted input and silently change the resulting data. Numeric handling is probably the most consequential difference—even for valid JSON. These are results from the probes:
Grails uses a custom selection rule: integral numbers generally become Jackson can preserve decimal precision with OpenJSON’s parser instead tries The returned object model is a separate compatibility problem.
Grails’ collection interfaces are part of its public API. Replacing them affects casts, collection operations, nested access, and callers expecting the Grails classes—not merely imports. Sources: Grails JSONObject API and Grails JSONArray API. My assessment of the tradeoffs:
OpenJSON explicitly describes itself as an Android-derived clean-room implementation, rather than the same source Grails forked. Its inspected parser also has a default nesting limit of 100. Jackson provides configurable limits for nesting and number/string lengths through I would not assume a performance winner without measuring the actual Grails path. Jackson’s streaming advantage can disappear if the implementation constructs a Jackson tree and then copies it into Grails containers. My recommendation is Jackson behind a Grails compatibility facade, with explicit decisions about which legacy behaviors to retain. An alternative framing is to separate two proposals:
That separation makes the proposal much easier to evaluate. Switching parsers should not implicitly switch Grails databinding or JSON marshalling to Jackson POJO behavior. Before accepting either replacement, I’d require a differential test suite covering the examples above, nested containers, missing versus explicit null, numeric precision and scale, errors, and size/depth limits. OpenJSON needs that suite just as much as Jackson does; API resemblance is insufficient evidence of compatibility. |
|
Even AI recommends this, but acknowledges the scope of it. That's why I'm struggling to accept this. |
|
@jdaugherty I think it summarized best by do we want to support sloppy syntax or go the same direction Spring Boot already went. Open up your browser dev console, paste {"x":TRUE}hit enter |
|
If you can't articulate why we should change, I don't think we should make the change. The same argument could be made for groovy vs java - why don't we use semicolons everywhere? At a minimum, this change is large enough that it should be proposed in a plan and it should be voted on after discussion. I'm a -1 on this for now as a result. I'm not against supporting both, but removing the functionality will break several existing apps with no off ramp / transition. We need to discuss the differences and plan on how to transition. This PR doesn't do either and I don't think this is something we should do on a PR alone. |
|
@jdaugherty It's not a question of groovy vs java. It's a question of valid syntax. All your examples don't work inside a basic browser console. Why would you want to allow Of course I can articulate why we should change, I was just quickly commenting on your comparison. All I was mentioning was Spring Boot 4 went pure Jackson 3. If we are going to leverage and supplement Spring Boot, we probably shouldn't add inconsistencies that basically just introduce bugs downstream. Why would you ever send Why would you be -1 on something that
? You prefer unmaintained Grails json code written 10 years ago and all its "quirks" OR
var x = TRUEdoesn't work in Groovy, Java or Javascript |
|
Groovy PR for Jackson 2 -> Jackson 3 |
|
Like I said, I don't dispute it's probably the right direction, but I'm against telling our users: sorry, it's broken with no gradual migration path. Also, not everyone uses a Grails app with a browser only - https://github.com/openjson/openjson was built by the Android team and has all of the same syntax. This PR is proposing no migration window / timeline. It's just a hard cut off. For something so critical to the framework, that's a huge direction shift and it's not something I want to support. If we're going to do a gradual migration, I'm fine with it. Especially because people can opt into the change when they're ready. |
JSON.parse, and so request.JSON, still parse with Grails' JSONTokener. The spec pins what it accepts beyond strict JSON (single quotes, comments, unquoted values, uppercase literals, trailing commas, octal numbers, new Date(...)), the number types it returns, and its Map and List containers.
…, deprecated Removing them broke code that uses them with no transition: the grails-datastore-rest-client-legacy plugin's RestBuilder creates a ByteArrayMarshaller, and applications import JSONParser directly. They are restored as they were in Grails 8, deprecated for removal in Grails 10. The value marshallers and Grails 8's SimpleTypeMarshaller entries are no longer registered by default. PrettyPrintJSONWriter writes through the generator with a pretty printer that indents as Grails 8's PrettyPrintJSONWriter did, which a new protected JSONWriter constructor takes. JSONParser is not used by Grails; JSON.parse and request.JSON parse with JSONTokener.
A transition to the Jackson rendering, deprecated for removal in Grails 10. With grails.converters.json.legacy: true, the JSON converter registers the marshallers of Grails 8 instead of the JsonMapper value marshallers, indents pretty printed JSON as Grails 8 did, both when rendering with grails.converters.json.pretty.print and from toString(true), and the domain class Jackson module is not registered with the application's JsonMapper. A warning is logged on startup. Grails8JsonRenderingSpec compares the legacy output with text rendered by Grails 8.0.x (232bb34) for 111 values and both pretty print paths. Comparing that text with the Jackson rendering showed that Grails 8 already rendered records as an object of their components, so the upgrade guide no longer lists records as changed. The guide states that parsing is unchanged, and shows the per-type marshallers that keep a Grails 8 format.
|
@jdaugherty This PR doesn't change parsing.
For what the PR does change, which is writing:
So an app can move to 9 with one setting and drop it type by type before 10. If you'd still like this on dev@ before it merges, I'll start the thread. |
|
@borinquenkid Addressed:
|
… ids and legacy with the domain module
|
@matrei Thanks for re-checking, and for confirming the sort order. The five threads have replies. Your smaller items are done:
Verification: 3,772 tests, 0 failures, all run fresh, across the same 12 suites as before, including the full |
jdaugherty
left a comment
There was a problem hiding this comment.
Re-reviewed at 27a86d8: the nine commits from 1b3a980 to 5536958; the merges after them only bring in 9.0.x. The implementation asks are closed, and I've replied in each thread with what I checked. Beyond the threads:
DomainClassMarshaller.marshalObjectAsGrails8is the 8.0.xmarshalObjectbody line for line, apart from the documented change to a to-manyMapassociation (one object of its entries, identical text for one entry) and an unused local.- I ran
Grails8DomainRenderingon 8.0.x (232bb34) in a fresh checkout, in UTC: the 11 scenarios reproduce theGRAILS_8_DOMAINtext byte for byte. With that, the switch covers domain classes, map keys andtoString(false)as the guide says, and the spec holds against a mapper that indents, writes numbers as strings, uses another time zone and has a module serializer. - matrei's threads: the enum constant with a body,
Money extends Number, the cycle hook, the unwrapped type id and the reference naming behave as described.DomainClassJacksonModulecreates its serializers per mapper insetupModule, so the per-type name cache cannot carry one mapper's naming strategy into another; I probed two mappers sharing one module instance. grails-web-common(144) andgrails-converters(412) pass locally from a clean run, and CI is green at this head.
One cosmetic note that needs no change: on the mapper path id and version follow the naming strategy (Id, Version under UPPER_CAMEL_CASE) while the class property written with include.class stays class, as Jackson does not introspect it.
On the 9.x default I am -1 until it is inverted; see that thread.
| Grails marshallers still render domain classes, beans, enums, collections, maps and arrays, and now records (as an | ||
| object of their components) and `Optional` (as its value), so the values they contain are rendered as any other value | ||
| is. A marshaller that an application registers, with `JSON.registerObjectMarshaller(...)` or an | ||
| `ObjectMarshallerRegisterer`, still takes precedence over the mapper for the types it supports. |
There was a problem hiding this comment.
Checked #16237 at 5ce132c against this: respond() stays on the converter in 9 and grails.web.rendering.json.spring flips in 10, the marshaller trigger is gone, and registerObjectMarshaller is not deprecated there either, so the two layers line up as you describe. Its guide still says render value as JSON "uses the legacy converter" and calls the converter "legacy" throughout; after this PR that converter writes through the mapper, so that wording needs the update you mention in whichever merges second.
|
|
||
| Config grailsConfig = getGrailsConfig(); | ||
| ProxyHandler proxyHandler = getProxyHandler(); | ||
| boolean legacy = grailsConfig.getProperty(SETTING_CONVERTERS_JSON_LEGACY, Boolean.class, false); |
There was a problem hiding this comment.
I'm still asking for the inversion, and I'm -1 until the default changes.
The Grails 8 precedent doesn't carry here. 9.0 is being accelerated, with few milestones, so the new output gets little pre-release exposure before it ships, and this change is not being backported to 8.1.x, so there is no release in which an application can opt into the mapper-backed output before it becomes the default. The first release that has the new output is also the one that turns it on for everyone. For a part of the framework this central, where the text goes to clients outside the application, that is changing a default, not offering a migration.
So for 9.x: default to the Grails 8 marshallers and layout, let an application opt into the mapper with the setting, and flip the default in 10. The switch being complete and undeprecated is good, and the inversion doesn't lose any of that work.
…er as an opt-in grails.converters.json.legacy now defaults to true: the JSON converter renders JSON as Grails 8 did unless an application sets it to false to write through the JsonMapper, which Grails 10 makes the default. The setting stays undeprecated, and nothing is logged. - The initializer, ConvertersConfigurationHolder and the configuration metadata default to the Grails 8 rendering. - The specs of the JsonMapper path opt in, and Grails8JsonRenderingSpec checks that, without the setting, every value, both pretty print paths and the 11 domain class scenarios render as Grails 8.0.x rendered them. - Tests this change had edited only because the default had changed are back to their 9.0.x versions: JsonDateTimeRenderingSpec, the app1 and hibernate7 app1 date marshaller controllers and specs, and the comment in MonthBindingSpec. - The upgrade guide and the REST guide describe the JsonMapper as an opt-in that Grails 10 makes the default.
|
inversion done, although I agree with @matrei I think it would be better the other way since it is so easy to flip legacy behavior back on via |
There was a problem hiding this comment.
@matrei I am good with this not being the default for 9. This is the same approach we took with the hibernate 7 work. I realize this is different from many past decisions, but in this case we're wanting to do 9 in less than a month. I would be supportive of flipping this default in 9.1 as part of the Spring 4.2 upgrade, but I do not think we should flip this with so little milestones / rc's given it's size. If you agree, I'm good to merge this.
matrei
left a comment
There was a problem hiding this comment.
Re-reviewed at 8ce0e14: the four commits since my last round (26f1dda, 4a20e8d, 5536958 and 6e9b680). The merges after them only bring in 9.0.x, and the one that touches upgrading90x.adoc only changes section 1.
The fixes in my threads hold as described: an enum constant with a body renders by name on the mapper path and as a bean under legacy, a Number with a module serializer reaches the mapper, the deep cycle goes through writeReference, the unwrapped type id fails as Jackson's own unwrapping does, and association and cycle references use the same names. I'll resolve those threads. grails-web-common (144) and grails-converters (409) pass locally on this head, run fresh.
I can live with the inversion. With the switch now complete, either default is a safe one to ship, and the comments below come from it. Most of the text was written when the mapper was the default, and a few places still describe it that way.
1. The REST guide's date and time section describes legacy: false as the default. defaultRenderers.adoc:172-210 starts with "With the default mapper configuration" and then gives the mapper's output: OffsetTime as "03:00-03:00", Month as a number, and "Map keys use the mapper's key serializers". With the 9 default, a Month renders "SEPTEMBER", an OffsetTime keeps its seconds, and map keys are formatted as in Grails 8. The paragraphs this PR removed there were the ones that describe the 9 default. Either restore them and add a legacy: false note, or open the section with the setting.
2. Two paragraphs in the upgrade guide read as if they apply whatever the setting. At upgrading90x.adoc:170 ("Map keys use the mapper's key serializers…") and :175 ("JSON#toString(false) does not indent…"), both behaviours apply only with legacy: false. Under the default, keys are formatted as in Grails 8, and toString(false) indents when pretty.print is true (Grails8JsonRenderingSpec pins that). Each needs a "With the setting false" like the table above it.
3. With the PATH circular reference behaviour, the default no longer matches Grails 8's text. The PathCapturingJSONWriterWrapper fix applies whatever the setting, so the default path writes different references than 8.0.x. In this example a cycle follows a number property, and the probe ran with no legacy setting:
class Node { int n; Node other }
def child = new Node(n: 2); child.other = child
new JSON([x: new Node(n: 1, other: child)]).toString()
new JSON([a: 5, x: [7, child]]).toString()| 8.0.x (60f75ee) | This head | |
|---|---|---|
| after a number property | "ref":"root.x.n" |
"ref":"root.x.other" |
| after a number in the map and in the list | "ref":"root.a.x" |
"ref":"root.x[1]" |
The new paths are the right ones, and I wouldn't keep the old ones. But the legacy section (upgrading90x.adoc:213) says the converter returns "the same text as in Grails 8", apart from what Grails 8 failed to render or rendered as invalid JSON, and a wrong path is neither. Please list it there with the other exceptions, or say that the PATH paragraph at :204 applies whatever the setting. Grails8JsonRenderingSpec has no PATH case, which is why nothing caught it.
4. Nothing runs the opt-in end to end any more. Before 6e9b680, the app1 and hibernate7/app1 functional suites ran the mapper path, because it was the default. With those edits reverted, legacy: false is covered only by the unit specs, which use a JsonMapper they build themselves. None of them uses the one Boot auto-configures from spring.jackson.* with real Hibernate proxies. A small functional spec in one of the apps that opts in would catch an integration break, for example @SpringBootTest(properties = ['grails.converters.json.legacy=false', 'spring.jackson.time-zone=America/New_York']) rendering a domain instance with a lazy association and a date.
Smaller items:
- The guide, the initializer javadoc, the metadata description and the nine marshaller javadocs all say that Grails 10 makes
falsethe default (15 places). jdaugherty's approval suggests flipping it in 9.1, with the Spring 4.2 upgrade. Whichever release it is, it would be easier to settle it before merge, or to word it as "a later release" for now, so the javadocs don't go out naming the wrong version.
|
One more thing I noticed after posting: nothing at runtime tells an application that its JSON output will change in Grails 10. With Could the initializer log a WARN once at startup, only when the property is not set? For example:
This isn't blocking for me, but it's cheap and it's the only way most users will find out before 10. |
… in the guides While grails.converters.json.legacy is not set, the initializer logs one warning, once per JVM, that a later release renders JSON through the JsonMapper by default, and how to keep or switch the rendering. - ConvertersConfigurationInitializerSpec checks that the unset setting warns once, and that true and false do not. - The REST guide's date and time section describes the default again, with a paragraph for the setting false. - The upgrade guide's map key and toString(false) paragraphs apply to the setting false, and the PATH reference fix, which applies whatever the setting, is listed with the exceptions to the Grails 8 text. Grails8JsonRenderingSpec pins the corrected paths. - hibernate5/grails-hibernate opts in, with spring.jackson.time-zone, and JsonMapperConverterSpec renders a domain class instance whose lazy association is a Hibernate proxy. - The guides, the javadoc and the configuration metadata say that a later release, rather than Grails 10, makes false the default.
|
@matrei thanks, all addressed in 2ec2017, including the startup warning. 1. REST guide dates. The section is back to the 9.0.x text, which describes the default: 2. Upgrade guide. The map key and 3. 4. End to end. I checked that the carrier is still an uninitialized 5. Version. All 15 places now say "a later release" instead of Grails 10. Startup warning. Done as you suggested. While
Tests run on this head:
|
This comment has been minimized.
This comment has been minimized.
matrei
left a comment
There was a problem hiding this comment.
Re-reviewed at 2ec2017, the only commit since 8ce0e14.
Everything from my last round is addressed as described:
- The REST guide's date and time section is back to the 9.0.x text, apart from the
javascriptsentence, which is now in the general rendering section. Thelegacy: falseparagraph after it is accurate. - The map key and
toString(false)paragraphs in the upgrade guide now say they apply with the settingfalse. - The
PATHparagraph applies whatever the setting, and the legacy section lists it with the other exceptions. The newGrails8JsonRenderingSpecfeature pins both of my examples. JsonMapperConverterSpecruns the opt-in end to end through the mapper that Boot auto-configures. I checked that no other spec inhibernate5/grails-hibernaterenders JSON, so opting the whole app in doesn't change their coverage. Your reason for not using app1, the staticConvertersConfigurationHolder, makes sense.- The 15 places now say "a later release".
The startup warning is what I had in mind: it fires only when the property is unset, once per JVM, and the upgrade guide mentions it.
On this head, run fresh: grails-converters (413), grails-web-common (144) and JsonMapperConverterSpec all pass. I also confirmed the claim that the carrier stays an uninitialized proxy. A probe spec loaded the Shipment in a new session, rendered it as JSON, and called Hibernate.isInitialized on the Carrier$HibernateProxy before and after rendering: false both times.
One optional suggestion: JsonMapperConverterSpec checks only the response body, and {"carrier":{"id":1}} would look the same if rendering had initialized the proxy. If you want the spec to pin the laziness too, an assertion like the one in my probe would do it:
Shipment.withNewSession {
def shipment = Shipment.get(shipmentId)
(shipment as JSON).toString()
assert !Hibernate.isInitialized(shipment.@carrier)
}That's not blocking. LGTM.
The response body is the same whether or not rendering initializes the carrier's proxy, so JsonMapperConverterSpec now also loads the shipment in a new session, renders it as JSON, and checks that the carrier is an uninitialized HibernateProxy before and after rendering.
|
@matrei thanks, good catch. The body alone can't show the laziness, so I added it in 1d3bf77.
To check that the assertion can fail, I made The branch also has a merge of the latest 9.0.x (4e69821). Its only conflict was the upgrade guide: the new invokedynamic section is §2, so the |
Since #16414 the writer hands its output to a Jackson generator: a bare top-level value is a complete JSON text rather than a sequence error, the generator writes the separators itself so comma() has nothing to add, and an output failure surfaces when the buffer is flushed instead of on the call that produced it. JSONWriterSpec covers each of those, so the 7.x features that pinned the synchronous failures and the top-level sequence error are dropped and the raw append coverage asserts the no-op comma.
Description
grails.converters.JSONcan write JSON through the application's JacksonJsonMapper, the one Spring Boot auto-configures, and render domain classes with a Jackson serializer, which an application can also register with that mapper. With it, a Grails application and a Spring Boot application return the same JSON for the same values, andspring.jackson.*applies torender ... as JSON.In Grails 9 this is an opt-in:
grails.converters.json.legacydefaults totrue, and an application turns the mapper on withlegacy: false. A later release makesfalsethe default. With #16237,respond()also stays on the converter in 9, so in 9 an application's JSON stays as Grails 8 rendered it unless it opts in, apart from what Grails 8 failed to render or rendered as invalid JSON (a single value,NaN) and thePATHreferences Grails 8 got wrong, and both layers can flip together later.Parsing does not change:
JSON.parse,request.JSONandnew JSONObject(String)still parse with Grails'JSONTokener, with the same lenient syntax, number types andMap/Listcontainers.By default,
grails.converters.json.legacy: truerenders the text Grails 8 rendered, including domain classes and map keys, whatever the application's Jackson settings, except where Grails 8 failed or wrote invalid JSON. The setting is not deprecated. While it is not set, Grails logs one warning at startup that a later release changes the default, and setting either value turns it off.Usage
With the mapper turned on, Jackson configuration applies to the converter:
With
grails.converters.json.domain.jackson.enabled: true, the application'sJsonMapperrenders a domain class as the converter does, sojsonMapper.writeValueAsString(book), or a Spring MVC controller returningbook, writes the following. By default, the mapper renders domain classes as Jackson beans, as in Grails 8.{"id":1,"title":"Grails","publisher":{"id":3},"authors":[{"id":1},{"id":2}]}A Jackson module registered with the application (a
JacksonModulebean or a@JacksonComponent) renders its types inrender ... as JSON. The values its serializer writes are rendered by the converter, so registered marshallers still apply to them. A marshaller registered withJSON.registerObjectMarshalleror anObjectMarshallerRegistererstill takes precedence over the mapper:With the mapper turned on, to keep the Grails 8 format of a few types only:
Output with
legacy: false, compared with Grails 8Grails 8's output is also the 9.x default. Measured against text rendered by Grails 8.0.x for the same values. They are listed in the new 9.0 upgrade guide.
OffsetTime"03:00:00-03:00","03:00:00.5+05:30""03:00-03:00","03:00:00.500+05:30"Month"SEPTEMBER"9(binds back)Locale"zh_TW_#Hant""zh-Hant-TW"char{}"c"byte[][1,2,3]"AQID"UUID,URI,@JsonValuetypes other than enumsPattern,Charset,InetAddressFile,Path,ByteBuffer"/tmp/a.txt","file:///tmp/a.txt","AQID"char[][{},{}]"ab"JsonNodeClassmap key"class java.lang.String""java.lang.String"Optional{"empty":false,"present":true}nullNaNNaN(invalid JSON)"NaN"toString(false)withpretty.print: truerender Role.HEAD as JSON)ConverterException"HEAD"@JsonIgnoreor write-onlyDates, times, records, beans, collections, maps and domain classes through the converter render as before. Enums still render by
name(), and strings are still escaped for an HTML<script>element (</as<\u002f).Design
JSONWriterwrites through a JacksonJsonGenerator, and keeps its public methods and state machine, so existing marshallers andPathCapturingJSONWriterWrapperwork unchanged.JsonMapperValueMarshallerrenders a value with the mapper when the mapper has a dedicated serializer for a single value of its type (dates and times,Month,UUID,Locale,byte[],@JsonValuetypes other than enums, module types). Containers, beans, enums and domain classes stay with Grails marshallers.JSONWriter.writeNestedwrites such a value and returns the writer to its state. The application's own mapper is not changed.DomainClassSerializer(inorg.grails.web.converters.jackson) renders an instance asDomainClassMarshallerdid: id, version and class name when included, persistent properties, and associations as{"id":…}references or, deep, in full, with proxies unwrapped and references to proxies written from their identifier. Withgrails.converters.json.domain.jackson.enabled: true,DomainClassJacksonModuleis registered as thedomainClassJacksonModulebean, which Spring Boot adds to itsJsonMapper. On the mapper the serializer also applies the mapper's naming strategy,@JsonNamingand@JsonPropertynames, writes@JsonTypeInfotype ids and unwraps@JsonUnwrappedproperties. It leaves a domain class with a@JsonValueorJacksonSerializable, and a subclass that is neither a domain class nor a GORM or Hibernate proxy class, to Jackson.DomainClassMarshallerandDeepDomainClassMarshallerhand instances to the serializer with the converter's includes and excludes, and their protected methods (includesProperty,excludesProperty,extractValue,asShortObject,isRenderDomainClassRelations) still apply.RecordMarshallerandOptionalMarshallerrender a record as an object of its components (Jackson annotations on the components are not applied) andOptionalas its value, with their contents rendered by the converter.HtmlSafeJsonWriterkeeps Grails'</, U+2028 and U+2029 escaping for everything the generator writes.DomainClassMarshallerruns its Grails 8 body.grails.converters.json.pretty.printand fromtoString(true)andtoString(false).JsonMapper, so the application's Jackson settings and modules don't change the text.Grails8JsonRenderingSpeccompares it with Grails 8.0.x output (232bb34) for 111 values, both pretty print paths and 11 domain class scenarios, both with a default mapper and with one that indents, writes numbers as strings, uses another time zone and has a module serializer.hibernate5/grails-hibernateopts in, withspring.jackson.time-zone, andJsonMapperConverterSpecrenders a domain class instance whose lazy association is a Hibernate proxy, through theJsonMapperSpring Boot auto-configures, and checks that the proxy is still uninitialized after rendering.Deprecated
Deprecated without a removal release, and still usable:
DateMarshaller,CalendarMarshaller,ToStringBeanMarshaller,ByteArrayMarshaller,InstantMarshaller,LocalDateMarshaller,LocalDateTimeMarshaller,OffsetDateTimeMarshallerandZonedDateTimeMarshaller.PrettyPrintJSONWriter, which still indents as Grails 8 did.org.grails.web.json.parser.JSONParser, which Grails does not use.Unchanged
JSON.parse,request.JSONandJSONObject/JSONArray(JsonParsingSpecpins their syntax, number types and containers), JSON views, the XML converter and the HAL renderer.Fixed
PathCapturingJSONWriterWrapper(circular.reference.behaviour: PATH) threw on an object with anullproperty, gave wrong paths after a number property, and failed on a single value.Mapfailed to render; it renders as an object of references.JsonMapperSupportno longer uses Jackson's test-onlyObjectMapper._serializationContext().Performance
render list as JSONfor 3,000 domain instances (528 KB of JSON). Each figure is the median of 60 renders after 40 warm-up renders, in separate JVMs:legacy: falseLimitations
@JsonSerialize(using = …)orconverteris written by that serializer even inside a module serializer, because Jackson builds class-annotated serializers without serializer modifiers. A Grails marshaller registered for such a type applies everywhere else.@JsonIgnorePropertiesapplies to the application'sJsonMapperonly, with the domain module enabled. GORM adds one to every domain class naming the version andtenantId, so the converter keeps rendering those as before.