Skip to content

Commit 27890ee

Browse files
feat: support Java 8 date/time formats per OpenAPI Formats Registry (#5172) (#5184)
Co-authored-by: Daniel Kmiecik <daniel.kmiecik@smartbear.com>
1 parent 49caebc commit 27890ee

10 files changed

Lines changed: 706 additions & 46 deletions

File tree

modules/swagger-core/src/main/java/io/swagger/v3/core/util/ModelDeserializer.java

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,9 @@
1212
import io.swagger.v3.oas.models.media.BooleanSchema;
1313
import io.swagger.v3.oas.models.media.ComposedSchema;
1414
import io.swagger.v3.oas.models.media.DateSchema;
15+
import io.swagger.v3.oas.models.media.DateTimeLocalSchema;
1516
import io.swagger.v3.oas.models.media.DateTimeSchema;
17+
import io.swagger.v3.oas.models.media.DurationSchema;
1618
import io.swagger.v3.oas.models.media.EmailSchema;
1719
import io.swagger.v3.oas.models.media.IntegerSchema;
1820
import io.swagger.v3.oas.models.media.JsonSchema;
@@ -22,6 +24,8 @@
2224
import io.swagger.v3.oas.models.media.PasswordSchema;
2325
import io.swagger.v3.oas.models.media.Schema;
2426
import io.swagger.v3.oas.models.media.StringSchema;
27+
import io.swagger.v3.oas.models.media.TimeLocalSchema;
28+
import io.swagger.v3.oas.models.media.TimeSchema;
2529
import io.swagger.v3.oas.models.media.UUIDSchema;
2630
import org.apache.commons.lang3.StringUtils;
2731

@@ -46,6 +50,10 @@ public class ModelDeserializer extends JsonDeserializer<Schema> {
4650
private static final String FORMAT = "format";
4751
private static final String DATE_FORMAT = "date";
4852
private static final String DATE_TIME_FORMAT = "date-time";
53+
private static final String TIME_FORMAT = "time";
54+
private static final String DURATION_FORMAT = "duration";
55+
private static final String DATE_TIME_LOCAL_FORMAT = "date-time-local";
56+
private static final String TIME_LOCAL_FORMAT = "time-local";
4957
private static final String EMAIL_FORMAT = "email";
5058
private static final String PASSWORD_FORMAT = "password";
5159
private static final String UUID_FORMAT = "uuid";
@@ -196,7 +204,15 @@ private Schema deserializeSchemaWithType(JsonNode node, JsonNode typeNode) {
196204
schema = Json.mapper().convertValue(node, DateSchema.class);
197205
} else if (DATE_TIME_FORMAT.equals(format)) {
198206
schema = Json.mapper().convertValue(node, DateTimeSchema.class);
199-
} else if (EMAIL_FORMAT.equals(format)) {
207+
} else if (TIME_FORMAT.equals(format)) {
208+
schema = Json.mapper().convertValue(node, TimeSchema.class);
209+
} else if (DURATION_FORMAT.equals(format)) {
210+
schema = Json.mapper().convertValue(node, DurationSchema.class);
211+
} else if (DATE_TIME_LOCAL_FORMAT.equals(format)) {
212+
schema = Json.mapper().convertValue(node, DateTimeLocalSchema.class);
213+
} else if (TIME_LOCAL_FORMAT.equals(format)) {
214+
schema = Json.mapper().convertValue(node, TimeLocalSchema.class);
215+
}else if (EMAIL_FORMAT.equals(format)) {
200216
schema = Json.mapper().convertValue(node, EmailSchema.class);
201217
} else if (PASSWORD_FORMAT.equals(format)) {
202218
schema = Json.mapper().convertValue(node, PasswordSchema.class);

modules/swagger-core/src/main/java/io/swagger/v3/core/util/PrimitiveType.java

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,13 +5,17 @@
55
import io.swagger.v3.oas.models.media.BooleanSchema;
66
import io.swagger.v3.oas.models.media.ByteArraySchema;
77
import io.swagger.v3.oas.models.media.DateSchema;
8+
import io.swagger.v3.oas.models.media.DateTimeLocalSchema;
89
import io.swagger.v3.oas.models.media.DateTimeSchema;
10+
import io.swagger.v3.oas.models.media.DurationSchema;
911
import io.swagger.v3.oas.models.media.FileSchema;
1012
import io.swagger.v3.oas.models.media.IntegerSchema;
1113
import io.swagger.v3.oas.models.media.JsonSchema;
1214
import io.swagger.v3.oas.models.media.NumberSchema;
1315
import io.swagger.v3.oas.models.media.Schema;
1416
import io.swagger.v3.oas.models.media.StringSchema;
17+
import io.swagger.v3.oas.models.media.TimeLocalSchema;
18+
import io.swagger.v3.oas.models.media.TimeSchema;
1519
import io.swagger.v3.oas.models.media.UUIDSchema;
1620
import org.apache.commons.lang3.StringUtils;
1721

@@ -221,6 +225,46 @@ public Schema createProperty31() {
221225
return new JsonSchema().typesItem("string").format("partial-time");
222226
}
223227
},
228+
DATE_TIME_LOCAL(java.time.LocalDateTime.class, "date-time-local") {
229+
@Override
230+
public Schema createProperty() {
231+
return new DateTimeLocalSchema();
232+
}
233+
@Override
234+
public Schema createProperty31() {
235+
return new JsonSchema().typesItem("string").format("date-time-local");
236+
}
237+
},
238+
TIME(java.time.OffsetTime.class, "time") {
239+
@Override
240+
public Schema createProperty() {
241+
return new TimeSchema();
242+
}
243+
@Override
244+
public Schema createProperty31() {
245+
return new JsonSchema().typesItem("string").format("time");
246+
}
247+
},
248+
TIME_LOCAL(java.time.LocalTime.class, "time-local") {
249+
@Override
250+
public Schema createProperty() {
251+
return new TimeLocalSchema();
252+
}
253+
@Override
254+
public Schema createProperty31() {
255+
return new JsonSchema().typesItem("string").format("time-local");
256+
}
257+
},
258+
DURATION(java.time.Duration.class, "duration") {
259+
@Override
260+
public Schema createProperty() {
261+
return new DurationSchema();
262+
}
263+
@Override
264+
public Schema createProperty31() {
265+
return new JsonSchema().typesItem("string").format("duration");
266+
}
267+
},
224268
FILE(java.io.File.class, "file") {
225269
@Override
226270
public FileSchema createProperty() {
@@ -315,6 +359,10 @@ public Schema createProperty31() {
315359
dms.put("string_uuid", "uuid");
316360
dms.put("string_date", "date");
317361
dms.put("string_date-time", "date-time");
362+
dms.put("string_date-time-local", "date-time-local");
363+
dms.put("string_time", "time");
364+
dms.put("string_time-local", "time-local");
365+
dms.put("string_duration", "duration");
318366
dms.put("string_partial-time", "partial-time");
319367
dms.put("string_password", "password");
320368
dms.put("boolean_", "boolean");
@@ -361,6 +409,9 @@ public Schema createProperty31() {
361409
"org.joda.time.ReadableDateTime",
362410
"org.joda.time.DateTime",
363411
"java.time.Instant");
412+
addKeys(externalClasses, TIME, "java.time.OffsetTime");
413+
addKeys(externalClasses, DURATION, "java.time.Duration");
414+
addKeys(externalClasses, TIME_LOCAL, "java.time.LocalTime");
364415
EXTERNAL_CLASSES = Collections.unmodifiableMap(externalClasses);
365416

366417
final Map<String, PrimitiveType> names = new TreeMap<>(String.CASE_INSENSITIVE_ORDER);
@@ -581,10 +632,28 @@ private DateStub() {
581632
* Convenience method to map LocalTime to string primitive with rfc3339 format partial-time.
582633
* See https://xml2rfc.tools.ietf.org/public/rfc/html/rfc3339.html#anchor14
583634
*
635+
* <p>{@code "partial-time"} is not itself a registered OpenAPI Formats Registry value
636+
* (it is borrowed from RFC 3339 grammar). {@code java.time.LocalTime} already defaults to
637+
* the registry-compliant {@code "time-local"}; this method overrides that default for
638+
* callers who specifically need the {@code "partial-time"} format instead, since it is a
639+
* different mapping, not a strict replacement for it.
640+
*
584641
* @since 2.0.6
642+
* @deprecated Prefer the default {@code "time-local"} mapping for {@code java.time.LocalTime}.
585643
*/
644+
@Deprecated
586645
public static void enablePartialTime() {
587646
customClasses().put("org.joda.time.LocalTime", PrimitiveType.PARTIAL_TIME);
588647
customClasses().put("java.time.LocalTime", PrimitiveType.PARTIAL_TIME);
589648
}
649+
650+
/**
651+
* Opts in to the OpenAPI Formats Registry mapping for {@code java.time.LocalDateTime}:
652+
* maps it to format {@code "date-time-local"} instead of the default {@code "date-time"}.
653+
*
654+
* @since 2.3.0
655+
*/
656+
public static void enableJava8Formats() {
657+
customClasses().put("java.time.LocalDateTime", PrimitiveType.DATE_TIME_LOCAL);
658+
}
590659
}
Lines changed: 194 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,194 @@
1+
package io.swagger.v3.core.resolving;
2+
3+
import io.swagger.v3.core.converter.AnnotatedType;
4+
import io.swagger.v3.core.converter.ModelConverterContextImpl;
5+
import io.swagger.v3.core.jackson.ModelResolver;
6+
import io.swagger.v3.core.matchers.SerializationMatchers;
7+
import io.swagger.v3.core.resolving.resources.TestObjectJava8Dates;
8+
import io.swagger.v3.core.resolving.resources.TestObject2992;
9+
import io.swagger.v3.core.util.PrimitiveType;
10+
import io.swagger.v3.oas.models.media.DurationSchema;
11+
import io.swagger.v3.oas.models.media.Schema;
12+
import io.swagger.v3.oas.models.media.TimeLocalSchema;
13+
import io.swagger.v3.oas.models.media.TimeSchema;
14+
import org.testng.annotations.Test;
15+
16+
import java.time.Duration;
17+
import java.time.LocalDateTime;
18+
import java.time.LocalTime;
19+
import java.time.OffsetTime;
20+
import java.util.Map;
21+
22+
import static org.testng.Assert.assertEquals;
23+
import static org.testng.Assert.assertNotNull;
24+
import static org.testng.Assert.assertTrue;
25+
26+
/**
27+
* Verifies Java 8 date/time type → OpenAPI format mappings (issue #5172).
28+
*
29+
* Default behaviour (fixed; previous complex-object expansion was always incorrect):
30+
* OffsetTime → "time"
31+
* Duration → "duration"
32+
* LocalTime → "time-local"
33+
*
34+
* Default behaviour (unchanged for compatibility):
35+
* LocalDateTime → "date-time"
36+
*
37+
* Opt-in via PrimitiveType.enableJava8Formats():
38+
* LocalDateTime → "date-time-local"
39+
*/
40+
public class Java8DateFormatsTest extends SwaggerTestBase {
41+
42+
@Test
43+
public void testDefaultFormats() throws Exception {
44+
final ModelResolver modelResolver = new ModelResolver(mapper());
45+
final ModelConverterContextImpl context = new ModelConverterContextImpl(modelResolver);
46+
47+
context.resolve(new AnnotatedType(TestObjectJava8Dates.class));
48+
49+
SerializationMatchers.assertEqualsToYaml(context.getDefinedModels(), "TestObjectJava8Dates:\n" +
50+
" type: object\n" +
51+
" properties:\n" +
52+
" localDateTime:\n" +
53+
" type: string\n" +
54+
" format: date-time\n" +
55+
" offsetDateTime:\n" +
56+
" type: string\n" +
57+
" format: date-time\n" +
58+
" zonedDateTime:\n" +
59+
" type: string\n" +
60+
" format: date-time\n" +
61+
" instant:\n" +
62+
" type: string\n" +
63+
" format: date-time\n" +
64+
" localDate:\n" +
65+
" type: string\n" +
66+
" format: date\n" +
67+
" offsetTime:\n" +
68+
" type: string\n" +
69+
" format: time\n" +
70+
" duration:\n" +
71+
" type: string\n" +
72+
" format: duration");
73+
}
74+
75+
@Test
76+
public void testEnableJava8Formats() throws Exception {
77+
// Save current state so other tests are not affected by the static customClasses map
78+
final Map<String, PrimitiveType> custom = PrimitiveType.customClasses();
79+
final PrimitiveType prevLocalDateTime = custom.get("java.time.LocalDateTime");
80+
final PrimitiveType prevLocalTime = custom.get("java.time.LocalTime");
81+
82+
PrimitiveType.enableJava8Formats();
83+
try {
84+
final ModelResolver modelResolver = new ModelResolver(mapper());
85+
final ModelConverterContextImpl context = new ModelConverterContextImpl(modelResolver);
86+
87+
context.resolve(new AnnotatedType(TestObject2992.class));
88+
89+
// LocalDateTime → "date-time-local", LocalTime → "time-local" after opt-in
90+
SerializationMatchers.assertEqualsToYaml(context.getDefinedModels(), "TestObject2992:\n" +
91+
" type: object\n" +
92+
" properties:\n" +
93+
" name:\n" +
94+
" type: string\n" +
95+
" a:\n" +
96+
" type: string\n" +
97+
" format: time-local\n" +
98+
" b:\n" +
99+
" type: string\n" +
100+
" format: time-local\n" +
101+
" c:\n" +
102+
" type: string\n" +
103+
" format: time-local\n" +
104+
" d:\n" +
105+
" type: string\n" +
106+
" format: date-time-local\n" +
107+
" e:\n" +
108+
" type: string\n" +
109+
" format: date-time-local\n" +
110+
" f:\n" +
111+
" type: string\n" +
112+
" format: date-time-local");
113+
} finally {
114+
// Restore previous state so subsequent tests are not affected
115+
if (prevLocalDateTime == null) custom.remove("java.time.LocalDateTime");
116+
else custom.put("java.time.LocalDateTime", prevLocalDateTime);
117+
if (prevLocalTime == null) custom.remove("java.time.LocalTime");
118+
else custom.put("java.time.LocalTime", prevLocalTime);
119+
}
120+
}
121+
122+
@Test
123+
public void testDefaultLocalTime() {
124+
// Isolate from any prior enablePartialTime()/enableJava8Formats() call left in the
125+
// shared static customClasses map by other tests (e.g. Ticket2992Test), so this checks
126+
// the true PrimitiveType default, regardless of test execution order.
127+
final String key = "java.time.LocalTime";
128+
final Map<String, PrimitiveType> custom = PrimitiveType.customClasses();
129+
final PrimitiveType previous = custom.remove(key);
130+
try {
131+
final Schema<?> schema = PrimitiveType.createProperty(LocalTime.class);
132+
assertNotNull(schema);
133+
assertEquals(schema.getClass(), TimeLocalSchema.class);
134+
assertEquals(schema.getFormat(), "time-local");
135+
} finally {
136+
if (previous == null) custom.remove(key);
137+
else custom.put(key, previous);
138+
}
139+
}
140+
141+
@Test
142+
public void testDefaultOffsetTime() {
143+
final Schema<?> schema = PrimitiveType.createProperty(OffsetTime.class);
144+
assertNotNull(schema);
145+
assertEquals(schema.getClass(), TimeSchema.class);
146+
assertEquals(schema.getFormat(), "time");
147+
}
148+
149+
@Test
150+
public void testDefaultDuration() {
151+
final Schema<?> schema = PrimitiveType.createProperty(Duration.class);
152+
assertNotNull(schema);
153+
assertEquals(schema.getClass(), DurationSchema.class);
154+
assertEquals(schema.getFormat(), "duration");
155+
}
156+
157+
@Test
158+
public void testDefaultFormats31() {
159+
assertSchema31(PrimitiveType.createProperty(OffsetTime.class, true), "time");
160+
assertSchema31(PrimitiveType.createProperty(Duration.class, true), "duration");
161+
162+
// LocalTime: same guard as testDefaultLocalTime — isolate from customClasses
163+
final String key = "java.time.LocalTime";
164+
final Map<String, PrimitiveType> custom = PrimitiveType.customClasses();
165+
final PrimitiveType previous = custom.remove(key);
166+
try {
167+
assertSchema31(PrimitiveType.createProperty(LocalTime.class, true), "time-local");
168+
} finally {
169+
if (previous == null) custom.remove(key);
170+
else custom.put(key, previous);
171+
}
172+
}
173+
174+
@Test
175+
public void testEnableJava8Formats31() {
176+
final Map<String, PrimitiveType> custom = PrimitiveType.customClasses();
177+
final PrimitiveType previous = custom.get("java.time.LocalDateTime");
178+
179+
PrimitiveType.enableJava8Formats();
180+
try {
181+
assertSchema31(PrimitiveType.createProperty(LocalDateTime.class, true), "date-time-local");
182+
} finally {
183+
if (previous == null) custom.remove("java.time.LocalDateTime");
184+
else custom.put("java.time.LocalDateTime", previous);
185+
}
186+
}
187+
188+
private static void assertSchema31(Schema<?> schema, String format) {
189+
assertNotNull(schema);
190+
assertNotNull(schema.getTypes());
191+
assertTrue(schema.getTypes().contains("string"));
192+
assertEquals(schema.getFormat(), format);
193+
}
194+
}

0 commit comments

Comments
 (0)