diff --git a/modules/swagger-core/src/main/java/io/swagger/v3/core/util/PrimitiveType.java b/modules/swagger-core/src/main/java/io/swagger/v3/core/util/PrimitiveType.java index 6d99fb7a2c..94f62d3983 100644 --- a/modules/swagger-core/src/main/java/io/swagger/v3/core/util/PrimitiveType.java +++ b/modules/swagger-core/src/main/java/io/swagger/v3/core/util/PrimitiveType.java @@ -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; @@ -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() { @@ -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"); @@ -361,6 +409,8 @@ 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"); EXTERNAL_CLASSES = Collections.unmodifiableMap(externalClasses); final Map names = new TreeMap<>(String.CASE_INSENSITIVE_ORDER); @@ -582,9 +632,33 @@ private DateStub() { * See https://xml2rfc.tools.ietf.org/public/rfc/html/rfc3339.html#anchor14 * * @since 2.0.6 + * @deprecated Use {@link #enableJava8Formats()} instead, which maps {@code java.time.LocalTime} + * to the OpenAPI Formats Registry format {@code "time-local"}. + * This method will be removed in the next major version. */ + @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 mappings for Java 8 date/time types: + * + * {@code java.time.OffsetTime} and {@code java.time.Duration} are already mapped + * by default to {@code "time"} and {@code "duration"} respectively, since their + * previous expansion as complex objects was always incorrect. + * + *

Note: {@code java.time.LocalDateTime} defaults to {@code "date-time"} for + * backward compatibility. The default will change in the next major version. + * + * @since 2.2.51 + */ + public static void enableJava8Formats() { + customClasses().put("java.time.LocalDateTime", PrimitiveType.DATE_TIME_LOCAL); + customClasses().put("java.time.LocalTime", PrimitiveType.TIME_LOCAL); + } } diff --git a/modules/swagger-core/src/test/java/io/swagger/v3/core/resolving/Java8DateFormatsTest.java b/modules/swagger-core/src/test/java/io/swagger/v3/core/resolving/Java8DateFormatsTest.java new file mode 100644 index 0000000000..a2aac89cb2 --- /dev/null +++ b/modules/swagger-core/src/test/java/io/swagger/v3/core/resolving/Java8DateFormatsTest.java @@ -0,0 +1,108 @@ +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 org.testng.annotations.Test; + +import java.util.Map; + +/** + * Verifies Java 8 date/time type → OpenAPI format mappings (issue #5172). + * + * Default behaviour (backward-compatible): + * OffsetTime → "time" (fixed; was incorrectly a complex object) + * Duration → "duration" (fixed; was incorrectly a complex object) + * LocalDateTime → "date-time" (unchanged for compatibility) + * LocalTime → complex object (unchanged; call enableJava8Formats() to opt in) + * + * Opt-in via PrimitiveType.enableJava8Formats(): + * LocalDateTime → "date-time-local" + * LocalTime → "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 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); + } + } +} diff --git a/modules/swagger-core/src/test/java/io/swagger/v3/core/resolving/resources/TestObjectJava8Dates.java b/modules/swagger-core/src/test/java/io/swagger/v3/core/resolving/resources/TestObjectJava8Dates.java new file mode 100644 index 0000000000..5284cc3727 --- /dev/null +++ b/modules/swagger-core/src/test/java/io/swagger/v3/core/resolving/resources/TestObjectJava8Dates.java @@ -0,0 +1,44 @@ +package io.swagger.v3.core.resolving.resources; + +import java.time.Duration; +import java.time.Instant; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.time.OffsetDateTime; +import java.time.OffsetTime; +import java.time.ZonedDateTime; + +// LocalTime is intentionally excluded: its default behaviour depends on whether +// enablePartialTime() has been called by a preceding test (shared static state). +// LocalTime opt-in behaviour is covered in Java8DateFormatsTest#testEnableJava8Formats. +public class TestObjectJava8Dates { + + private LocalDateTime localDateTime; + private OffsetDateTime offsetDateTime; + private ZonedDateTime zonedDateTime; + private Instant instant; + private LocalDate localDate; + private OffsetTime offsetTime; + private Duration duration; + + public LocalDateTime getLocalDateTime() { return localDateTime; } + public void setLocalDateTime(LocalDateTime localDateTime) { this.localDateTime = localDateTime; } + + public OffsetDateTime getOffsetDateTime() { return offsetDateTime; } + public void setOffsetDateTime(OffsetDateTime offsetDateTime) { this.offsetDateTime = offsetDateTime; } + + public ZonedDateTime getZonedDateTime() { return zonedDateTime; } + public void setZonedDateTime(ZonedDateTime zonedDateTime) { this.zonedDateTime = zonedDateTime; } + + public Instant getInstant() { return instant; } + public void setInstant(Instant instant) { this.instant = instant; } + + public LocalDate getLocalDate() { return localDate; } + public void setLocalDate(LocalDate localDate) { this.localDate = localDate; } + + public OffsetTime getOffsetTime() { return offsetTime; } + public void setOffsetTime(OffsetTime offsetTime) { this.offsetTime = offsetTime; } + + public Duration getDuration() { return duration; } + public void setDuration(Duration duration) { this.duration = duration; } +} diff --git a/modules/swagger-models/src/main/java/io/swagger/v3/oas/models/media/DateTimeLocalSchema.java b/modules/swagger-models/src/main/java/io/swagger/v3/oas/models/media/DateTimeLocalSchema.java new file mode 100644 index 0000000000..6983360152 --- /dev/null +++ b/modules/swagger-models/src/main/java/io/swagger/v3/oas/models/media/DateTimeLocalSchema.java @@ -0,0 +1,66 @@ +package io.swagger.v3.oas.models.media; + +import java.time.LocalDateTime; +import java.util.Objects; + +/** + * DateTimeLocalSchema + */ +public class DateTimeLocalSchema extends Schema { + + public DateTimeLocalSchema() { + super("string", "date-time-local"); + } + + @Override + public DateTimeLocalSchema type(String type) { + super.setType(type); + return this; + } + + @Override + public DateTimeLocalSchema format(String format) { + super.setFormat(format); + return this; + } + + @Override + protected LocalDateTime cast(Object value) { + if (value != null) { + try { + if (value instanceof LocalDateTime) { + return (LocalDateTime) value; + } else if (value instanceof String) { + return LocalDateTime.parse((String) value); + } + } catch (Exception e) { + } + } + return null; + } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (o == null || getClass() != o.getClass()) { + return false; + } + return super.equals(o); + } + + @Override + public int hashCode() { + return Objects.hash(super.hashCode()); + } + + @Override + public String toString() { + StringBuilder sb = new StringBuilder(); + sb.append("class DateTimeLocalSchema {\n"); + sb.append(" ").append(toIndentedString(super.toString())).append("\n"); + sb.append("}"); + return sb.toString(); + } +} diff --git a/modules/swagger-models/src/main/java/io/swagger/v3/oas/models/media/DurationSchema.java b/modules/swagger-models/src/main/java/io/swagger/v3/oas/models/media/DurationSchema.java new file mode 100644 index 0000000000..3c031568bb --- /dev/null +++ b/modules/swagger-models/src/main/java/io/swagger/v3/oas/models/media/DurationSchema.java @@ -0,0 +1,66 @@ +package io.swagger.v3.oas.models.media; + +import java.time.Duration; +import java.util.Objects; + +/** + * DurationSchema + */ +public class DurationSchema extends Schema { + + public DurationSchema() { + super("string", "duration"); + } + + @Override + public DurationSchema type(String type) { + super.setType(type); + return this; + } + + @Override + public DurationSchema format(String format) { + super.setFormat(format); + return this; + } + + @Override + protected Duration cast(Object value) { + if (value != null) { + try { + if (value instanceof Duration) { + return (Duration) value; + } else if (value instanceof String) { + return Duration.parse((String) value); + } + } catch (Exception e) { + } + } + return null; + } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (o == null || getClass() != o.getClass()) { + return false; + } + return super.equals(o); + } + + @Override + public int hashCode() { + return Objects.hash(super.hashCode()); + } + + @Override + public String toString() { + StringBuilder sb = new StringBuilder(); + sb.append("class DurationSchema {\n"); + sb.append(" ").append(toIndentedString(super.toString())).append("\n"); + sb.append("}"); + return sb.toString(); + } +} diff --git a/modules/swagger-models/src/main/java/io/swagger/v3/oas/models/media/TimeLocalSchema.java b/modules/swagger-models/src/main/java/io/swagger/v3/oas/models/media/TimeLocalSchema.java new file mode 100644 index 0000000000..08a9738d4d --- /dev/null +++ b/modules/swagger-models/src/main/java/io/swagger/v3/oas/models/media/TimeLocalSchema.java @@ -0,0 +1,66 @@ +package io.swagger.v3.oas.models.media; + +import java.time.LocalTime; +import java.util.Objects; + +/** + * TimeLocalSchema + */ +public class TimeLocalSchema extends Schema { + + public TimeLocalSchema() { + super("string", "time-local"); + } + + @Override + public TimeLocalSchema type(String type) { + super.setType(type); + return this; + } + + @Override + public TimeLocalSchema format(String format) { + super.setFormat(format); + return this; + } + + @Override + protected LocalTime cast(Object value) { + if (value != null) { + try { + if (value instanceof LocalTime) { + return (LocalTime) value; + } else if (value instanceof String) { + return LocalTime.parse((String) value); + } + } catch (Exception e) { + } + } + return null; + } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (o == null || getClass() != o.getClass()) { + return false; + } + return super.equals(o); + } + + @Override + public int hashCode() { + return Objects.hash(super.hashCode()); + } + + @Override + public String toString() { + StringBuilder sb = new StringBuilder(); + sb.append("class TimeLocalSchema {\n"); + sb.append(" ").append(toIndentedString(super.toString())).append("\n"); + sb.append("}"); + return sb.toString(); + } +} diff --git a/modules/swagger-models/src/main/java/io/swagger/v3/oas/models/media/TimeSchema.java b/modules/swagger-models/src/main/java/io/swagger/v3/oas/models/media/TimeSchema.java new file mode 100644 index 0000000000..c14a5251ed --- /dev/null +++ b/modules/swagger-models/src/main/java/io/swagger/v3/oas/models/media/TimeSchema.java @@ -0,0 +1,66 @@ +package io.swagger.v3.oas.models.media; + +import java.time.OffsetTime; +import java.util.Objects; + +/** + * TimeSchema + */ +public class TimeSchema extends Schema { + + public TimeSchema() { + super("string", "time"); + } + + @Override + public TimeSchema type(String type) { + super.setType(type); + return this; + } + + @Override + public TimeSchema format(String format) { + super.setFormat(format); + return this; + } + + @Override + protected OffsetTime cast(Object value) { + if (value != null) { + try { + if (value instanceof OffsetTime) { + return (OffsetTime) value; + } else if (value instanceof String) { + return OffsetTime.parse((String) value); + } + } catch (Exception e) { + } + } + return null; + } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (o == null || getClass() != o.getClass()) { + return false; + } + return super.equals(o); + } + + @Override + public int hashCode() { + return Objects.hash(super.hashCode()); + } + + @Override + public String toString() { + StringBuilder sb = new StringBuilder(); + sb.append("class TimeSchema {\n"); + sb.append(" ").append(toIndentedString(super.toString())).append("\n"); + sb.append("}"); + return sb.toString(); + } +}