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 index 62ba35f6f6f..22102886431 100644 --- 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 @@ -19,11 +19,8 @@ package org.grails.web.converters.marshaller.json; import java.text.Format; +import java.time.format.DateTimeFormatter; 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; @@ -31,27 +28,28 @@ import org.grails.web.json.JSONException; /** - * JSON ObjectMarshaller which converts a Calendar Object to ISO-8601 format with Z suffix. + * JSON ObjectMarshaller which converts a Calendar Object to an RFC 3339 / ISO 8601 + * UTC instant string (e.g. {@code 2024-06-15T14:30:45.123Z}). * * @since 7.0 */ public class CalendarMarshaller implements ObjectMarshaller { - private final Format formatter; + private final Format legacyFormatter; /** * Constructor with a custom formatter. * @param formatter the formatter */ public CalendarMarshaller(Format formatter) { - this.formatter = formatter; + this.legacyFormatter = formatter; } /** - * Default constructor. + * Default constructor — uses {@link DateTimeFormatter#ISO_INSTANT}. */ public CalendarMarshaller() { - this(FastDateFormat.getInstance("yyyy-MM-dd'T'HH:mm:ss.SSS'Z'", TimeZone.getTimeZone("GMT"), Locale.US)); + this(null); } public boolean supports(Object object) { @@ -61,7 +59,10 @@ public boolean supports(Object object) { public void marshalObject(Object object, JSON converter) throws ConverterException { try { Calendar calendar = (Calendar) object; - converter.getWriter().value(formatter.format(calendar.getTime())); + String formatted = legacyFormatter != null ? + legacyFormatter.format(calendar.getTime()) : + DateTimeFormatter.ISO_INSTANT.format(calendar.toInstant()); + converter.getWriter().value(formatted); } 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 b32e4d859b7..fa1f4e68093 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 @@ -19,11 +19,8 @@ package org.grails.web.converters.marshaller.json; import java.text.Format; +import java.time.format.DateTimeFormatter; import java.util.Date; -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; @@ -31,29 +28,29 @@ import org.grails.web.json.JSONException; /** - * JSON ObjectMarshaller which converts a Date Object, conforming to the ECMA-Script-Specification - * Draft, to a String value. + * JSON ObjectMarshaller which converts a Date Object to an RFC 3339 / ISO 8601 + * UTC instant string (e.g. {@code 2024-06-15T14:30:45.123Z}). * * @author Siegfried Puchbauer * @since 1.1 */ public class DateMarshaller implements ObjectMarshaller { - private final Format formatter; + private final Format legacyFormatter; /** * Constructor with a custom formatter. * @param formatter the formatter */ public DateMarshaller(Format formatter) { - this.formatter = formatter; + this.legacyFormatter = formatter; } /** - * Default constructor. + * Default constructor — uses {@link DateTimeFormatter#ISO_INSTANT}. */ public DateMarshaller() { - this(FastDateFormat.getInstance("yyyy-MM-dd'T'HH:mm:ss.SSS'Z'", TimeZone.getTimeZone("GMT"), Locale.US)); + this(null); } public boolean supports(Object object) { @@ -62,7 +59,11 @@ public boolean supports(Object object) { public void marshalObject(Object object, JSON converter) throws ConverterException { try { - converter.getWriter().value(formatter.format(object)); + Date date = (Date) object; + String formatted = legacyFormatter != null ? + legacyFormatter.format(date) : + DateTimeFormatter.ISO_INSTANT.format(date.toInstant()); + converter.getWriter().value(formatted); } catch (JSONException e) { throw new ConverterException(e); diff --git a/grails-converters/src/main/groovy/org/grails/web/converters/marshaller/xml/DateMarshaller.java b/grails-converters/src/main/groovy/org/grails/web/converters/marshaller/xml/DateMarshaller.java index 15801041b07..17921eec663 100644 --- a/grails-converters/src/main/groovy/org/grails/web/converters/marshaller/xml/DateMarshaller.java +++ b/grails-converters/src/main/groovy/org/grails/web/converters/marshaller/xml/DateMarshaller.java @@ -19,36 +19,43 @@ package org.grails.web.converters.marshaller.xml; import java.text.Format; +import java.time.ZoneId; +import java.time.format.DateTimeFormatter; import java.util.Date; -import org.apache.commons.lang3.time.FastDateFormat; - import grails.converters.XML; import org.grails.web.converters.ConverterUtil; import org.grails.web.converters.exceptions.ConverterException; import org.grails.web.converters.marshaller.ObjectMarshaller; /** + * XML ObjectMarshaller which converts a Date Object to an ISO 8601 offset + * date-time string in the system default zone (e.g. {@code 2024-06-15T14:30:45.123-04:00}). + * * @author Siegfried Puchbauer * @since 1.1 */ public class DateMarshaller implements ObjectMarshaller { - private final Format formatter; + private static final DateTimeFormatter DEFAULT_FORMATTER = + DateTimeFormatter.ISO_OFFSET_DATE_TIME.withZone(ZoneId.systemDefault()); + + private final Format legacyFormatter; /** * Constructor with a custom formatter. * @param formatter the formatter */ public DateMarshaller(Format formatter) { - this.formatter = formatter; + this.legacyFormatter = formatter; } /** - * Default constructor. + * Default constructor — uses {@link DateTimeFormatter#ISO_OFFSET_DATE_TIME} + * with the system default zone. */ public DateMarshaller() { - this(FastDateFormat.getInstance("yyyy-MM-dd HH:mm:ss.S z")); + this(null); } public boolean supports(Object object) { @@ -57,7 +64,11 @@ public boolean supports(Object object) { public void marshalObject(Object object, XML xml) throws ConverterException { try { - xml.chars(formatter.format(object)); + Date date = (Date) object; + String formatted = legacyFormatter != null ? + legacyFormatter.format(date) : + DEFAULT_FORMATTER.format(date.toInstant()); + xml.chars(formatted); } catch (Exception e) { throw ConverterUtil.resolveConverterException(e); diff --git a/grails-converters/src/test/groovy/org/grails/web/converters/marshaller/json/CalendarMarshallerSpec.groovy b/grails-converters/src/test/groovy/org/grails/web/converters/marshaller/json/CalendarMarshallerSpec.groovy new file mode 100644 index 00000000000..318aef2343f --- /dev/null +++ b/grails-converters/src/test/groovy/org/grails/web/converters/marshaller/json/CalendarMarshallerSpec.groovy @@ -0,0 +1,121 @@ +/* + * 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.SimpleDateFormat + +import spock.lang.Specification + +import grails.converters.JSON +import org.grails.web.json.JSONWriter + +class CalendarMarshallerSpec extends Specification { + + void "supports returns true for Calendar instances"() { + given: + def marshaller = new CalendarMarshaller() + + expect: + with(marshaller) { + supports(Calendar.getInstance()) + supports(new GregorianCalendar()) + } + } + + void "supports returns false for non-Calendar instances"() { + given: + def marshaller = new CalendarMarshaller() + + expect: + with(marshaller) { + !supports(new Date()) + !supports('not a calendar') + !supports(null) + } + } + + void "default formatter produces ISO-8601 UTC format with Z suffix"() { + given: + def marshaller = new CalendarMarshaller() + def calendar = Calendar.getInstance(TimeZone.getTimeZone('UTC')).tap { + timeInMillis = 1718461845123L + } + + when: + def result = marshalToString(marshaller, calendar) + + then: + result == '["2024-06-15T14:30:45.123Z"]' + } + + void "default formatter converts non-UTC calendar to UTC"() { + given: + def marshaller = new CalendarMarshaller() + def calendar = Calendar.getInstance(TimeZone.getTimeZone('America/New_York')).tap { + timeInMillis = 1718461845123L + } + + when: + def result = marshalToString(marshaller, calendar) + + then: "output is always UTC regardless of calendar timezone" + result == '["2024-06-15T14:30:45.123Z"]' + } + + void "default formatter pads sub-100 milliseconds to three digits"() { + given: + def marshaller = new CalendarMarshaller() + def calendar = Calendar.getInstance(TimeZone.getTimeZone('UTC')).tap { + timeInMillis = 1704067200005L + } + + when: + def result = marshalToString(marshaller, calendar) + + then: + result == '["2024-01-01T00:00:00.005Z"]' + } + + void "legacy formatter is used when provided"() { + given: + def customFormat = new SimpleDateFormat('dd/MM/yyyy').tap { + timeZone = TimeZone.getTimeZone('UTC') + } + def marshaller = new CalendarMarshaller(customFormat) + def calendar = Calendar.getInstance(TimeZone.getTimeZone('UTC')).tap { + timeInMillis = 1718461845123L + } + + when: + def result = marshalToString(marshaller, calendar) + + then: + result == '["15/06/2024"]' + } + + private static String marshalToString(CalendarMarshaller marshaller, Calendar calendar) { + def json = new JSON() + def stringWriter = new StringWriter() + json.writer = new JSONWriter(stringWriter) + json.writer.array() + marshaller.marshalObject(calendar, json) + json.writer.endArray() + stringWriter.toString() + } +} diff --git a/grails-converters/src/test/groovy/org/grails/web/converters/marshaller/json/DateMarshallerSpec.groovy b/grails-converters/src/test/groovy/org/grails/web/converters/marshaller/json/DateMarshallerSpec.groovy new file mode 100644 index 00000000000..9032705b154 --- /dev/null +++ b/grails-converters/src/test/groovy/org/grails/web/converters/marshaller/json/DateMarshallerSpec.groovy @@ -0,0 +1,109 @@ +/* + * 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.SimpleDateFormat + +import spock.lang.Specification + +import grails.converters.JSON +import org.grails.web.json.JSONWriter + +class DateMarshallerSpec extends Specification { + + void "supports returns true for Date instances"() { + given: + def marshaller = new DateMarshaller() + + expect: + marshaller.supports(new Date()) + } + + void "supports returns false for non-Date instances"() { + given: + def marshaller = new DateMarshaller() + + expect: + !marshaller.supports('not a date') + !marshaller.supports(42) + !marshaller.supports(null) + } + + void "default formatter produces ISO-8601 UTC format with Z suffix"() { + given: + def marshaller = new DateMarshaller() + def date = new Date(1718461845123L) + + when: + def result = marshalToString(marshaller, date) + + then: + result == '["2024-06-15T14:30:45.123Z"]' + } + + void "default formatter omits fractional seconds when millis are zero (ISO_INSTANT)"() { + given: + def marshaller = new DateMarshaller() + // 2024-01-01T00:00:00.000 UTC + def date = new Date(1704067200000L) + + when: + def result = marshalToString(marshaller, date) + + then: "ISO_INSTANT drops the fraction entirely on whole-second instants" + result == '["2024-01-01T00:00:00Z"]' + } + + void "default formatter pads sub-100 milliseconds to three digits"() { + given: + def marshaller = new DateMarshaller() + // 5 milliseconds past epoch second + def date = new Date(1704067200005L) + + when: + def result = marshalToString(marshaller, date) + + then: + result == '["2024-01-01T00:00:00.005Z"]' + } + + void "legacy formatter is used when provided"() { + given: + def customFormat = new SimpleDateFormat('dd/MM/yyyy') + customFormat.setTimeZone(TimeZone.getTimeZone('UTC')) + def marshaller = new DateMarshaller(customFormat) + def date = new Date(1718461845123L) + + when: + def result = marshalToString(marshaller, date) + + then: + result == '["15/06/2024"]' + } + + private static String marshalToString(DateMarshaller marshaller, Date date) { + def json = new JSON() + def stringWriter = new StringWriter() + json.writer = new JSONWriter(stringWriter) + json.writer.array() + marshaller.marshalObject(date, json) + json.writer.endArray() + stringWriter.toString() + } +} diff --git a/grails-converters/src/test/groovy/org/grails/web/converters/marshaller/xml/DateMarshallerSpec.groovy b/grails-converters/src/test/groovy/org/grails/web/converters/marshaller/xml/DateMarshallerSpec.groovy new file mode 100644 index 00000000000..202f959a534 --- /dev/null +++ b/grails-converters/src/test/groovy/org/grails/web/converters/marshaller/xml/DateMarshallerSpec.groovy @@ -0,0 +1,97 @@ +/* + * 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.xml + +import java.text.SimpleDateFormat +import java.time.ZoneId +import java.time.format.DateTimeFormatter + +import spock.lang.Specification + +import grails.converters.XML + +class DateMarshallerSpec extends Specification { + + void "supports returns true for Date instances"() { + given: + def marshaller = new DateMarshaller() + + expect: + marshaller.supports(new Date()) + } + + void "supports returns false for non-Date instances"() { + given: + def marshaller = new DateMarshaller() + + expect: + with(marshaller) { + !supports('not a date') + !supports(42) + !supports(null) + } + } + + void "default formatter produces ISO_OFFSET_DATE_TIME in system zone"() { + given: + def marshaller = new DateMarshaller() + def date = new Date(1718461845123L) + def xml = Mock(XML) + + and: "expected output computed independently using the same formatter" + def expected = DateTimeFormatter.ISO_OFFSET_DATE_TIME + .withZone(ZoneId.systemDefault()) + .format(date.toInstant()) + + when: + marshaller.marshalObject(date, xml) + + then: + 1 * xml.chars(expected) + } + + void "default formatter output matches ISO 8601 offset date-time pattern"() { + given: + def marshaller = new DateMarshaller() + def date = new Date(1718461845123L) + def xml = Mock(XML) + + when: + marshaller.marshalObject(date, xml) + + then: "matches yyyy-MM-ddTHH:mm:ss(.fraction)?(Z|+HH:MM)" + 1 * xml.chars({ String s -> s ==~ /\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d+)?(Z|[+-]\d{2}:\d{2})/ }) + } + + void "legacy formatter is used when provided"() { + given: + def customFormat = new SimpleDateFormat('dd/MM/yyyy').tap { + timeZone = TimeZone.getTimeZone('UTC') + } + def marshaller = new DateMarshaller(customFormat) + def date = new Date(1718461845123L) + def xml = Mock(XML) + + when: + marshaller.marshalObject(date, xml) + + then: + 1 * xml.chars('15/06/2024') + } +} diff --git a/grails-test-examples/app1/grails-app/controllers/functionaltests/marshaller/DateMarshallerController.groovy b/grails-test-examples/app1/grails-app/controllers/functionaltests/marshaller/DateMarshallerController.groovy new file mode 100644 index 00000000000..0312cc339ed --- /dev/null +++ b/grails-test-examples/app1/grails-app/controllers/functionaltests/marshaller/DateMarshallerController.groovy @@ -0,0 +1,72 @@ +/* + * 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 functionaltests.marshaller + +import grails.converters.JSON +import grails.converters.XML + +/** + * Controller for functional testing of Date and Calendar marshalling + * through the Grails converters pipeline (JSON and XML). + * + * Uses deterministic epoch-based dates so expected output is + * timezone-independent. + */ +class DateMarshallerController { + + static responseFormats = ['json', 'xml'] + + /** + * Returns a Date at epoch (1970-01-01T00:00:00Z) as JSON or XML. + * Exercises json/DateMarshaller and xml/DateMarshaller. + */ + def date() { + def data = [dateField: new Date(0)] + withFormat { + json { render data as JSON } + xml { render data as XML } + } + } + + /** + * Returns a Calendar at epoch as JSON or XML. + * Exercises json/CalendarMarshaller. + */ + def calendar() { + def cal = Calendar.getInstance(TimeZone.getTimeZone('UTC')) + cal.timeInMillis = 0 + def data = [calField: cal] + withFormat { + json { render data as JSON } + xml { render data as XML } + } + } + + /** + * Returns a Date with non-zero milliseconds to verify the fractional-seconds path. + * 1234567890123L = 2009-02-13T23:31:30.123Z + */ + def dateWithMillis() { + def data = [dateField: new Date(1234567890123L)] + withFormat { + json { render data as JSON } + xml { render data as XML } + } + } +} diff --git a/grails-test-examples/app1/src/integration-test/groovy/functionaltests/marshaller/DateMarshallerSpec.groovy b/grails-test-examples/app1/src/integration-test/groovy/functionaltests/marshaller/DateMarshallerSpec.groovy new file mode 100644 index 00000000000..d8866be34af --- /dev/null +++ b/grails-test-examples/app1/src/integration-test/groovy/functionaltests/marshaller/DateMarshallerSpec.groovy @@ -0,0 +1,128 @@ +/* + * 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 functionaltests.marshaller + +import java.time.Instant +import java.time.ZoneId +import java.time.format.DateTimeFormatter + +import spock.lang.Narrative +import spock.lang.Specification +import spock.lang.Tag + +import grails.testing.mixin.integration.Integration +import org.apache.grails.testing.http.client.HttpClientSupport + +/** + * Functional tests verifying that Date and Calendar objects are marshalled + * through the Grails JSON and XML converters using the JDK's ISO formatters. + * + * - JSON marshallers use {@link DateTimeFormatter#ISO_INSTANT} (UTC, "Z" suffix). + * - XML marshaller uses {@link DateTimeFormatter#ISO_OFFSET_DATE_TIME} in the + * system default zone (numeric offset, e.g. "+00:00", "-04:00"). + */ +@Integration +@Tag('http-client') +@Narrative(''' +Grails converters marshal Date and Calendar objects using the JDK's standard +ISO formatters. JSON output is RFC 3339 / ISO 8601 in UTC. XML output is +ISO 8601 offset date-time in the system default zone. +''') +class DateMarshallerSpec extends Specification implements HttpClientSupport { + + private static final Map ACCEPT_JSON = [Accept: 'application/json'] + private static final Map ACCEPT_XML = [Accept: 'application/xml'] + + // ========== JSON Date Marshalling ========== + + def "Date at epoch is marshalled to ISO_INSTANT in JSON"() { + when: + def response = http(ACCEPT_JSON, '/dateMarshaller/date') + + then: "ISO_INSTANT drops the fraction on whole-second instants" + response.assertJson(200, [dateField: '1970-01-01T00:00:00Z']) + } + + def "Date with milliseconds renders fraction to .SSS in JSON"() { + when: + def response = http(ACCEPT_JSON, '/dateMarshaller/dateWithMillis') + + then: + response.assertJson(200, [dateField: '2009-02-13T23:31:30.123Z']) + } + + // ========== JSON Calendar Marshalling ========== + + def "Calendar at epoch is marshalled to ISO_INSTANT in JSON"() { + when: + def response = http(ACCEPT_JSON, '/dateMarshaller/calendar') + + then: + response.assertJson(200, [calField: '1970-01-01T00:00:00Z']) + } + + // ========== XML Date Marshalling ========== + + def "Date at epoch is marshalled as ISO_OFFSET_DATE_TIME in XML"() { + given: + def expectedDate = xmlFormatter().format(Instant.EPOCH) + + when: + def response = http(ACCEPT_XML, '/dateMarshaller/date') + + then: + response.assertContains(200, expectedDate) + } + + def "Date with milliseconds renders fraction in XML"() { + given: + def expectedDate = xmlFormatter().format(Instant.ofEpochMilli(1234567890123L)) + + when: + def response = http(ACCEPT_XML, '/dateMarshaller/dateWithMillis') + + then: + response.assertContains(200, expectedDate) + } + + // ========== URL Extension Format ========== + + def "Date JSON via .json URL extension"() { + when: + def response = http('/dateMarshaller/date.json') + + then: + response.assertJson(200, [dateField: '1970-01-01T00:00:00Z']) + } + + def "Date XML via .xml URL extension"() { + given: + def expectedDate = xmlFormatter().format(Instant.EPOCH) + + when: + def response = http('/dateMarshaller/date.xml') + + then: + response.assertContains(200, expectedDate) + } + + private static DateTimeFormatter xmlFormatter() { + DateTimeFormatter.ISO_OFFSET_DATE_TIME.withZone(ZoneId.systemDefault()) + } +} 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 index 749aff6e416..b500da1367c 100644 --- 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 @@ -18,19 +18,21 @@ */ 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 +import java.time.ZonedDateTime + +import spock.lang.Specification + +import grails.converters.JSON +import grails.testing.web.GrailsWebUnitTest /** - * Tests for JSON marshalling of Date, Calendar, Instant, LocalDate, LocalDateTime, OffsetDateTime, and ZonedDateTime types. + * Tests for JSON marshalling of Date, Calendar, Instant, LocalDate, LocalDateTime, + * OffsetDateTime, and ZonedDateTime types. * * @since 7.0 */ @@ -38,10 +40,11 @@ class JSONDateTimeMarshallingSpec extends Specification implements GrailsWebUnit 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 instant = Instant.parse('2025-10-07T21:14:31Z') def date = Date.from(instant) - def calendar = Calendar.getInstance(TimeZone.getTimeZone("UTC")) - calendar.setTime(date) + def calendar = Calendar.getInstance(TimeZone.getTimeZone('UTC')).tap { + time = date + } def localDateTime = LocalDateTime.ofInstant(instant, ZoneOffset.UTC) when: "All four are converted to JSON" @@ -52,9 +55,9 @@ class JSONDateTimeMarshallingSpec extends Specification implements GrailsWebUnit 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"') + then: "Date, Calendar, and Instant render with Z suffix; ISO_INSTANT drops the fraction on whole seconds" + json.contains('"createdDate":"2025-10-07T21:14:31Z"') + json.contains('"createdCalendar":"2025-10-07T21:14:31Z"') and: "Instant renders with Z suffix" json.contains('"createdInstant":"2025-10-07T21:14:31Z"') @@ -65,7 +68,7 @@ class JSONDateTimeMarshallingSpec extends Specification implements GrailsWebUnit 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 + 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() @@ -85,26 +88,34 @@ class JSONDateTimeMarshallingSpec extends Specification implements GrailsWebUnit 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) + void "test Calendar renders with Z suffix"() { + given: "A Calendar value with zero milliseconds" + def calendar = Calendar.getInstance(TimeZone.getTimeZone('UTC')).tap { + set(2025, OCTOBER, 7, 21, 14, 31) + set(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') + then: "Calendar renders as ISO_INSTANT — whole-second values drop the fraction" + json == '{"timestamp":"2025-10-07T21:14:31Z"}' + } + + void "test Calendar with non-zero milliseconds renders fraction"() { + given: "A Calendar value with 123 milliseconds" + def calendar = Calendar.getInstance(TimeZone.getTimeZone('UTC')).tap { + set(2025, OCTOBER, 7, 21, 14, 31) + set(MILLISECOND, 123) + } + + when: "The Calendar is converted to JSON" + def json = ([timestamp: calendar] as JSON).toString() + + then: "Calendar renders as ISO_INSTANT with 3-digit fraction" + json == '{"timestamp":"2025-10-07T21:14:31.123Z"}' } void "test OffsetDateTime renders with timezone offset"() { @@ -116,8 +127,6 @@ class JSONDateTimeMarshallingSpec extends Specification implements GrailsWebUnit 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)"() { @@ -129,8 +138,6 @@ class JSONDateTimeMarshallingSpec extends Specification implements GrailsWebUnit 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)"() { @@ -142,8 +149,5 @@ class JSONDateTimeMarshallingSpec extends Specification implements GrailsWebUnit then: "LocalDate renders as ISO-8601 date only (no time)" json == '{"date":"2025-10-08"}' - !json.contains('T') - !json.contains('year') - !json.contains('month') } }