Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: support the "format" standard built-in tags (without the OpenAPI ones) #399

Merged
merged 11 commits into from
Nov 9, 2023
Original file line number Diff line number Diff line change
Expand Up @@ -286,6 +286,20 @@ public enum Option {
* @since 4.11.0
*/
PLAIN_DEFINITION_KEYS(null, null),

/**
* For the "format" attribute, JSON Schema defines various built-in supported values.
* <br>
* Some of those data-types would be handed if either {@link #ADDITIONAL_FIXED_TYPES}
* or a custom {@link SimpleTypeModule} (i.e., {@link SimpleTypeModule#forPrimitiveTypes()})
* are added.
* In that case, by enabling this option these standard built-in "format" values would be added.
* Note that if {@link #EXTRA_OPEN_API_FORMAT_VALUES} is enabled, it overrides this option as
* it include extra "format" attributes.
*
* @since 4.32.1
*/
STANDARD_FORMATS(null, null),
/**
* For the "format" attribute, JSON Schema defines various supported values. The OpenAPI specification assigns a few more of those in order to
* differentiate between standard data types (e.g. float vs. double) and even some more fixed data types (e.g. LocalDate, LocalDateTime) if
Expand All @@ -294,7 +308,7 @@ public enum Option {
*
* @since 4.15.0
*/
EXTRA_OPEN_API_FORMAT_VALUES(null, null),
EXTRA_OPEN_API_FORMAT_VALUES(null, null, Option.STANDARD_FORMATS),
/**
* Whether as the last step of the schema generation, unnecessary "allOf" elements (i.e. where there are no conflicts/overlaps between the
* contained sub-schemas) should be merged into one, in order to make the generated schema more readable. This also applies to manually added
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,13 @@ public interface SchemaGeneratorConfig extends StatefulConfig {
*/
boolean shouldUsePlainDefinitionKeys();

/**
* Determine whether standard {@link SchemaKeyword#TAG_FORMAT} values should be included for "simple types".
*
* @return whether to include standard {@link SchemaKeyword#TAG_FORMAT} values
*/
boolean shouldIncludeStandardFormatValues();

/**
* Determine whether extra {@link SchemaKeyword#TAG_FORMAT} values should be included for "simple types".
*
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -138,6 +138,11 @@ public boolean shouldUsePlainDefinitionKeys() {
return this.isOptionEnabled(Option.PLAIN_DEFINITION_KEYS);
}

@Override
public boolean shouldIncludeStandardFormatValues() {
return this.isOptionEnabled(Option.STANDARD_FORMATS);
}

@Override
public boolean shouldIncludeExtraOpenApiFormatValues() {
return this.isOptionEnabled(Option.EXTRA_OPEN_API_FORMAT_VALUES);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,8 +27,10 @@
import com.github.victools.jsonschema.generator.SchemaKeyword;
import com.github.victools.jsonschema.generator.TypeScope;
import java.lang.reflect.Type;
import java.util.ArrayList;
import java.util.Collections;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.stream.Stream;

Expand Down Expand Up @@ -81,17 +83,18 @@ public static SimpleTypeModule forPrimitiveTypes() {
public static SimpleTypeModule forPrimitiveAndAdditionalTypes() {
SimpleTypeModule module = SimpleTypeModule.forPrimitiveTypes();

module.withStringType(java.time.LocalDate.class, "date");
module.withStandardStringType(java.time.LocalDate.class, "date");
Stream.of(java.time.LocalDateTime.class, java.time.ZonedDateTime.class,
java.time.OffsetDateTime.class, java.time.Instant.class,
java.util.Date.class, java.util.Calendar.class)
.forEach(javaType -> module.withStringType(javaType, "date-time"));
.forEach(javaType -> module.withStandardStringType(javaType, "date-time"));
Stream.of(java.time.LocalTime.class, java.time.OffsetTime.class)
.forEach(javaType -> module.withStringType(javaType, "time"));
module.withStringType(java.util.UUID.class, "uuid");
module.withStringType(java.net.URI.class, "uri");
.forEach(javaType -> module.withStandardStringType(javaType, "time"));
Stream.of(java.time.Duration.class, java.time.Period.class)
.forEach(javaType -> module.withStandardStringType(javaType, "duration"));
module.withStandardStringType(java.util.UUID.class, "uuid");
module.withStandardStringType(java.net.URI.class, "uri");
module.withStringType(java.time.ZoneId.class);
module.withStringType(java.time.Period.class);
module.withIntegerType(java.math.BigInteger.class);

Stream.of(java.math.BigDecimal.class, Number.class)
Expand All @@ -101,6 +104,7 @@ public static SimpleTypeModule forPrimitiveAndAdditionalTypes() {
}

private final Map<Class<?>, SchemaKeyword> fixedJsonSchemaTypes = new HashMap<>();
private final List<Class<?>> standardFormats = new ArrayList<>();
private final Map<Class<?>, String> extraOpenApiFormatValues = new HashMap<>();

/**
Expand Down Expand Up @@ -162,6 +166,12 @@ public final SimpleTypeModule withStringType(Class<?> javaType, String openApiFo
return this.with(javaType, SchemaKeyword.TAG_TYPE_STRING, openApiFormat);
}

private final SimpleTypeModule withStandardStringType(Class<?> javaType, final String format) {
// track as a standard format
this.standardFormats.add(javaType);
return this.withStringType(javaType, format);
}

/**
* Add the given mapping for a (simple) java class to its JSON schema equivalent "type" attribute: "{@link SchemaKeyword#TAG_TYPE_BOOLEAN}".
*
Expand Down Expand Up @@ -286,7 +296,7 @@ public CustomDefinition provideCustomSchemaDefinition(ResolvedType javaType, Sch
if (jsonSchemaTypeValue != SchemaKeyword.TAG_TYPE_NULL) {
customSchema.put(context.getKeyword(SchemaKeyword.TAG_TYPE), context.getKeyword(jsonSchemaTypeValue));
}
if (context.getGeneratorConfig().shouldIncludeExtraOpenApiFormatValues()) {
if (shouldAddFormatTag(javaType, context)) {
String formatValue = SimpleTypeModule.this.extraOpenApiFormatValues.get(javaType.getErasedType());
if (formatValue != null) {
customSchema.put(context.getKeyword(SchemaKeyword.TAG_FORMAT), formatValue);
Expand All @@ -295,5 +305,12 @@ public CustomDefinition provideCustomSchemaDefinition(ResolvedType javaType, Sch
// set true as second parameter to indicate simple types to be always in-lined (i.e. not put into definitions)
return new CustomDefinition(customSchema, CustomDefinition.DefinitionType.INLINE, CustomDefinition.AttributeInclusion.YES);
}

private boolean shouldAddFormatTag(final ResolvedType javaType, final SchemaGenerationContext context) {
// either OpenAPI extra formats or standard-formats that are registered
return context.getGeneratorConfig().shouldIncludeExtraOpenApiFormatValues()
|| (context.getGeneratorConfig().shouldIncludeStandardFormatValues()
&& standardFormats.contains(javaType.getErasedType()));
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -32,40 +32,43 @@
public class SchemaGeneratorSimpleTypesTest {

static Stream<Arguments> getSimpleTypeCombinations() {
// order of arguments: targetType, expectedJsonSchemaType, expectedOpenApiFormat, standardFormat
return Stream.of(
Arguments.of(Object.class, null, null),
Arguments.of(String.class, SchemaKeyword.TAG_TYPE_STRING, null),
Arguments.of(Character.class, SchemaKeyword.TAG_TYPE_STRING, null),
Arguments.of(char.class, SchemaKeyword.TAG_TYPE_STRING, null),
Arguments.of(CharSequence.class, SchemaKeyword.TAG_TYPE_STRING, null),
Arguments.of(Byte.class, SchemaKeyword.TAG_TYPE_STRING, null),
Arguments.of(byte.class, SchemaKeyword.TAG_TYPE_STRING, null),
Arguments.of(Boolean.class, SchemaKeyword.TAG_TYPE_BOOLEAN, null),
Arguments.of(boolean.class, SchemaKeyword.TAG_TYPE_BOOLEAN, null),
Arguments.of(Integer.class, SchemaKeyword.TAG_TYPE_INTEGER, "int32"),
Arguments.of(int.class, SchemaKeyword.TAG_TYPE_INTEGER, "int32"),
Arguments.of(Long.class, SchemaKeyword.TAG_TYPE_INTEGER, "int64"),
Arguments.of(long.class, SchemaKeyword.TAG_TYPE_INTEGER, "int64"),
Arguments.of(Short.class, SchemaKeyword.TAG_TYPE_INTEGER, null),
Arguments.of(short.class, SchemaKeyword.TAG_TYPE_INTEGER, null),
Arguments.of(Double.class, SchemaKeyword.TAG_TYPE_NUMBER, "double"),
Arguments.of(double.class, SchemaKeyword.TAG_TYPE_NUMBER, "double"),
Arguments.of(Float.class, SchemaKeyword.TAG_TYPE_NUMBER, "float"),
Arguments.of(float.class, SchemaKeyword.TAG_TYPE_NUMBER, "float"),
Arguments.of(java.time.LocalDate.class, SchemaKeyword.TAG_TYPE_STRING, "date"),
Arguments.of(java.time.LocalDateTime.class, SchemaKeyword.TAG_TYPE_STRING, "date-time"),
Arguments.of(java.time.LocalTime.class, SchemaKeyword.TAG_TYPE_STRING, "time"),
Arguments.of(java.time.ZonedDateTime.class, SchemaKeyword.TAG_TYPE_STRING, "date-time"),
Arguments.of(java.time.OffsetDateTime.class, SchemaKeyword.TAG_TYPE_STRING, "date-time"),
Arguments.of(java.time.OffsetTime.class, SchemaKeyword.TAG_TYPE_STRING, "time"),
Arguments.of(java.time.Instant.class, SchemaKeyword.TAG_TYPE_STRING, "date-time"),
Arguments.of(java.util.Date.class, SchemaKeyword.TAG_TYPE_STRING, "date-time"),
Arguments.of(java.util.Calendar.class, SchemaKeyword.TAG_TYPE_STRING, "date-time"),
Arguments.of(java.util.UUID.class, SchemaKeyword.TAG_TYPE_STRING, "uuid"),
Arguments.of(java.time.ZoneId.class, SchemaKeyword.TAG_TYPE_STRING, null),
Arguments.of(java.math.BigInteger.class, SchemaKeyword.TAG_TYPE_INTEGER, null),
Arguments.of(java.math.BigDecimal.class, SchemaKeyword.TAG_TYPE_NUMBER, null),
Arguments.of(Number.class, SchemaKeyword.TAG_TYPE_NUMBER, null)
Arguments.of(Object.class, null, null, null),
Arguments.of(String.class, SchemaKeyword.TAG_TYPE_STRING, null, null),
Arguments.of(Character.class, SchemaKeyword.TAG_TYPE_STRING, null, null),
Arguments.of(char.class, SchemaKeyword.TAG_TYPE_STRING, null, null),
Arguments.of(CharSequence.class, SchemaKeyword.TAG_TYPE_STRING, null, null),
Arguments.of(Byte.class, SchemaKeyword.TAG_TYPE_STRING, null, null),
Arguments.of(byte.class, SchemaKeyword.TAG_TYPE_STRING, null, null),
Arguments.of(Boolean.class, SchemaKeyword.TAG_TYPE_BOOLEAN, null, null),
Arguments.of(boolean.class, SchemaKeyword.TAG_TYPE_BOOLEAN, null, null),
Arguments.of(Integer.class, SchemaKeyword.TAG_TYPE_INTEGER, "int32", null),
Arguments.of(int.class, SchemaKeyword.TAG_TYPE_INTEGER, "int32", null),
Arguments.of(Long.class, SchemaKeyword.TAG_TYPE_INTEGER, "int64", null),
Arguments.of(long.class, SchemaKeyword.TAG_TYPE_INTEGER, "int64", null),
Arguments.of(Short.class, SchemaKeyword.TAG_TYPE_INTEGER, null, null),
Arguments.of(short.class, SchemaKeyword.TAG_TYPE_INTEGER, null, null),
Arguments.of(Double.class, SchemaKeyword.TAG_TYPE_NUMBER, "double", null, null),
Arguments.of(double.class, SchemaKeyword.TAG_TYPE_NUMBER, "double", null, null),
Arguments.of(Float.class, SchemaKeyword.TAG_TYPE_NUMBER, "float", null, null),
Arguments.of(float.class, SchemaKeyword.TAG_TYPE_NUMBER, "float", null, null),
Arguments.of(java.time.LocalDate.class, SchemaKeyword.TAG_TYPE_STRING, "date", "date"),
Arguments.of(java.time.LocalDateTime.class, SchemaKeyword.TAG_TYPE_STRING, "date-time", "date-time"),
Arguments.of(java.time.LocalTime.class, SchemaKeyword.TAG_TYPE_STRING, "time", "time"),
Arguments.of(java.time.ZonedDateTime.class, SchemaKeyword.TAG_TYPE_STRING, "date-time", "date-time"),
Arguments.of(java.time.OffsetDateTime.class, SchemaKeyword.TAG_TYPE_STRING, "date-time", "date-time"),
Arguments.of(java.time.OffsetTime.class, SchemaKeyword.TAG_TYPE_STRING, "time", "time"),
Arguments.of(java.time.Instant.class, SchemaKeyword.TAG_TYPE_STRING, "date-time", "date-time"),
Arguments.of(java.util.Date.class, SchemaKeyword.TAG_TYPE_STRING, "date-time", "date-time"),
Arguments.of(java.util.Calendar.class, SchemaKeyword.TAG_TYPE_STRING, "date-time", "date-time"),
Arguments.of(java.time.Duration.class, SchemaKeyword.TAG_TYPE_STRING, "duration", "duration"),
Arguments.of(java.time.Period.class, SchemaKeyword.TAG_TYPE_STRING, "duration", "duration"),
Arguments.of(java.util.UUID.class, SchemaKeyword.TAG_TYPE_STRING, "uuid", "uuid"),
Arguments.of(java.time.ZoneId.class, SchemaKeyword.TAG_TYPE_STRING, null, null),
Arguments.of(java.math.BigInteger.class, SchemaKeyword.TAG_TYPE_INTEGER, null, null),
Arguments.of(java.math.BigDecimal.class, SchemaKeyword.TAG_TYPE_NUMBER, null, null),
Arguments.of(Number.class, SchemaKeyword.TAG_TYPE_NUMBER, null, null)
);
}

Expand All @@ -83,6 +86,13 @@ static Stream<Arguments> parametersForTestGenerateSchema_SimpleTypeWithFormat()
.map(entry -> Arguments.of(entry.get()[0], entry.get()[1], entry.get()[2], schemaVersion)));
}

static Stream<Arguments> parametersForTestGenerateSchema_SimpleTypeWithStandardFormat() {
List<Arguments> typeCombinations = getSimpleTypeCombinations().collect(Collectors.toList());
return EnumSet.allOf(SchemaVersion.class).stream()
.flatMap(schemaVersion -> typeCombinations.stream()
.map(entry -> Arguments.of(entry.get()[0], entry.get()[1], entry.get()[3], schemaVersion)));
}

@ParameterizedTest
@MethodSource("parametersForTestGenerateSchema_SimpleTypeWithoutFormat")
public void testGenerateSchema_SimpleTypeWithoutFormat(Class<?> targetType, SchemaKeyword expectedJsonSchemaType, SchemaVersion schemaVersion)
Expand Down Expand Up @@ -141,4 +151,27 @@ public void testGenerateSchema_SimpleType_withAdditionalPropertiesOption(Class<?
result.get(SchemaKeyword.TAG_TYPE.forVersion(schemaVersion)).asText());
}
}

@ParameterizedTest
@MethodSource("parametersForTestGenerateSchema_SimpleTypeWithStandardFormat")
public void testGenerateSchema_SimpleTypeWithStandardFormat(Class<?> targetType, SchemaKeyword expectedJsonSchemaType, String expectedFormat,
SchemaVersion schemaVersion) throws Exception {
SchemaGeneratorConfig config = new SchemaGeneratorConfigBuilder(schemaVersion)
.with(Option.ADDITIONAL_FIXED_TYPES, Option.STANDARD_FORMATS)
.build();
SchemaGenerator generator = new SchemaGenerator(config);
JsonNode result = generator.generateSchema(targetType);
if (expectedJsonSchemaType == null) {
Assertions.assertTrue(result.isEmpty());
} else if (expectedFormat == null) {
Assertions.assertEquals(1, result.size());
Assertions.assertEquals(expectedJsonSchemaType.forVersion(schemaVersion),
result.get(SchemaKeyword.TAG_TYPE.forVersion(schemaVersion)).asText());
} else {
Assertions.assertEquals(2, result.size());
Assertions.assertEquals(expectedJsonSchemaType.forVersion(schemaVersion),
result.get(SchemaKeyword.TAG_TYPE.forVersion(schemaVersion)).asText());
Assertions.assertEquals(expectedFormat, result.get(SchemaKeyword.TAG_FORMAT.forVersion(schemaVersion)).asText());
}
}
}
12 changes: 11 additions & 1 deletion slate-docs/source/includes/_main-generator-options.md
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,7 @@ configBuilder.without(
<td colspan="2"><code>Option.EXTRA_OPEN_API_FORMAT_VALUES</code></td>
</tr>
<tr>
<td>Include extra <code>"format"</code> values (e.g. <code>"int32"</code>, <code>"int64"</code>, <code>"date"</code>, <code>"date-time"</code>, <code>"uuid"</code>) for fixed types (primitive/basic types, plus some of the <code>Option.ADDITIONAL_FIXED_TYPES</code> if they are enabled as well).</td>
<td>Include extra <code>"format"</code> values (e.g. <code>"int32"</code>, <code>"int64"</code>, <code>"date"</code>, <code>"time"</code>, <code>"date-time"</code>, <code>"duration"</code>, <code>"uuid"</code>, <code>"uri"</code>) for fixed types (primitive/basic types, plus some of the <code>Option.ADDITIONAL_FIXED_TYPES</code> if they are enabled as well). Only works if <code>Option.ADDITIONAL_FIXED_TYPES</code> is set and it overrides <code>Option.STANDARD_FORMATS.</code></td></td>
<td>no automatic <code>"format"</code> values are being included.</td>
</tr>
<tr>
Expand Down Expand Up @@ -323,6 +323,15 @@ configBuilder.without(
<td>As final step in the schema generation process, ensure all sub schemas containing keywords implying a particular "type" (e.g., "properties" implying an "object") have this "type" declared explicitly – this also affects the results from custom definitions.</td>
<td>No additional "type" indication will be added for each sub schema, e.g. on the collected attributes where the "allOf" clean-up could not be applied or was disabled.</td>
</tr>
<tr>
<td rowspan="2" style="text-align: right">35</td>
<td colspan="2"><code>Option.STANDARD_FORMATS</code></td>
</tr>
<tr>
<td>Same as <code>Option.EXTRA_OPEN_API_FORMAT_VALUES</code> but only for built-in supported <code>"format"</code> values (<code>"date"</code>, <code>"time"</code>, <code>"date-time"</code>, <code>"duration"</code>, <code>"uuid"</code>, <code>"uri"</code>).
Only works if <code>Option.ADDITIONAL_FIXED_TYPES</code> is set and it is overriden by <code>Option.EXTRA_OPEN_API_FORMAT_VALUES</code></td>
<td>no automatic <code>"format"</code> values are being included.</td>
</tr>
</tbody>
</table>

Expand Down Expand Up @@ -368,3 +377,4 @@ Below, you can find the lists of <code>Option</code>s included/excluded in the r
| 32 | `PLAIN_DEFINITION_KEYS` | ⬜️ | ⬜️ | ⬜️ |
| 33 | `ALLOF_CLEANUP_AT_THE_END` | ✅ | ✅ | ✅ |
| 34 | `STRICT_TYPE_INFO` | ⬜️ | ⬜️ | ⬜️ |
| 35 | `STANDARD_FORMATS` | ⬜️ | ⬜️ | ⬜️ |
Loading