Repository: victools/jsonschema-generator (java)

Issue:
Support the "format" string tag without the other OpenAPI format-values
When looking into how to get the `"format"` tag properly formatted following the specs https://json-schema.org/understanding-json-schema/reference/string#format, I found that the only supported option for that is the `Option.EXTRA_OPEN_API_FORMAT_VALUES`. Nevertheless, this introduces`"format"` tags also for properties of numeric types that are not defined in the json-schema specs.

I know that there is a way to implement this with a customized method,  but it would be nice to have support with an `Option` to use the supported built-in formats (https://json-schema.org/understanding-json-schema/reference/string#built-in-formats)  from the json-schema specs (without relying on third-party OpenAPI custom formats).

Is there a possibility to support that feature out-of-the-box with either an option or directly when a version schema supports that built-in format?

*Reproduction*: I realized when I tried to use the `2019-09` schema with a model containing an `UUID` and an `Integer`, expecting to either get a `"format": uuid` with some option (i.e., `Option.ADDITIONAL_FIXED_TYPES`) but the only way was providing the OpenAPI format values that are also adding the `"format": int32` to the `number` and `integer` fields.



Pull request:
feat: support the "format" standard built-in tags (without the OpenAPI ones)
Fixes #396 

Patch:
diff --git a/jsonschema-generator/src/main/java/com/github/victools/jsonschema/generator/Option.java b/jsonschema-generator/src/main/java/com/github/victools/jsonschema/generator/Option.java
index 5cb5b3b..b6daac2 100644
--- a/jsonschema-generator/src/main/java/com/github/victools/jsonschema/generator/Option.java
+++ b/jsonschema-generator/src/main/java/com/github/victools/jsonschema/generator/Option.java
@@ -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
@@ -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
diff --git a/jsonschema-generator/src/main/java/com/github/victools/jsonschema/generator/SchemaGeneratorConfig.java b/jsonschema-generator/src/main/java/com/github/victools/jsonschema/generator/SchemaGeneratorConfig.java
index 2795744..2007c69 100644
--- a/jsonschema-generator/src/main/java/com/github/victools/jsonschema/generator/SchemaGeneratorConfig.java
+++ b/jsonschema-generator/src/main/java/com/github/victools/jsonschema/generator/SchemaGeneratorConfig.java
@@ -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".
      *
diff --git a/jsonschema-generator/src/main/java/com/github/victools/jsonschema/generator/impl/SchemaGeneratorConfigImpl.java b/jsonschema-generator/src/main/java/com/github/victools/jsonschema/generator/impl/SchemaGeneratorConfigImpl.java
index b109dc9..02e82e8 100644
--- a/jsonschema-generator/src/main/java/com/github/victools/jsonschema/generator/impl/SchemaGeneratorConfigImpl.java
+++ b/jsonschema-generator/src/main/java/com/github/victools/jsonschema/generator/impl/SchemaGeneratorConfigImpl.java
@@ -138,6 +138,11 @@ public class SchemaGeneratorConfigImpl implements SchemaGeneratorConfig {
         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);
diff --git a/jsonschema-generator/src/main/java/com/github/victools/jsonschema/generator/impl/module/SimpleTypeModule.java b/jsonschema-generator/src/main/java/com/github/victools/jsonschema/generator/impl/module/SimpleTypeModule.java
index 44cd8ab..7262b66 100644
--- a/jsonschema-generator/src/main/java/com/github/victools/jsonschema/generator/impl/module/SimpleTypeModule.java
+++ b/jsonschema-generator/src/main/java/com/github/victools/jsonschema/generator/impl/module/SimpleTypeModule.java
@@ -27,8 +27,10 @@ import com.github.victools.jsonschema.generator.SchemaGeneratorConfigBuilder;
 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;
 
@@ -81,17 +83,18 @@ public class SimpleTypeModule implements Module {
     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)
@@ -101,6 +104,7 @@ public class SimpleTypeModule implements Module {
     }
 
     private final Map<Class<?>, SchemaKeyword> fixedJsonSchemaTypes = new HashMap<>();
+    private final List<Class<?>> standardFormats = new ArrayList<>();
     private final Map<Class<?>, String> extraOpenApiFormatValues = new HashMap<>();
 
     /**
@@ -162,6 +166,12 @@ public class SimpleTypeModule implements Module {
         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}".
      *
@@ -286,7 +296,7 @@ public class SimpleTypeModule implements Module {
             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);
@@ -295,5 +305,12 @@ public class SimpleTypeModule implements Module {
             // 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()));
+        }
     }
 }
diff --git a/slate-docs/source/includes/_main-generator-options.md b/slate-docs/source/includes/_main-generator-options.md
index c211113..fe02215 100644
--- a/slate-docs/source/includes/_main-generator-options.md
+++ b/slate-docs/source/includes/_main-generator-options.md
@@ -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>
@@ -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>
 
@@ -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`                           | ⬜️ | ⬜️ | ⬜️ |


Test patch:
diff --git a/jsonschema-generator/src/test/java/com/github/victools/jsonschema/generator/SchemaGeneratorSimpleTypesTest.java b/jsonschema-generator/src/test/java/com/github/victools/jsonschema/generator/SchemaGeneratorSimpleTypesTest.java
index 02ae9dd..54893ba 100644
--- a/jsonschema-generator/src/test/java/com/github/victools/jsonschema/generator/SchemaGeneratorSimpleTypesTest.java
+++ b/jsonschema-generator/src/test/java/com/github/victools/jsonschema/generator/SchemaGeneratorSimpleTypesTest.java
@@ -32,40 +32,43 @@ import org.junit.jupiter.params.provider.MethodSource;
 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)
         );
     }
 
@@ -83,6 +86,13 @@ public class SchemaGeneratorSimpleTypesTest {
                         .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)
@@ -141,4 +151,27 @@ public class SchemaGeneratorSimpleTypesTest {
                     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());
+        }
+    }
 }