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..55358457e92 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,10 @@ package org.grails.web.converters.marshaller.json; import java.text.Format; +import java.time.ZoneOffset; +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; @@ -37,21 +36,25 @@ */ public class CalendarMarshaller implements ObjectMarshaller { - private final Format formatter; + private static final DateTimeFormatter DEFAULT_FORMATTER = + DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH:mm:ss.SSS'Z'", Locale.US) + .withZone(ZoneOffset.UTC); + + 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. */ public CalendarMarshaller() { - this(FastDateFormat.getInstance("yyyy-MM-dd'T'HH:mm:ss.SSS'Z'", TimeZone.getTimeZone("GMT"), Locale.US)); + this.legacyFormatter = null; } public boolean supports(Object object) { @@ -61,7 +64,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()) : + DEFAULT_FORMATTER.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..1478321e6fb 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,10 @@ package org.grails.web.converters.marshaller.json; import java.text.Format; +import java.time.ZoneOffset; +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; @@ -39,21 +38,25 @@ */ public class DateMarshaller implements ObjectMarshaller { - private final Format formatter; + private static final DateTimeFormatter DEFAULT_FORMATTER = + DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH:mm:ss.SSS'Z'", Locale.US) + .withZone(ZoneOffset.UTC); + + 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. */ public DateMarshaller() { - this(FastDateFormat.getInstance("yyyy-MM-dd'T'HH:mm:ss.SSS'Z'", TimeZone.getTimeZone("GMT"), Locale.US)); + this.legacyFormatter = null; } public boolean supports(Object object) { @@ -62,7 +65,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) : + DEFAULT_FORMATTER.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..b8b588e4eb4 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,10 +19,10 @@ 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; @@ -34,21 +34,25 @@ */ public class DateMarshaller implements ObjectMarshaller { - private final Format formatter; + private static final DateTimeFormatter DEFAULT_FORMATTER = + DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss.SSS z") + .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. */ public DateMarshaller() { - this(FastDateFormat.getInstance("yyyy-MM-dd HH:mm:ss.S z")); + this.legacyFormatter = null; } public boolean supports(Object object) { @@ -57,7 +61,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..0ae8525de85 --- /dev/null +++ b/grails-converters/src/test/groovy/org/grails/web/converters/marshaller/json/CalendarMarshallerSpec.groovy @@ -0,0 +1,113 @@ +/* + * 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 grails.converters.JSON + +import org.grails.web.json.JSONWriter + +import spock.lang.Specification + +class CalendarMarshallerSpec extends Specification { + + void "supports returns true for Calendar instances"() { + given: + def marshaller = new CalendarMarshaller() + + expect: + marshaller.supports(Calendar.getInstance()) + marshaller.supports(new GregorianCalendar()) + } + + void "supports returns false for non-Calendar instances"() { + given: + def marshaller = new CalendarMarshaller() + + expect: + !marshaller.supports(new Date()) + !marshaller.supports("not a calendar") + !marshaller.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")) + calendar.setTimeInMillis(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")) + calendar.setTimeInMillis(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 milliseconds to three digits"() { + given: + def marshaller = new CalendarMarshaller() + def calendar = Calendar.getInstance(TimeZone.getTimeZone("UTC")) + calendar.setTimeInMillis(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") + customFormat.setTimeZone(TimeZone.getTimeZone("UTC")) + def marshaller = new CalendarMarshaller(customFormat) + def calendar = Calendar.getInstance(TimeZone.getTimeZone("UTC")) + calendar.setTimeInMillis(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..4fa39133bf2 --- /dev/null +++ b/grails-converters/src/test/groovy/org/grails/web/converters/marshaller/json/DateMarshallerSpec.groovy @@ -0,0 +1,110 @@ +/* + * 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 grails.converters.JSON + +import org.grails.web.json.JSONWriter + +import spock.lang.Specification + +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 formats zero milliseconds correctly"() { + given: + def marshaller = new DateMarshaller() + // 2024-01-01T00:00:00.000 UTC + def date = new Date(1704067200000L) + + when: + def result = marshalToString(marshaller, date) + + then: + result == '["2024-01-01T00:00:00.000Z"]' + } + + void "default formatter pads 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..d55300ddf6c --- /dev/null +++ b/grails-converters/src/test/groovy/org/grails/web/converters/marshaller/xml/DateMarshallerSpec.groovy @@ -0,0 +1,108 @@ +/* + * 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 grails.converters.XML + +import spock.lang.Specification + +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 date in system timezone with three-digit millis"() { + given: + def marshaller = new DateMarshaller() + def date = new Date(1718461845123L) + def xml = Mock(XML) + + // Compute expected output independently using the same pattern and system timezone + def expected = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss.SSS z") + .withZone(ZoneId.systemDefault()) + .format(date.toInstant()) + + when: + marshaller.marshalObject(date, xml) + + then: + 1 * xml.chars(expected) + } + + void "default formatter pads milliseconds to three digits"() { + given: + def marshaller = new DateMarshaller() + // 5 milliseconds past epoch second + def date = new Date(1704067200005L) + def xml = Mock(XML) + + when: + marshaller.marshalObject(date, xml) + + then: + 1 * xml.chars({ String s -> s.contains('.005') }) + } + + void "default formatter output matches expected pattern"() { + given: + def marshaller = new DateMarshaller() + def date = new Date(1718461845123L) + def xml = Mock(XML) + + when: + marshaller.marshalObject(date, xml) + + then: + 1 * xml.chars({ String s -> s ==~ /\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}\.\d{3} .+/ }) + } + + 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) + 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..e29c76f82bd --- /dev/null +++ b/grails-test-examples/app1/grails-app/controllers/functionaltests/marshaller/DateMarshallerController.groovy @@ -0,0 +1,73 @@ +/* + * 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:00.000Z) 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 .SSS 3-digit padding. + * 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..530815fb91d --- /dev/null +++ b/grails-test-examples/app1/src/integration-test/groovy/functionaltests/marshaller/DateMarshallerSpec.groovy @@ -0,0 +1,191 @@ +/* + * 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 groovy.json.JsonSlurper + +import java.time.Instant +import java.time.ZoneId +import java.time.format.DateTimeFormatter +import io.micronaut.http.HttpRequest +import io.micronaut.http.HttpStatus +import io.micronaut.http.MediaType +import io.micronaut.http.client.HttpClient +import spock.lang.Narrative +import spock.lang.Shared +import spock.lang.Specification + +import grails.testing.mixin.integration.Integration + +/** + * Functional tests verifying that Date and Calendar objects are marshalled + * through the Grails JSON and XML converters using DateTimeFormatter. + * + * These tests exercise the DateTimeFormatter-based marshallers: + * - org.grails.web.converters.marshaller.json.DateMarshaller (ISO 8601 UTC) + * - org.grails.web.converters.marshaller.json.CalendarMarshaller (ISO 8601 UTC) + * - org.grails.web.converters.marshaller.xml.DateMarshaller (system default timezone) + * + * JSON format: yyyy-MM-dd'T'HH:mm:ss.SSS'Z' (always UTC) + * XML format: yyyy-MM-dd HH:mm:ss.SSS z (system default timezone) + */ +@Integration +@Narrative(''' +Grails converters marshal Date and Calendar objects using DateTimeFormatter. +JSON output uses ISO 8601 UTC format. XML output uses system default timezone. +Both produce consistent 3-digit millisecond formatting (.000, .123, etc.). +''') +class DateMarshallerSpec extends Specification { + + @Shared + HttpClient client + + def setup() { + client = client ?: HttpClient.create(new URL("http://localhost:$serverPort")) + } + + def cleanupSpec() { + client.close() + } + + // ========== JSON Date Marshalling ========== + + def "Date at epoch is marshalled to ISO 8601 UTC in JSON"() { + when: "requesting date endpoint with Accept: application/json" + def response = client.toBlocking().exchange( + HttpRequest.GET('/dateMarshaller/date') + .accept(MediaType.APPLICATION_JSON), + String + ) + + then: "response contains date in yyyy-MM-dd'T'HH:mm:ss.SSS'Z' format" + response.status == HttpStatus.OK + + and: "date value is epoch in UTC" + def json = new JsonSlurper().parseText(response.body()) + json.dateField == '1970-01-01T00:00:00.000Z' + } + + def "Date with milliseconds uses 3-digit padded format in JSON"() { + when: "requesting dateWithMillis endpoint with Accept: application/json" + def response = client.toBlocking().exchange( + HttpRequest.GET('/dateMarshaller/dateWithMillis') + .accept(MediaType.APPLICATION_JSON), + String + ) + + then: "response contains date with .123 milliseconds" + response.status == HttpStatus.OK + + and: "milliseconds are 3-digit zero-padded" + def json = new JsonSlurper().parseText(response.body()) + json.dateField == '2009-02-13T23:31:30.123Z' + } + + // ========== JSON Calendar Marshalling ========== + + def "Calendar at epoch is marshalled to ISO 8601 UTC in JSON"() { + when: "requesting calendar endpoint with Accept: application/json" + def response = client.toBlocking().exchange( + HttpRequest.GET('/dateMarshaller/calendar') + .accept(MediaType.APPLICATION_JSON), + String + ) + + then: "response contains calendar in ISO 8601 UTC format" + response.status == HttpStatus.OK + + and: "calendar value is epoch in UTC" + def json = new JsonSlurper().parseText(response.body()) + json.calField == '1970-01-01T00:00:00.000Z' + } + + // ========== XML Date Marshalling ========== + + def "Date at epoch is marshalled in system default timezone in XML"() { + given: "the expected formatted date using the same pattern as the XML marshaller" + def expectedDate = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss.SSS z") + .withZone(ZoneId.systemDefault()) + .format(Instant.EPOCH) + + when: "requesting date endpoint with Accept: application/xml" + def response = client.toBlocking().exchange( + HttpRequest.GET('/dateMarshaller/date') + .accept(MediaType.APPLICATION_XML), + String + ) + + then: "response is XML containing date in system default timezone format" + response.status == HttpStatus.OK + response.body().contains(expectedDate) + } + + def "Date with milliseconds uses 3-digit padded format in XML"() { + given: "the expected formatted date" + def expectedDate = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss.SSS z") + .withZone(ZoneId.systemDefault()) + .format(Instant.ofEpochMilli(1234567890123L)) + + when: "requesting dateWithMillis endpoint with Accept: application/xml" + def response = client.toBlocking().exchange( + HttpRequest.GET('/dateMarshaller/dateWithMillis') + .accept(MediaType.APPLICATION_XML), + String + ) + + then: "response is XML containing date with .123 milliseconds" + response.status == HttpStatus.OK + response.body().contains(expectedDate) + } + + // ========== URL Extension Format ========== + + def "Date JSON via .json URL extension"() { + when: "requesting date with .json extension" + def response = client.toBlocking().exchange( + HttpRequest.GET('/dateMarshaller/date.json'), + String + ) + + then: "response is JSON with correct date format" + response.status == HttpStatus.OK + + and: "date is in ISO 8601 UTC format" + def json = new JsonSlurper().parseText(response.body()) + json.dateField == '1970-01-01T00:00:00.000Z' + } + + def "Date XML via .xml URL extension"() { + given: "the expected formatted date" + def expectedDate = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss.SSS z") + .withZone(ZoneId.systemDefault()) + .format(Instant.EPOCH) + + when: "requesting date with .xml extension" + def response = client.toBlocking().exchange( + HttpRequest.GET('/dateMarshaller/date.xml'), + String + ) + + then: "response is XML with correct date format" + response.status == HttpStatus.OK + response.body().contains(expectedDate) + } +}