codeconsole opened a new pull request, #16414:
URL: https://github.com/apache/grails-core/pull/16414
## Description
`grails.converters.JSON` now writes JSON through 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 (`time-zone`, `date-format`,
serialization features, …) apply to `render ... as JSON`.
This is the "use Spring Boot directly" counterpart to #16411:
- On 8.0.x, #16411 makes the converters match Spring Boot by imitating
Jackson with a formatter plus about 20 per-type marshallers.
- Here the converters ask Boot's mapper instead, so date, time and other
value formats match by construction, and follow the application's Jackson
configuration and modules.
### Design
The `ObjectMarshaller<JSON>` SPI that applications and plugins use is kept.
Only the engine underneath changes.
- **`JacksonJSONWriter`** is a `JSONWriter` that writes to a Jackson
`JsonGenerator`.
- It mirrors `JSONWriter`'s state machine (mode, nesting, comma), so
existing marshallers and `PathCapturingJSONWriterWrapper` work unchanged, and
the same calls are valid or invalid.
- `value(Object)` writes exactly what `JSONWriter` wrote for a value: a
`JSONElement` raw, and any other object as its quoted, JSON-encoded
`toString()`. So a marshaller that writes an id straight to the writer, as
`DomainClassMarshaller.asShortObject` does for a MongoDB `ObjectId`, renders as
before.
- Jackson failures surface as `JSONException`, and so as
`ConverterException`.
- **`JsonMapperValueMarshaller`** renders a value when the mapper has a
dedicated serializer for a single value of its type: dates and times, `Month`,
`UUID`, `Locale`, `byte[]`, `@JsonValue` types, and Jackson module types.
- Bean, enum, reference-type (`Optional`), container and unsupported-type
serializers are excluded, and so are iterables, maps, map entries, streams and
object arrays. Anything that contains other values therefore stays with Grails
marshallers, so nested domain objects, registered marshallers and
circular-reference handling still apply inside them.
- It is a default marshaller ahead of the others, and behind anything an
application registers (`JSON.registerObjectMarshaller`,
`ObjectMarshallerRegisterer`), which still takes precedence.
- **`RecordMarshaller` and `OptionalMarshaller`** render records (as an
object of their components) and `Optional` (as its value) the way Spring Boot
does, with their contents rendered by the converter.
- **`HtmlSafeJsonWriter`** keeps Grails' escaping for JSON embedded in an
HTML `<script>`: `</` becomes `</`, and U+2028/U+2029 become `
`/`
`. It
filters the output stream, which is safe because those sequences can only
appear inside JSON strings. That means every string is covered, including keys
and values the mapper writes.
- **`JsonMapperSupport`** wraps the mapper.
- It caches, per type, whether the mapper renders it, in a `ClassValue`.
Introspection failures leave the type to Grails.
- It writes values through the generator's own context (`writePOJO`, so
there is no root wrapping and no per-value flush).
- It formats map keys with the mapper's key serializers, in
`MapMarshaller`, `DomainClassMarshaller` map associations and the `JSON`
builder.
- **`ConvertersConfigurationInitializer`** takes the application's
`JsonMapper` with `getIfUnique()`. With no mapper, or several and none primary,
it uses a default `JsonMapper`.
### Kept from Grails 8, on purpose
- **Enums render by `name()`,** as §8 of the 8.0 upgrade guide decided,
where Jackson 3 writes `toString()`. Rendering `toString()` would silently
break binding the JSON back: `SimpleDataBinder` binds by `valueOf`. `Month` is
the exception, because Jackson has a dedicated serializer for it. It renders as
its number, and a `Month` binding converter (the same change as #16411) binds
it back instead of Spring's by-ordinal conversion.
- **HTML-safe string escaping,** as above. The parsed values are identical
to Spring Boot's.
- **Unchanged:** domain class, bean, collection and map rendering,
circular-reference behaviours, includes/excludes, `JSON.use('deep')`,
`JSON.parse`/`request.JSON`, JSON views, the XML converter and the HAL renderer.
### Removed
- `InstantMarshaller`, `LocalDateMarshaller`, `LocalDateTimeMarshaller`,
`OffsetDateTimeMarshaller` and `ZonedDateTimeMarshaller`.
- The default registration of `DateMarshaller`, `CalendarMarshaller`,
`ToStringBeanMarshaller` and `ByteArrayMarshaller`. These classes remain for
applications that register them.
### Output changes from Grails 8
These are all listed in the new 9.0 upgrade guide:
| Value | Grails 8 | Grails 9 |
|---|---|---|
| whole-second `Date`/`Calendar`/`Timestamp` | `…:00Z` | `…:00.000Z` |
| `Timestamp` nanos | `.407254Z` | `.407Z` |
| `java.sql.Date`/`Time` | failure | UTC instant / `"01:48:46"` |
| `LocalTime`, `OffsetTime`, `YearMonth`, `MonthDay`, `Duration`, `Period`,
`ZoneId`, `TimeZone`, `XMLGregorianCalendar` | bean objects or failure | ISO/ID
strings |
| `Year`, `Month` | bean object, `"SEPTEMBER"` | `2026`, `9` |
| `Date`/`Calendar`/`ZonedDateTime` map keys | `toString()` | mapper format |
| `Locale` | `"zh_TW_#Hant"` | `"zh-Hant-TW"` |
| `char` | `{}` | `"c"` |
| `byte[]` | `[1,2,3]` | `"AQID"` |
| `UUID`, `URI`, `@JsonValue` types | bean objects | values |
| records | `{}` | `{"x":1,"y":2}` |
| `Optional` | `{"empty":false,"present":true}` | value or `null` |
| `NaN` | `NaN` (invalid JSON) | `"NaN"` |
| pretty printing | Grails layout | Jackson default pretty printer |
| a single value (`render Role.HEAD as JSON`) | `ConverterException` |
`"HEAD"` |
The date rows describe 9.0.x as it is today, which still has 8.0.0-RC1's
marshallers. Once #16411 is released in 8.0.0 and merged forward, those rows
become "unchanged". The guide should be trimmed at that merge, and
`grails-converters` should keep this branch's version.
### Found along the way
`PathCapturingJSONWriterWrapper` (`circular.reference.behaviour: PATH`) had
three bugs. Nothing tested the circular-reference behaviours before; the new
spec covers all five.
- It didn't override `valueNull()`: any object with a null property threw
"Value out of sequence".
- It didn't override `value(Number)`: references after a number property got
wrong paths.
- It popped an empty path stack for a single value.
### Verification
- **Oracle tests.** `JsonMapperRenderingSpec` compares `new JSON([value:
v]).toString()` with `JsonMapper.writeValueAsString` for 108 values:
- the 55 date/time values (including BC and 5-digit years) and 15
date-keyed maps from #16411;
- numbers, `NaN`, `char`, `byte[]`/`int[]`, `UUID`, `URL`/`URI`, `Locale`,
`Currency`, `Class`, `Optional`, records, `@JsonValue` types, nested
collections and maps, and non-`String` keys.
- **Mapper equivalence.** I also checked that Spring Boot 4.1.1's
auto-configured mapper gives the same output as `JsonMapper.builder().build()`
for the date/time values and the scalar values.
- **Other behaviours covered:**
- HTML-safe escaping in keys, values and mapper-written values;
- enum names and `Month`;
- pretty printing;
- a `JsonMapper` bean from the application context
(`spring.jackson.time-zone` applied), and two beans with none primary;
- `JSON.registerObjectMarshaller` precedence, including inside records and
`Optional`;
- `ObjectMarshallerRegisterer` precedence;
- beans and `Map.Entry`;
- a marshaller writing an object straight to the writer;
- a Jackson failure becoming `ConverterException`;
- the `javascript` date format and the `JSON` builder;
- every circular-reference behaviour, including a `PATH` reference below
the root after a number and a null;
- single values in PATH mode.
- **`JacksonJSONWriterSpec`:** the writer rejects the same calls as
`JSONWriter`, `PathCapturingJSONWriterWrapper` tracks paths over it, a
`JsonMapperValue` written by a plain `JSONWriter` is raw JSON, and generator
failures become `JSONException`.
- **`HtmlSafeJsonWriterSpec`:** includes `</` split across two writes.
- **`MonthBindingSpec`** and `Jsr310ConvertersConfigurationSpec`: `Month`
binding.
- **Test runs:** 3,253 tests, 0 failures, in:
- `grails-web-common` (92), `grails-converters` (231),
`grails-databinding` (34), `grails-web-databinding` (61),
`grails-rest-transforms` (47), `grails-controllers` (89), `grails-views-gson`
(185);
- `grails-test-suite-web` (435), `grails-test-suite-uber` (576),
`grails-test-suite-persistence` (105);
- the full `app1` (709) and `hibernate7/app1` (689) functional suites.
- **Code style:** `codeStyle` passes for `grails-converters`,
`grails-web-common` and `grails-databinding`.
- **Parity with Spring Boot's own output:** 132 of 136 probed values render
byte-identically to Spring Boot 4.1.1's output. The 4 differences are the
deliberate enum-name and HTML-escaping cases.
- **Existing tests changed:**
- `JSONDateTimeMarshallingSpec` and the two functional
`DateMarshallerSpec`s now expect whole-second dates to keep `.000`.
- `JSONConverterTests.testJSONEnumConverting`: a single enum now renders
instead of throwing.
- `JSONConverterTests.testMoreStringsWithQuotes2`, which pins the `</`
escaping, is unchanged.
An independent review of the first version of this change led to:
- the HTML-safe escaping;
- keeping enum names;
- the `Month` binding converter;
- `getIfUnique()`;
- `value(Object)` compatibility;
- keeping containers of values with Grails;
- the exception wrapping;
- the PATH fixes.
## Contributor Checklist
### Issue and Scope
- [ ] This PR is linked to an existing issue that has been **acknowledged or
approved** by the project team. If no approved issue exists, please give
background on why this change is necessary. Tickets are preferred for release
change log history.
- [ ] This PR addresses the **complete scope** of the linked issue. Partial
implementations or unfinished work should not be submitted for review.
- [x] This PR contains a **single, focused change**. Unrelated changes
should be submitted as separate pull requests.
- [x] This PR targets the **correct branch** for the type of change
### Code Quality
- [x] I have **added or updated tests** that cover the changes introduced in
this PR. All code contributions are expected to include appropriate test
coverage.
- [ ] I have verified that all existing tests pass by running `./gradlew
build --rerun-tasks`.
- [x] My code follows the project's **code style** guidelines. I have run
`./gradlew codeStyle` and resolved any violations.
- [x] This PR does **not** include mass reformatting, style-only changes, or
large-scale refactoring unless it was **explicitly approved** in the linked
issue.
- [ ] If generative AI tooling was used in preparing this contribution, a
quality model was used to ensure contributions are **consistent with the
project's quality standards**.
### Licensing and Attribution
- [x] All contributed code is provided under the Apache License 2.0, and new
source files include the appropriate **Apache license header**.
- [ ] I have the necessary rights to submit this contribution and confirm it
is my own original work.
- [ ] If generative AI tooling was used in preparing this contribution, I
have followed the Apache Software Foundation's policy on generative tooling and
have properly attributed its use.
### Documentation
- [x] If this PR introduces user-facing changes, I have included or updated
the relevant documentation.
- [ ] If this PR adds a new feature, I have updated the **What's New**
section of the Grails Guide.
- [x] If this PR introduces breaking changes or changes that require user
action during an upgrade, I have updated the **Upgrade Notes** for the
corresponding version in the Grails Guide.
- [x] The PR description clearly explains **what** was changed and **why**.
--
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]