Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,9 @@
import io.swagger.v3.oas.models.media.BooleanSchema;
import io.swagger.v3.oas.models.media.ComposedSchema;
import io.swagger.v3.oas.models.media.DateSchema;
import io.swagger.v3.oas.models.media.DateTimeLocalSchema;
import io.swagger.v3.oas.models.media.DateTimeSchema;
import io.swagger.v3.oas.models.media.DurationSchema;
import io.swagger.v3.oas.models.media.EmailSchema;
import io.swagger.v3.oas.models.media.IntegerSchema;
import io.swagger.v3.oas.models.media.JsonSchema;
Expand All @@ -22,6 +24,8 @@
import io.swagger.v3.oas.models.media.PasswordSchema;
import io.swagger.v3.oas.models.media.Schema;
import io.swagger.v3.oas.models.media.StringSchema;
import io.swagger.v3.oas.models.media.TimeLocalSchema;
import io.swagger.v3.oas.models.media.TimeSchema;
import io.swagger.v3.oas.models.media.UUIDSchema;
import org.apache.commons.lang3.StringUtils;

Expand All @@ -46,6 +50,10 @@ public class ModelDeserializer extends JsonDeserializer<Schema> {
private static final String FORMAT = "format";
private static final String DATE_FORMAT = "date";
private static final String DATE_TIME_FORMAT = "date-time";
private static final String TIME_FORMAT = "time";
private static final String DURATION_FORMAT = "duration";
private static final String DATE_TIME_LOCAL_FORMAT = "date-time-local";
private static final String TIME_LOCAL_FORMAT = "time-local";
private static final String EMAIL_FORMAT = "email";
private static final String PASSWORD_FORMAT = "password";
private static final String UUID_FORMAT = "uuid";
Expand Down Expand Up @@ -196,7 +204,15 @@ private Schema deserializeSchemaWithType(JsonNode node, JsonNode typeNode) {
schema = Json.mapper().convertValue(node, DateSchema.class);
} else if (DATE_TIME_FORMAT.equals(format)) {
schema = Json.mapper().convertValue(node, DateTimeSchema.class);
} else if (EMAIL_FORMAT.equals(format)) {
} else if (TIME_FORMAT.equals(format)) {
schema = Json.mapper().convertValue(node, TimeSchema.class);
} else if (DURATION_FORMAT.equals(format)) {
schema = Json.mapper().convertValue(node, DurationSchema.class);
} else if (DATE_TIME_LOCAL_FORMAT.equals(format)) {
schema = Json.mapper().convertValue(node, DateTimeLocalSchema.class);
} else if (TIME_LOCAL_FORMAT.equals(format)) {
schema = Json.mapper().convertValue(node, TimeLocalSchema.class);
}else if (EMAIL_FORMAT.equals(format)) {
schema = Json.mapper().convertValue(node, EmailSchema.class);
} else if (PASSWORD_FORMAT.equals(format)) {
schema = Json.mapper().convertValue(node, PasswordSchema.class);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,13 +5,17 @@
import io.swagger.v3.oas.models.media.BooleanSchema;
import io.swagger.v3.oas.models.media.ByteArraySchema;
import io.swagger.v3.oas.models.media.DateSchema;
import io.swagger.v3.oas.models.media.DateTimeLocalSchema;
import io.swagger.v3.oas.models.media.DateTimeSchema;
import io.swagger.v3.oas.models.media.DurationSchema;
import io.swagger.v3.oas.models.media.FileSchema;
import io.swagger.v3.oas.models.media.IntegerSchema;
import io.swagger.v3.oas.models.media.JsonSchema;
import io.swagger.v3.oas.models.media.NumberSchema;
import io.swagger.v3.oas.models.media.Schema;
import io.swagger.v3.oas.models.media.StringSchema;
import io.swagger.v3.oas.models.media.TimeLocalSchema;
import io.swagger.v3.oas.models.media.TimeSchema;
import io.swagger.v3.oas.models.media.UUIDSchema;
import org.apache.commons.lang3.StringUtils;

Expand Down Expand Up @@ -221,6 +225,46 @@ public Schema createProperty31() {
return new JsonSchema().typesItem("string").format("partial-time");
}
},
DATE_TIME_LOCAL(java.time.LocalDateTime.class, "date-time-local") {
@Override
public Schema createProperty() {
return new DateTimeLocalSchema();
}
@Override
public Schema createProperty31() {
return new JsonSchema().typesItem("string").format("date-time-local");
}
},
TIME(java.time.OffsetTime.class, "time") {
@Override
public Schema createProperty() {
return new TimeSchema();
}
@Override
public Schema createProperty31() {
return new JsonSchema().typesItem("string").format("time");
}
},
TIME_LOCAL(java.time.LocalTime.class, "time-local") {
@Override
public Schema createProperty() {
return new TimeLocalSchema();
}
@Override
public Schema createProperty31() {
return new JsonSchema().typesItem("string").format("time-local");
}
},
DURATION(java.time.Duration.class, "duration") {
@Override
public Schema createProperty() {
return new DurationSchema();
}
@Override
public Schema createProperty31() {
return new JsonSchema().typesItem("string").format("duration");
}
},
FILE(java.io.File.class, "file") {
@Override
public FileSchema createProperty() {
Expand Down Expand Up @@ -315,6 +359,10 @@ public Schema createProperty31() {
dms.put("string_uuid", "uuid");
dms.put("string_date", "date");
dms.put("string_date-time", "date-time");
dms.put("string_date-time-local", "date-time-local");
dms.put("string_time", "time");
dms.put("string_time-local", "time-local");
dms.put("string_duration", "duration");
dms.put("string_partial-time", "partial-time");
dms.put("string_password", "password");
dms.put("boolean_", "boolean");
Expand Down Expand Up @@ -361,6 +409,9 @@ public Schema createProperty31() {
"org.joda.time.ReadableDateTime",
"org.joda.time.DateTime",
"java.time.Instant");
addKeys(externalClasses, TIME, "java.time.OffsetTime");
addKeys(externalClasses, DURATION, "java.time.Duration");
addKeys(externalClasses, TIME_LOCAL, "java.time.LocalTime");
EXTERNAL_CLASSES = Collections.unmodifiableMap(externalClasses);

final Map<String, PrimitiveType> names = new TreeMap<>(String.CASE_INSENSITIVE_ORDER);
Expand Down Expand Up @@ -581,10 +632,28 @@ private DateStub() {
* Convenience method to map LocalTime to string primitive with rfc3339 format partial-time.
* See https://xml2rfc.tools.ietf.org/public/rfc/html/rfc3339.html#anchor14
*
* <p>{@code "partial-time"} is not itself a registered OpenAPI Formats Registry value
* (it is borrowed from RFC 3339 grammar). {@code java.time.LocalTime} already defaults to
* the registry-compliant {@code "time-local"}; this method overrides that default for
* callers who specifically need the {@code "partial-time"} format instead, since it is a
* different mapping, not a strict replacement for it.
*
* @since 2.0.6
* @deprecated Prefer the default {@code "time-local"} mapping for {@code java.time.LocalTime}.
*/
@Deprecated
public static void enablePartialTime() {
customClasses().put("org.joda.time.LocalTime", PrimitiveType.PARTIAL_TIME);
customClasses().put("java.time.LocalTime", PrimitiveType.PARTIAL_TIME);
}

/**
* Opts in to the OpenAPI Formats Registry mapping for {@code java.time.LocalDateTime}:
* maps it to format {@code "date-time-local"} instead of the default {@code "date-time"}.
*
* @since 2.3.0
*/
public static void enableJava8Formats() {
customClasses().put("java.time.LocalDateTime", PrimitiveType.DATE_TIME_LOCAL);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,194 @@
package io.swagger.v3.core.resolving;

import io.swagger.v3.core.converter.AnnotatedType;
import io.swagger.v3.core.converter.ModelConverterContextImpl;
import io.swagger.v3.core.jackson.ModelResolver;
import io.swagger.v3.core.matchers.SerializationMatchers;
import io.swagger.v3.core.resolving.resources.TestObjectJava8Dates;
import io.swagger.v3.core.resolving.resources.TestObject2992;
import io.swagger.v3.core.util.PrimitiveType;
import io.swagger.v3.oas.models.media.DurationSchema;
import io.swagger.v3.oas.models.media.Schema;
import io.swagger.v3.oas.models.media.TimeLocalSchema;
import io.swagger.v3.oas.models.media.TimeSchema;
import org.testng.annotations.Test;

import java.time.Duration;
import java.time.LocalDateTime;
import java.time.LocalTime;
import java.time.OffsetTime;
import java.util.Map;

import static org.testng.Assert.assertEquals;
import static org.testng.Assert.assertNotNull;
import static org.testng.Assert.assertTrue;

/**
* Verifies Java 8 date/time type → OpenAPI format mappings (issue #5172).
*
* Default behaviour (fixed; previous complex-object expansion was always incorrect):
* OffsetTime → "time"
* Duration → "duration"
* LocalTime → "time-local"
*
* Default behaviour (unchanged for compatibility):
* LocalDateTime → "date-time"
*
* Opt-in via PrimitiveType.enableJava8Formats():
* LocalDateTime → "date-time-local"
*/
public class Java8DateFormatsTest extends SwaggerTestBase {

@Test
public void testDefaultFormats() throws Exception {
final ModelResolver modelResolver = new ModelResolver(mapper());
final ModelConverterContextImpl context = new ModelConverterContextImpl(modelResolver);

context.resolve(new AnnotatedType(TestObjectJava8Dates.class));

SerializationMatchers.assertEqualsToYaml(context.getDefinedModels(), "TestObjectJava8Dates:\n" +
" type: object\n" +
" properties:\n" +
" localDateTime:\n" +
" type: string\n" +
" format: date-time\n" +
" offsetDateTime:\n" +
" type: string\n" +
" format: date-time\n" +
" zonedDateTime:\n" +
" type: string\n" +
" format: date-time\n" +
" instant:\n" +
" type: string\n" +
" format: date-time\n" +
" localDate:\n" +
" type: string\n" +
" format: date\n" +
" offsetTime:\n" +
" type: string\n" +
" format: time\n" +
" duration:\n" +
" type: string\n" +
" format: duration");
}

@Test
public void testEnableJava8Formats() throws Exception {
// Save current state so other tests are not affected by the static customClasses map
final Map<String, PrimitiveType> custom = PrimitiveType.customClasses();
final PrimitiveType prevLocalDateTime = custom.get("java.time.LocalDateTime");
final PrimitiveType prevLocalTime = custom.get("java.time.LocalTime");

PrimitiveType.enableJava8Formats();
try {
final ModelResolver modelResolver = new ModelResolver(mapper());
final ModelConverterContextImpl context = new ModelConverterContextImpl(modelResolver);

context.resolve(new AnnotatedType(TestObject2992.class));

// LocalDateTime → "date-time-local", LocalTime → "time-local" after opt-in
SerializationMatchers.assertEqualsToYaml(context.getDefinedModels(), "TestObject2992:\n" +
" type: object\n" +
" properties:\n" +
" name:\n" +
" type: string\n" +
" a:\n" +
" type: string\n" +
" format: time-local\n" +
" b:\n" +
" type: string\n" +
" format: time-local\n" +
" c:\n" +
" type: string\n" +
" format: time-local\n" +
" d:\n" +
" type: string\n" +
" format: date-time-local\n" +
" e:\n" +
" type: string\n" +
" format: date-time-local\n" +
" f:\n" +
" type: string\n" +
" format: date-time-local");
} finally {
// Restore previous state so subsequent tests are not affected
if (prevLocalDateTime == null) custom.remove("java.time.LocalDateTime");
else custom.put("java.time.LocalDateTime", prevLocalDateTime);
if (prevLocalTime == null) custom.remove("java.time.LocalTime");
else custom.put("java.time.LocalTime", prevLocalTime);
}
}

@Test
public void testDefaultLocalTime() {
// Isolate from any prior enablePartialTime()/enableJava8Formats() call left in the
// shared static customClasses map by other tests (e.g. Ticket2992Test), so this checks
// the true PrimitiveType default, regardless of test execution order.
final String key = "java.time.LocalTime";
final Map<String, PrimitiveType> custom = PrimitiveType.customClasses();
final PrimitiveType previous = custom.remove(key);
try {
final Schema<?> schema = PrimitiveType.createProperty(LocalTime.class);
assertNotNull(schema);
assertEquals(schema.getClass(), TimeLocalSchema.class);
assertEquals(schema.getFormat(), "time-local");
} finally {
if (previous == null) custom.remove(key);
else custom.put(key, previous);
}
}

@Test
public void testDefaultOffsetTime() {
final Schema<?> schema = PrimitiveType.createProperty(OffsetTime.class);
assertNotNull(schema);
assertEquals(schema.getClass(), TimeSchema.class);
assertEquals(schema.getFormat(), "time");
}

@Test
public void testDefaultDuration() {
final Schema<?> schema = PrimitiveType.createProperty(Duration.class);
assertNotNull(schema);
assertEquals(schema.getClass(), DurationSchema.class);
assertEquals(schema.getFormat(), "duration");
}

@Test
public void testDefaultFormats31() {
assertSchema31(PrimitiveType.createProperty(OffsetTime.class, true), "time");
assertSchema31(PrimitiveType.createProperty(Duration.class, true), "duration");

// LocalTime: same guard as testDefaultLocalTime — isolate from customClasses
final String key = "java.time.LocalTime";
final Map<String, PrimitiveType> custom = PrimitiveType.customClasses();
final PrimitiveType previous = custom.remove(key);
try {
assertSchema31(PrimitiveType.createProperty(LocalTime.class, true), "time-local");
} finally {
if (previous == null) custom.remove(key);
else custom.put(key, previous);
}
}

@Test
public void testEnableJava8Formats31() {
final Map<String, PrimitiveType> custom = PrimitiveType.customClasses();
final PrimitiveType previous = custom.get("java.time.LocalDateTime");

PrimitiveType.enableJava8Formats();
try {
assertSchema31(PrimitiveType.createProperty(LocalDateTime.class, true), "date-time-local");
} finally {
if (previous == null) custom.remove("java.time.LocalDateTime");
else custom.put("java.time.LocalDateTime", previous);
}
}

private static void assertSchema31(Schema<?> schema, String format) {
assertNotNull(schema);
assertNotNull(schema.getTypes());
assertTrue(schema.getTypes().contains("string"));
assertEquals(schema.getFormat(), format);
}
}
Loading
Loading