diff --git a/grails-converters/src/main/groovy/org/grails/web/converters/configuration/ConvertersConfigurationInitializer.java b/grails-converters/src/main/groovy/org/grails/web/converters/configuration/ConvertersConfigurationInitializer.java index a6c0051fb73..e5938ba3ea5 100644 --- a/grails-converters/src/main/groovy/org/grails/web/converters/configuration/ConvertersConfigurationInitializer.java +++ b/grails-converters/src/main/groovy/org/grails/web/converters/configuration/ConvertersConfigurationInitializer.java @@ -112,6 +112,12 @@ private void initJSONConfiguration() { } marshallers.add(new org.grails.web.converters.marshaller.json.DateMarshaller()); } + marshallers.add(new org.grails.web.converters.marshaller.json.CalendarMarshaller()); + marshallers.add(new org.grails.web.converters.marshaller.json.InstantMarshaller()); + marshallers.add(new org.grails.web.converters.marshaller.json.LocalDateMarshaller()); + marshallers.add(new org.grails.web.converters.marshaller.json.LocalDateTimeMarshaller()); + marshallers.add(new org.grails.web.converters.marshaller.json.OffsetDateTimeMarshaller()); + marshallers.add(new org.grails.web.converters.marshaller.json.ZonedDateTimeMarshaller()); marshallers.add(new org.grails.web.converters.marshaller.json.ToStringBeanMarshaller()); boolean includeDomainVersion = includeDomainVersionProperty(grailsConfig, "json"); diff --git a/grails-converters/src/main/groovy/org/grails/web/converters/marshaller/json/CalendarMarshaller.java b/grails-converters/src/main/groovy/org/grails/web/converters/marshaller/json/CalendarMarshaller.java new file mode 100644 index 00000000000..62ba35f6f6f --- /dev/null +++ b/grails-converters/src/main/groovy/org/grails/web/converters/marshaller/json/CalendarMarshaller.java @@ -0,0 +1,70 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.grails.web.converters.marshaller.json; + +import java.text.Format; +import java.util.Calendar; +import java.util.Locale; +import java.util.TimeZone; + +import org.apache.commons.lang3.time.FastDateFormat; + +import grails.converters.JSON; +import org.grails.web.converters.exceptions.ConverterException; +import org.grails.web.converters.marshaller.ObjectMarshaller; +import org.grails.web.json.JSONException; + +/** + * JSON ObjectMarshaller which converts a Calendar Object to ISO-8601 format with Z suffix. + * + * @since 7.0 + */ +public class CalendarMarshaller implements ObjectMarshaller { + + private final Format formatter; + + /** + * Constructor with a custom formatter. + * @param formatter the formatter + */ + public CalendarMarshaller(Format formatter) { + this.formatter = formatter; + } + + /** + * Default constructor. + */ + public CalendarMarshaller() { + this(FastDateFormat.getInstance("yyyy-MM-dd'T'HH:mm:ss.SSS'Z'", TimeZone.getTimeZone("GMT"), Locale.US)); + } + + public boolean supports(Object object) { + return object instanceof Calendar; + } + + public void marshalObject(Object object, JSON converter) throws ConverterException { + try { + Calendar calendar = (Calendar) object; + converter.getWriter().value(formatter.format(calendar.getTime())); + } + catch (JSONException e) { + throw new ConverterException(e); + } + } +} diff --git a/grails-converters/src/main/groovy/org/grails/web/converters/marshaller/json/DateMarshaller.java b/grails-converters/src/main/groovy/org/grails/web/converters/marshaller/json/DateMarshaller.java index beedb48a017..b32e4d859b7 100644 --- a/grails-converters/src/main/groovy/org/grails/web/converters/marshaller/json/DateMarshaller.java +++ b/grails-converters/src/main/groovy/org/grails/web/converters/marshaller/json/DateMarshaller.java @@ -53,7 +53,7 @@ public DateMarshaller(Format formatter) { * Default constructor. */ public DateMarshaller() { - this(FastDateFormat.getInstance("yyyy-MM-dd'T'HH:mm:ss'Z'", TimeZone.getTimeZone("GMT"), Locale.US)); + this(FastDateFormat.getInstance("yyyy-MM-dd'T'HH:mm:ss.SSS'Z'", TimeZone.getTimeZone("GMT"), Locale.US)); } public boolean supports(Object object) { diff --git a/grails-converters/src/main/groovy/org/grails/web/converters/marshaller/json/InstantMarshaller.java b/grails-converters/src/main/groovy/org/grails/web/converters/marshaller/json/InstantMarshaller.java new file mode 100644 index 00000000000..15b2e7ef90c --- /dev/null +++ b/grails-converters/src/main/groovy/org/grails/web/converters/marshaller/json/InstantMarshaller.java @@ -0,0 +1,49 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.grails.web.converters.marshaller.json; + +import java.time.Instant; +import java.time.format.DateTimeFormatter; + +import grails.converters.JSON; +import org.grails.web.converters.exceptions.ConverterException; +import org.grails.web.converters.marshaller.ObjectMarshaller; +import org.grails.web.json.JSONException; + +/** + * JSON ObjectMarshaller which converts an Instant to ISO-8601 format with Z suffix. + * + * @since 7.0 + */ +public class InstantMarshaller implements ObjectMarshaller { + + public boolean supports(Object object) { + return object instanceof Instant; + } + + public void marshalObject(Object object, JSON converter) throws ConverterException { + try { + Instant instant = (Instant) object; + converter.getWriter().value(DateTimeFormatter.ISO_INSTANT.format(instant)); + } + catch (JSONException e) { + throw new ConverterException(e); + } + } +} diff --git a/grails-converters/src/main/groovy/org/grails/web/converters/marshaller/json/LocalDateMarshaller.java b/grails-converters/src/main/groovy/org/grails/web/converters/marshaller/json/LocalDateMarshaller.java new file mode 100644 index 00000000000..5e669f02bdb --- /dev/null +++ b/grails-converters/src/main/groovy/org/grails/web/converters/marshaller/json/LocalDateMarshaller.java @@ -0,0 +1,49 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.grails.web.converters.marshaller.json; + +import java.time.LocalDate; +import java.time.format.DateTimeFormatter; + +import grails.converters.JSON; +import org.grails.web.converters.exceptions.ConverterException; +import org.grails.web.converters.marshaller.ObjectMarshaller; +import org.grails.web.json.JSONException; + +/** + * JSON ObjectMarshaller which converts a LocalDate to ISO-8601 date format (YYYY-MM-DD). + * + * @since 7.0 + */ +public class LocalDateMarshaller implements ObjectMarshaller { + + public boolean supports(Object object) { + return object instanceof LocalDate; + } + + public void marshalObject(Object object, JSON converter) throws ConverterException { + try { + LocalDate localDate = (LocalDate) object; + converter.getWriter().value(DateTimeFormatter.ISO_LOCAL_DATE.format(localDate)); + } + catch (JSONException e) { + throw new ConverterException(e); + } + } +} diff --git a/grails-converters/src/main/groovy/org/grails/web/converters/marshaller/json/LocalDateTimeMarshaller.java b/grails-converters/src/main/groovy/org/grails/web/converters/marshaller/json/LocalDateTimeMarshaller.java new file mode 100644 index 00000000000..e03d8bdcdd2 --- /dev/null +++ b/grails-converters/src/main/groovy/org/grails/web/converters/marshaller/json/LocalDateTimeMarshaller.java @@ -0,0 +1,49 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.grails.web.converters.marshaller.json; + +import java.time.LocalDateTime; +import java.time.format.DateTimeFormatter; + +import grails.converters.JSON; +import org.grails.web.converters.exceptions.ConverterException; +import org.grails.web.converters.marshaller.ObjectMarshaller; +import org.grails.web.json.JSONException; + +/** + * JSON ObjectMarshaller which converts a LocalDateTime to ISO-8601 format (without timezone). + * + * @since 7.0 + */ +public class LocalDateTimeMarshaller implements ObjectMarshaller { + + public boolean supports(Object object) { + return object instanceof LocalDateTime; + } + + public void marshalObject(Object object, JSON converter) throws ConverterException { + try { + LocalDateTime localDateTime = (LocalDateTime) object; + converter.getWriter().value(DateTimeFormatter.ISO_LOCAL_DATE_TIME.format(localDateTime)); + } + catch (JSONException e) { + throw new ConverterException(e); + } + } +} diff --git a/grails-converters/src/main/groovy/org/grails/web/converters/marshaller/json/OffsetDateTimeMarshaller.java b/grails-converters/src/main/groovy/org/grails/web/converters/marshaller/json/OffsetDateTimeMarshaller.java new file mode 100644 index 00000000000..83c84d680aa --- /dev/null +++ b/grails-converters/src/main/groovy/org/grails/web/converters/marshaller/json/OffsetDateTimeMarshaller.java @@ -0,0 +1,49 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.grails.web.converters.marshaller.json; + +import java.time.OffsetDateTime; +import java.time.format.DateTimeFormatter; + +import grails.converters.JSON; +import org.grails.web.converters.exceptions.ConverterException; +import org.grails.web.converters.marshaller.ObjectMarshaller; +import org.grails.web.json.JSONException; + +/** + * JSON ObjectMarshaller which converts an OffsetDateTime to ISO-8601 format with timezone offset. + * + * @since 7.0 + */ +public class OffsetDateTimeMarshaller implements ObjectMarshaller { + + public boolean supports(Object object) { + return object instanceof OffsetDateTime; + } + + public void marshalObject(Object object, JSON converter) throws ConverterException { + try { + OffsetDateTime offsetDateTime = (OffsetDateTime) object; + converter.getWriter().value(DateTimeFormatter.ISO_OFFSET_DATE_TIME.format(offsetDateTime)); + } + catch (JSONException e) { + throw new ConverterException(e); + } + } +} diff --git a/grails-converters/src/main/groovy/org/grails/web/converters/marshaller/json/ZonedDateTimeMarshaller.java b/grails-converters/src/main/groovy/org/grails/web/converters/marshaller/json/ZonedDateTimeMarshaller.java new file mode 100644 index 00000000000..d65b420105f --- /dev/null +++ b/grails-converters/src/main/groovy/org/grails/web/converters/marshaller/json/ZonedDateTimeMarshaller.java @@ -0,0 +1,49 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.grails.web.converters.marshaller.json; + +import java.time.ZonedDateTime; +import java.time.format.DateTimeFormatter; + +import grails.converters.JSON; +import org.grails.web.converters.exceptions.ConverterException; +import org.grails.web.converters.marshaller.ObjectMarshaller; +import org.grails.web.json.JSONException; + +/** + * JSON ObjectMarshaller which converts a ZonedDateTime to ISO-8601 format with timezone offset. + * + * @since 7.0 + */ +public class ZonedDateTimeMarshaller implements ObjectMarshaller { + + public boolean supports(Object object) { + return object instanceof ZonedDateTime; + } + + public void marshalObject(Object object, JSON converter) throws ConverterException { + try { + ZonedDateTime zonedDateTime = (ZonedDateTime) object; + converter.getWriter().value(DateTimeFormatter.ISO_OFFSET_DATE_TIME.format(zonedDateTime)); + } + catch (JSONException e) { + throw new ConverterException(e); + } + } +} diff --git a/grails-doc/src/en/guide/upgrading/upgrading60x.adoc b/grails-doc/src/en/guide/upgrading/upgrading60x.adoc index ee21d8daf0b..3dcf0df3083 100644 --- a/grails-doc/src/en/guide/upgrading/upgrading60x.adoc +++ b/grails-doc/src/en/guide/upgrading/upgrading60x.adoc @@ -569,4 +569,113 @@ db.Example.updateMany( { created: { $type: "long" } }, [ { $set: { created: { $toDate: "$created" } } } ] ); ----- \ No newline at end of file +---- + +===== 12.25 JSON Rendering of Date/Time Types + +JSON rendering of `java.util.Calendar`, `java.time.Instant`, `java.time.LocalDate`, `java.time.LocalDateTime`, `java.time.OffsetDateTime`, and `java.time.ZonedDateTime` has been updated for consistency across both standard JSON converters (`render ... as JSON`) and JSON views (`.gson` files). + +====== Changes to JSON Output + +*Calendar*: Previously rendered as a complex object with all properties. Now consistently renders as ISO-8601 format with Z suffix: + +[source,json] +---- +// Before +{"timestamp": {"time": 1759869218602, "timeZone": {...}, "firstDayOfWeek": 1, ...}} + +// After +{"timestamp": "2025-10-07T21:14:31Z"} +---- + +*Instant*: Previously rendered as either epoch milliseconds (standard converters) or a complex object structure (JSON views). Now consistently renders as ISO-8601 format with Z suffix: + +[source,json] +---- +// Before (standard converters) +{"timestamp": 1759869218602} + +// Before (JSON views) +{"timestamp": {"epochSecond": 1759869218, "nano": 602000000}} + +// After (both) +{"timestamp": "2025-10-07T21:14:31.602Z"} +---- + +*LocalDate*: Previously rendered as a complex object structure. Now consistently renders as ISO-8601 date format (YYYY-MM-DD): + +[source,json] +---- +// Before +{"date": {"year": 2025, "month": "OCTOBER", "dayOfMonth": 8, ...}} + +// After +{"date": "2025-10-08"} +---- + +*LocalDateTime*: Previously rendered as a complex object structure (JSON views) or inconsistently. Now consistently renders as ISO-8601 format without timezone (matching Spring Boot behavior): + +[source,json] +---- +// Before (JSON views) +{"dateTime": {"year": 2025, "month": "OCTOBER", "dayOfMonth": 7, ...}} + +// After (both) +{"dateTime": "2025-10-07T21:14:31"} +---- + +NOTE: `LocalDate` and `LocalDateTime` do not include timezone information, so they render without the Z suffix, unlike `Date`, `Calendar`, and `Instant` which represent specific points in time. + +*OffsetDateTime*: Previously rendered as a complex object structure. Now consistently renders as ISO-8601 format with timezone offset: + +[source,json] +---- +// Before +{"dateTime": {"offset": {...}, "year": 2025, "month": "OCTOBER", ...}} + +// After +{"dateTime": "2025-10-08T00:48:46.407254-07:00"} +---- + +*ZonedDateTime*: Previously rendered with zone ID brackets like `[America/Los_Angeles]`. Now consistently renders as ISO-8601 format with timezone offset only (matching Spring Boot): + +[source,json] +---- +// Before +{"dateTime": "2025-10-08T00:48:46.407254-07:00[America/Los_Angeles]"} + +// After +{"dateTime": "2025-10-08T00:48:46.407254-07:00"} +---- + +*java.util.Date*: Now renders as ISO-8601 with Z suffix **including milliseconds**: + +[source,json] +---- +// Before +{"created": "2025-10-07T21:14:31Z"} + +// After +{"created": "2025-10-07T21:14:31.407Z"} +---- + +NOTE: `java.util.Date` and `Calendar` have **millisecond precision** (3 decimal places: `.SSS`), while Java 8 date/time types (`Instant`, `OffsetDateTime`, `ZonedDateTime`) have **nanosecond precision** (up to 9 decimal places, with trailing zeros dropped per ISO-8601 spec). This matches Spring Boot's Jackson serialization behavior. + +====== Migration Impact + +If your application or API consumers depend on the previous JSON format for `Calendar`, `Instant`, `LocalDate`, `LocalDateTime`, `OffsetDateTime`, or `ZonedDateTime` fields: + +1. **API Responses**: Client applications may need updates to parse the new ISO-8601 string format instead of epoch milliseconds or object structures. + +2. **Date Parsing**: The new format is a standard ISO-8601 string that can be parsed using `Instant.parse()`, `LocalDate.parse()`, `LocalDateTime.parse()`, `OffsetDateTime.parse()`, or `ZonedDateTime.parse()` with appropriate formatters. + +3. **Consistency**: + - Temporal types with timezone information (`Date`, `Calendar`, `Instant`) render with **Z suffix** (UTC) + - `LocalDate` (date only) renders as **YYYY-MM-DD** + - `LocalDateTime` (date and time, no timezone) renders **without Z suffix** + - `OffsetDateTime` and `ZonedDateTime` render with their **timezone offset** (e.g., `-07:00`) + - All formatting matches Spring Boot's behavior + +4. **ZonedDateTime**: Note that the zone ID (e.g., `[America/Los_Angeles]`) is no longer included in the output, matching Spring Boot's behavior. + +This change applies to both the `grails-converters` module (standard JSON rendering) and the `grails-views-gson` module (JSON views). \ No newline at end of file diff --git a/grails-test-suite-web/src/test/groovy/org/grails/web/converters/JSONDateTimeMarshallingSpec.groovy b/grails-test-suite-web/src/test/groovy/org/grails/web/converters/JSONDateTimeMarshallingSpec.groovy new file mode 100644 index 00000000000..749aff6e416 --- /dev/null +++ b/grails-test-suite-web/src/test/groovy/org/grails/web/converters/JSONDateTimeMarshallingSpec.groovy @@ -0,0 +1,149 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.grails.web.converters + +import grails.converters.JSON +import grails.testing.web.GrailsWebUnitTest +import spock.lang.Specification + +import java.time.Instant +import java.time.LocalDate +import java.time.LocalDateTime +import java.time.OffsetDateTime +import java.time.ZonedDateTime +import java.time.ZoneOffset + +/** + * Tests for JSON marshalling of Date, Calendar, Instant, LocalDate, LocalDateTime, OffsetDateTime, and ZonedDateTime types. + * + * @since 7.0 + */ +class JSONDateTimeMarshallingSpec extends Specification implements GrailsWebUnitTest { + + void "test Date, Calendar, and Instant render with Z suffix, LocalDateTime without"() { + given: "All four date types representing the same point in time" + def instant = Instant.parse("2025-10-07T21:14:31Z") + def date = Date.from(instant) + def calendar = Calendar.getInstance(TimeZone.getTimeZone("UTC")) + calendar.setTime(date) + def localDateTime = LocalDateTime.ofInstant(instant, ZoneOffset.UTC) + + when: "All four are converted to JSON" + def json = ([ + createdDate: date, + createdCalendar: calendar, + createdLocalDateTime: localDateTime, + createdInstant: instant + ] as JSON).toString() + + then: "Date and Calendar render with Z suffix and milliseconds" + json.contains('"createdDate":"2025-10-07T21:14:31.000Z"') + json.contains('"createdCalendar":"2025-10-07T21:14:31.000Z"') + + and: "Instant renders with Z suffix" + json.contains('"createdInstant":"2025-10-07T21:14:31Z"') + + and: "LocalDateTime renders without timezone (ISO_LOCAL_DATE_TIME)" + json.contains('"createdLocalDateTime":"2025-10-07T21:14:31"') + } + + void "test Instant renders with ISO-8601 format instead of object structure"() { + given: "An Instant value with nanosecond precision" + def instant = Instant.parse("2025-10-07T21:14:31.407254Z") // 407.254 milliseconds = 407254000 nanoseconds + + when: "The Instant is converted to JSON" + def json = ([timestamp: instant] as JSON).toString() + + then: "Instant renders as ISO-8601 string with full precision, not object properties" + json == '{"timestamp":"2025-10-07T21:14:31.407254Z"}' + !json.contains('epochSecond') + !json.contains('nano') + } + + void "test LocalDateTime renders without timezone suffix"() { + given: "A LocalDateTime value with nanosecond precision" + def localDateTime = LocalDateTime.of(2025, 10, 7, 21, 14, 31, 407254000) // 407.254 milliseconds + + when: "The LocalDateTime is converted to JSON" + def json = ([dateTime: localDateTime] as JSON).toString() + + then: "LocalDateTime renders as ISO-8601 with full precision, without Z suffix" + json == '{"dateTime":"2025-10-07T21:14:31.407254"}' + !json.contains('Z') + !json.contains('year') + !json.contains('month') + !json.contains('dayOfMonth') + } + + void "test Calendar renders with Z suffix and milliseconds"() { + given: "A Calendar value" + def calendar = Calendar.getInstance(TimeZone.getTimeZone("UTC")) + calendar.set(2025, Calendar.OCTOBER, 7, 21, 14, 31) + calendar.set(Calendar.MILLISECOND, 0) + + when: "The Calendar is converted to JSON" + def json = ([timestamp: calendar] as JSON).toString() + + then: "Calendar renders as ISO-8601 with Z suffix and milliseconds, not as object properties" + json == '{"timestamp":"2025-10-07T21:14:31.000Z"}' + !json.contains('timeInMillis') + !json.contains('firstDayOfWeek') + !json.contains('lenient') + } + + void "test OffsetDateTime renders with timezone offset"() { + given: "An OffsetDateTime value with -07:00 offset" + def offsetDateTime = OffsetDateTime.of(2025, 10, 8, 0, 48, 46, 407254000, ZoneOffset.ofHours(-7)) + + when: "The OffsetDateTime is converted to JSON" + def json = ([dateTime: offsetDateTime] as JSON).toString() + + then: "OffsetDateTime renders as ISO-8601 with offset" + json == '{"dateTime":"2025-10-08T00:48:46.407254-07:00"}' + !json.contains('offset') + !json.contains('nano') + } + + void "test ZonedDateTime renders with timezone offset (without zone ID)"() { + given: "A ZonedDateTime value" + def zonedDateTime = ZonedDateTime.of(2025, 10, 8, 0, 48, 46, 407254000, ZoneOffset.ofHours(-7)) + + when: "The ZonedDateTime is converted to JSON" + def json = ([dateTime: zonedDateTime] as JSON).toString() + + then: "ZonedDateTime renders as ISO-8601 with offset (no zone ID brackets)" + json == '{"dateTime":"2025-10-08T00:48:46.407254-07:00"}' + !json.contains('[') + !json.contains('zone') + } + + void "test LocalDate renders as date only (YYYY-MM-DD)"() { + given: "A LocalDate value" + def localDate = LocalDate.of(2025, 10, 8) + + when: "The LocalDate is converted to JSON" + def json = ([date: localDate] as JSON).toString() + + then: "LocalDate renders as ISO-8601 date only (no time)" + json == '{"date":"2025-10-08"}' + !json.contains('T') + !json.contains('year') + !json.contains('month') + } +} diff --git a/grails-views-gson/src/main/groovy/grails/plugin/json/converters/InstantJsonConverter.groovy b/grails-views-gson/src/main/groovy/grails/plugin/json/converters/InstantJsonConverter.groovy index bf360a14c75..0f380b4c2a6 100644 --- a/grails-views-gson/src/main/groovy/grails/plugin/json/converters/InstantJsonConverter.groovy +++ b/grails-views-gson/src/main/groovy/grails/plugin/json/converters/InstantJsonConverter.groovy @@ -20,6 +20,7 @@ package grails.plugin.json.converters import java.time.Instant +import java.time.format.DateTimeFormatter import groovy.transform.CompileStatic @@ -40,6 +41,6 @@ class InstantJsonConverter implements JsonGenerator.Converter { @Override Object convert(Object value, String key) { - ((Instant) value).toEpochMilli() + DateTimeFormatter.ISO_INSTANT.format((Instant) value) } } diff --git a/grails-views-gson/src/main/groovy/grails/plugin/json/converters/ZonedDateTimeJsonConverter.groovy b/grails-views-gson/src/main/groovy/grails/plugin/json/converters/ZonedDateTimeJsonConverter.groovy index 90a8c1ccab6..086ae674cc4 100644 --- a/grails-views-gson/src/main/groovy/grails/plugin/json/converters/ZonedDateTimeJsonConverter.groovy +++ b/grails-views-gson/src/main/groovy/grails/plugin/json/converters/ZonedDateTimeJsonConverter.groovy @@ -41,6 +41,6 @@ class ZonedDateTimeJsonConverter implements JsonGenerator.Converter { @Override Object convert(Object value, String key) { - DateTimeFormatter.ISO_ZONED_DATE_TIME.format((ZonedDateTime) value) + DateTimeFormatter.ISO_OFFSET_DATE_TIME.format((ZonedDateTime) value) } } diff --git a/grails-views-gson/src/test/groovy/grails/plugin/json/view/DateTimeRenderingSpec.groovy b/grails-views-gson/src/test/groovy/grails/plugin/json/view/DateTimeRenderingSpec.groovy new file mode 100644 index 00000000000..8fe2c1aeaf2 --- /dev/null +++ b/grails-views-gson/src/test/groovy/grails/plugin/json/view/DateTimeRenderingSpec.groovy @@ -0,0 +1,197 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package grails.plugin.json.view + +import grails.plugin.json.view.test.JsonViewTest +import spock.lang.Specification + +import java.time.Instant +import java.time.LocalDate +import java.time.LocalDateTime +import java.time.OffsetDateTime +import java.time.ZonedDateTime +import java.time.ZoneOffset + +class DateTimeRenderingSpec extends Specification implements JsonViewTest { + + void "Test Date and Instant render with Z, LocalDateTime without"() { + given: "A view that renders date/time types" + String source = ''' +import java.time.Instant +import java.time.LocalDateTime + +model { + Date createdDate + LocalDateTime createdLocalDateTime + Instant createdInstant +} + +json { + createdDate createdDate + createdLocalDateTime createdLocalDateTime + createdInstant createdInstant +} +''' + + and: "All three date types representing the same point in time" + // Use a fixed instant: 2025-10-07T21:14:31Z + def instant = Instant.parse("2025-10-07T21:14:31Z") + def date = Date.from(instant) + def localDateTime = LocalDateTime.ofInstant(instant, ZoneOffset.UTC) + + when: "The view is rendered" + def result = render(source, [ + createdDate: date, + createdLocalDateTime: localDateTime, + createdInstant: instant + ]) + + then: "Date and Instant render with Z suffix" + result.json.createdDate == "2025-10-07T21:14:31Z" + result.json.createdInstant == "2025-10-07T21:14:31Z" + + and: "LocalDateTime renders without timezone (local time)" + result.json.createdLocalDateTime == "2025-10-07T21:14:31" + } + + void "Test Instant renders with ISO-8601 format instead of epoch milliseconds"() { + given: "A view that renders an Instant" + String source = ''' +import java.time.Instant + +model { + Instant timestamp +} + +json { + timestamp timestamp +} +''' + + and: "An Instant value with nanosecond precision" + def instant = Instant.parse("2025-10-07T21:14:31.407254Z") // 407.254 milliseconds = 407254000 nanoseconds + + when: "The view is rendered" + def result = render(source, [timestamp: instant]) + + then: "Instant renders as ISO-8601 string with full precision, not epoch milliseconds" + result.json.timestamp == "2025-10-07T21:14:31.407254Z" + result.json.timestamp instanceof String + } + + void "Test LocalDateTime renders without timezone suffix"() { + given: "A view that renders a LocalDateTime" + String source = ''' +import java.time.LocalDateTime + +model { + LocalDateTime dateTime +} + +json { + dateTime dateTime +} +''' + + and: "A LocalDateTime value with nanosecond precision" + def localDateTime = LocalDateTime.of(2025, 10, 7, 21, 14, 31, 407254000) // 407.254 milliseconds + + when: "The view is rendered" + def result = render(source, [dateTime: localDateTime]) + + then: "LocalDateTime renders as ISO-8601 with full precision, without timezone" + result.json.dateTime == "2025-10-07T21:14:31.407254" + result.json.dateTime instanceof String + } + + void "Test OffsetDateTime renders with timezone offset"() { + given: "A view that renders an OffsetDateTime" + String source = ''' +import java.time.OffsetDateTime + +model { + OffsetDateTime dateTime +} + +json { + dateTime dateTime +} +''' + + and: "An OffsetDateTime value with -07:00 offset" + def offsetDateTime = OffsetDateTime.of(2025, 10, 8, 0, 48, 46, 407254000, ZoneOffset.ofHours(-7)) + + when: "The view is rendered" + def result = render(source, [dateTime: offsetDateTime]) + + then: "OffsetDateTime renders with offset" + result.json.dateTime == "2025-10-08T00:48:46.407254-07:00" + result.json.dateTime instanceof String + } + + void "Test ZonedDateTime renders with timezone offset (no zone ID)"() { + given: "A view that renders a ZonedDateTime" + String source = ''' +import java.time.ZonedDateTime + +model { + ZonedDateTime dateTime +} + +json { + dateTime dateTime +} +''' + + and: "A ZonedDateTime value" + def zonedDateTime = ZonedDateTime.of(2025, 10, 8, 0, 48, 46, 407254000, ZoneOffset.ofHours(-7)) + + when: "The view is rendered" + def result = render(source, [dateTime: zonedDateTime]) + + then: "ZonedDateTime renders with offset (no zone ID brackets)" + result.json.dateTime == "2025-10-08T00:48:46.407254-07:00" + result.json.dateTime instanceof String + } + + void "Test LocalDate renders as date only (YYYY-MM-DD)"() { + given: "A view that renders a LocalDate" + String source = ''' +import java.time.LocalDate + +model { + LocalDate date +} + +json { + date date +} +''' + + and: "A LocalDate value" + def localDate = LocalDate.of(2025, 10, 8) + + when: "The view is rendered" + def result = render(source, [date: localDate]) + + then: "LocalDate renders as date only (no time)" + result.json.date == "2025-10-08" + result.json.date instanceof String + } +}