NAV

Introduction

The victools/jsonschema-generator repository is home to multiple artifacts that are published independently to "The Central Repository" (Sonatype) and from there to others like "Maven Central". Besides the main generator library itself, there are a few modules providing some standard configurations for your convenience.

This documentation aims at always covering the latest released version of the jsonschema-generator and its (standard) modules. There is no documentation for previous versions at this stage. Please refer to the CHANGELOG for a list of the incremental changes.


The victools:jsonschema-generator aims at allowing the generation of JSON Schema (Draft 6, Draft 7, Draft 2019-09 or Draft 2020-12) to document Java code. This is expressly not limited to JSON but also allows for a Java API to be documented (i.e. including methods and the associated return values).

Generator – Options

The schema generation caters for a certain degree of flexibility out-of-the-box.
Various aspects can be toggled on/off by including or excluding respective Options.

configBuilder.with(
    Option.EXTRA_OPEN_API_FORMAT_VALUES,
    Option.PLAIN_DEFINITION_KEYS);
configBuilder.without(
    Option.Schema_VERSION_INDICATOR,
    Option.ENUM_KEYWORD_FOR_SINGLE_VALUES);
#Behavior if includedBehavior if excluded
1 Option.SCHEMA_VERSION_INDICATOR
Setting appropriate $schema attribute on main schema being generated. No $schema attribute is being added.
2 Option.ADDITIONAL_FIXED_TYPES
  • String/Character/char/CharSequence are treated as { "type": "string" } schema
  • Boolean/boolean are treated as { "type": "boolean" } schema
  • Integer/int/Long/long/Short/short/Byte/byte are treated as { "type": "integer" } schema
  • Double/double/Float/float are treated as { "type": "number" } schema
  • BigInteger as { "type": "integer" } schema
  • BigDecimal/Number as { "type": "number" } schema
  • LocalDate/LocalDateTime/LocalTime/ZonedDateTime/OffsetDateTime/OffsetTime/Instant/ZoneId/Date/Calendar/UUID as { "type": "string" } schema
  • String/Character/char/CharSequence are treated as { "type": "string" } schema
  • Boolean/boolean are treated as { "type": "boolean" } schema
  • Integer/int/Long/long/Short/short/Byte/byte are treated as { "type": "integer" } schema
  • Double/double/Float/float are treated as { "type": "number" } schema
3 Option.EXTRA_OPEN_API_FORMAT_VALUES
Include extra "format" values (e.g. "int32", "int64", "date", "date-time", "uuid") for fixed types (primitive/basic types, plus some of the Option.ADDITIONAL_FIXED_TYPES if they are enabled as well). no automatic "format" values are being included.
4 Option.SIMPLIFIED_ENUMS
Treating encountered enum types as objects, but including only the name() method and listing the names of the enum constants as its enum values. -
#Behavior if includedBehavior if excluded
5 Option.FLATTENED_ENUMS
Treating enountered enum types as { "type": "string" } schema with the names of the enum constants being listed as its enum values. -
6 Option.FLATTENED_ENUMS_FROM_TOSTRING
Treating enountered enum types as { "type": "string" } schema with the toString() values of the enum constants being listed as its enum values. -
7 Option.SIMPLIFIED_OPTIONALS
Treating encountered Optional instances as objects, but including only the get(), orElse() and isPresent() methods. -
8 Option.FLATTENED_OPTIONALS
Replacing encountered Optional instances as null-able forms of their generic parameter type. -
#Behavior if includedBehavior if excluded
9 Option.FLATTENED_SUPPLIERS
Replacing encountered Supplier instances with their generic parameter type. -
10 Option.VALUES_FROM_CONSTANT_FIELDS
Attempt to load the values of static final fields, serialize them via the ObjectMapper and include them as the respective schema's const value. No const values are populated for static final fields.
11 Option.PUBLIC_STATIC_FIELDS
Include public static fields in an object's properties. No public static fields are included in an object's properties.
12 Option.PUBLIC_NONSTATIC_FIELDS
Include public non-static fields in an object's properties. No public non-static fields are included in an object's properties.
#Behavior if includedBehavior if excluded
13 Option.NONPUBLIC_STATIC_FIELDS
Include protected/package-visible/private static fields in an object's properties. No protected/package-visible/private static fields are included in an object's properties.
14 Option.NONPUBLIC_NONSTATIC_FIELDS_WITH_GETTERS
Include protected/package-visible/private non-static fields in an object's properties if they have corresponding getter methods. No protected/package-visible/private non-static fields with getter methods are included in an object's properties.
15 Option.NONPUBLIC_NONSTATIC_FIELDS_WITHOUT_GETTERS
Include protected/package-visible/private non-static fields in an object's properties if they don't have corresponding getter methods. No protected/package-visible/private non-static fields without getter methods are included in an object's properties.
16 Option.TRANSIENT_FIELDS
Include transient fields in an object's properties if they would otherwise be included according to the Options above. No transient fields are included in an object's properties even if they would otherwise be included according to the Options above.
#Behavior if includedBehavior if excluded
17 Option.STATIC_METHODS
Include public static methods in an object's properties No static methods are included in an object's properties even if they would be included according to the Option.VOID_METHODS below.
18 Option.VOID_METHODS
Include public void methods in an object's properties No void methods are included in an object's properties even if they would be included according to the Option.STATIC_METHODS above.
19 Option.GETTER_METHODS
Include public methods in an object's properties if a corresponding field exists that fulfills the usual naming conventions (getX()/x or isValid()/valid). No methods are included in an object's properties> for which a field exists that fulfills the usual naming conventions.
20 Option.NONSTATIC_NONVOID_NONGETTER_METHODS
Include public non-static non-void methods in an object's properties for which no field exists that fulfills the usual getter naming conventions. No non-static/non-void/non-getter methods are included in an object's properties.
#Behavior if includedBehavior if excluded
21 Option.NULLABLE_FIELDS_BY_DEFAULT
The schema type for a field allows null by default unless some configuration specifically says it is not null-able. The schema type for a field does not allow for null by default unless some configuration specifically says it is null-able.
22 Option.NULLABLE_METHOD_RETURN_VALUES_BY_DEFAULT
The schema type for a method's return type allows null by default unless some configuration specifically says it is not null-able. The schema type for a method's return type does not allow for null by default unless some configuration specifically says it is null-able.
23 Option.NULLABLE_ARRAY ITEMS_ALLOWED
The schema type for the items in an array (in case of a field's value or method's return value being a container/array) allows null, if the corresponding configuration explicitly says so. Otherwise, they're still deemed not null-able by default. The schema type for the items in an array (in case of a field's value or method's return value being a container/array) never allows null.
24 Option.FIELDS_DERIVED_FROM_ARGUMENTFREE_METHODS
Include argument-free methods as fields, e.g. the return type of getName() will be included as name field. Argument-free methods will be included with the appended parentheses.
#Behavior if includedBehavior if excluded
25 Option.MAP_VALUES_AS_ADDITIONAL_PROPERTIES
Setting the additionalProperties attribute in each Map to a schema representing the declared value type V. Omitting the additionalProperties attribute in Map schemas by default (thereby allowing additional properties of any type) unless some configuration specifically says something else.
26 Option.ENUM_KEYWORD_FOR_SINGLE_VALUES
Using the enum keyword for allowed values, even if there is only one. In case of a single allowed value, use the const keyword instead of enum.
27 Option.FORBIDDEN_ADDITIONAL_PROPERTIES_BY_DEFAULT
Setting the additionalProperties attribute in all object schemas to false by default unless some configuration specifically says something else. Omitting the additionalProperties attribute in all object schemas by default (thereby allowing any additional properties) unless some configuration specifically says something else.
28 Option.DEFINITIONS_FOR_ALL_OBJECTS
Include an entry in the $defs/definitions for each encountered object type that is not explicitly declared as "inline" via a custom definition. Only include those entries in the $defs/definitions for object types that are referenced more than once and which are not explicitly declared as "inline" via a custom definition.
#Behavior if includedBehavior if excluded
29 Option.DEFINITION_FOR_MAIN_SCHEMA
Include an entry in the $defs/definitions for the main/target type and a corresponding $ref on the top level (which is only valid from Draft 2019-09 onward). Define the main/target type "inline".
30 Option.DEFINITIONS_FOR_MEMBER_SUPERTYPES
For a member (field/method), having a declared type for which subtypes are being detected, include a single definition with any collected member attributes assigned directly. Any subtypes are only being handled as generic types, i.e., outside of the member context. That means, certain relevent annotations may be ignored (e.g. a jackson @JsonTypeInfo override on a single member would not be correctly reflected in the produced schema). For a member (field/method), having a declared type for which subtypes are being detected, include a list of definittions: one for each subtype in the given member's context. This allows independently interpreting contextual information (e.g., member annotations) for each subtype.
31 Option.INLINE_ALL_SCHEMAS
Do not include any $defs/definitions but rather define all sub-schemas "inline" – however, this results in an exception being thrown if the given type contains any kind of circular reference. Depending on whether DEFINITIONS_FOR_ALL_OBJECTS is included or excluded.
32 Option.PLAIN_DEFINITION_KEYS
Ensure that the keys for any $defs/definitions match the regular expression ^[a-zA-Z0-9\.\-_]+$ (as expected by the OpenAPI specification 3.0). Ensure that the keys for any $defs/definitions are URI compatible (as expected by the JSON Schema specification).
33 Option.ALLOF_CLEANUP_AT_THE_END
At the very end of the schema generation reduce allOf wrappers where it is possible without overwriting any attributes – this also affects the results from custom definitions. Do not attempt to reduce allOf wrappers but preserve them as they were generated regardless of them being necessary or not.

Below, you can find the lists of Options included/excluded in the respective standard OptionPresets:

# Standard Option F_D J_O P_J
1 SCHEMA_VERSION_INDICATOR ⬜️ ⬜️
2 ADDITIONAL_FIXED_TYPES ⬜️ ⬜️
3 EXTRA_OPEN_API_FORMAT_VALUES ⬜️ ⬜️ ⬜️
4 SIMPLIFIED_ENUMS ⬜️
5 FLATTENED_ENUMS ⬜️ ⬜️
6 FLATTENED_ENUMS_FROM_TOSTRING ⬜️ ⬜️ ⬜️
7 SIMPLIFIED_OPTIONALS ⬜️
8 FLATTENED_OPTIONALS ⬜️ ⬜️
8 FLATTENED_SUPPLIERS ⬜️ ⬜️
10 VALUES_FROM_CONSTANT_FIELDS
11 PUBLIC_STATIC_FIELDS ⬜️
12 PUBLIC_NONSTATIC_FIELDS
13 NONPUBLIC_STATIC_FIELDS ⬜️ ⬜️
14 NONPUBLIC_NONSTATIC_FIELDS_WITH_GETTERS ⬜️
15 NONPUBLIC_NONSTATIC_FIELDS_WITHOUT_GETTERS ⬜️
16 TRANSIENT_FIELDS ⬜️ ⬜️
17 STATIC_METHODS ⬜️
18 VOID_METHODS ⬜️
19 GETTER_METHODS ⬜️
20 NONSTATIC_NONVOID_NONGETTER_METHODS ⬜️
21 NULLABLE_FIELDS_BY_DEFAULT ⬜️ ⬜️
22 NULLABLE_METHOD_RETURN_VALUES_BY_DEFAULT ⬜️ ⬜️
23 NULLABLE_ARRAY_ITEMS_ALLOWED ⬜️ ⬜️ ⬜️
24 FIELDS_DERIVED_FROM_ARGUMENTFREE_METHODS ⬜️ ⬜️ ⬜️
25 MAP_VALUES_AS_ADDITIONAL_PROPERTIES ⬜️ ⬜️ ⬜️
26 ENUM_KEYWORD_FOR_SINGLE_VALUES ⬜️ ⬜️ ⬜️
27 FORBIDDEN_ADDITIONAL_PROPERTIES_BY_DEFAULT ⬜️ ⬜️ ⬜️
28 DEFINITIONS_FOR_ALL_OBJECTS ⬜️ ⬜️ ⬜️
29 DEFINITION_FOR_MAIN_SCHEMA ⬜️ ⬜️ ⬜️
30 DEFINITIONS_FOR_MEMBER_SUPERTYPES ⬜️ ⬜️ ⬜️
31 INLINE_ALL_SCHEMAS ⬜️ ⬜️ ⬜️
32 PLAIN_DEFINITION_KEYS ⬜️ ⬜️ ⬜️
33 ALLOF_CLEANUP_AT_THE_END

Generator – Modules

Similar to an OptionPreset being a short-cut to including various Options, the concept of Modules is a convenient way of including multiple individual configurations or even advanced configurations (as per the following sections) at once.

You can easily group your own set of configurations into a Module if you wish. However, the main intention behind Modules is that they are an entry-point for separate external dependencies you can "plug-in" as required via SchemaGeneratorConfigBuilder.with(Module), like the few standard Modules documented below.

Generator – Individual Configurations

E.g. for the given configuration:

SchemaGeneratorConfigBuilder configBuilder = new SchemaGeneratorConfigBuilder(SchemaVersion.DRAFT_2019_09);
configBuilder.forField()
    .withTitleResolver(field -> field.getName() + " = "
            + (field.isFakeContainerItemScope() ? "(fake) " : "(real) ")
            + field.getSimpleTypeDescription())
    .withDescriptionResolver(field -> "original type = "
            + field.getContext().getSimpleTypeDescription(field.getDeclaredType()));
JsonNode mySchema = new SchemaGenerator(configBuilder.build())
        .generateSchema(MyClass.class);

and target class:

class MyClass {
    public List<String> texts;
}

The following schema will be generated:

{
  "type": "object",
  "properties": {
    "texts": {
      "type": "array",
      "title": "texts = (real) List<String>",
      "description": "original type = List<String>",
      "items": {
        "type": "string",
        "title": "texts = (fake) String",
        "description": "original type = List<String>"
      }
    }
  }
}

In order to control various attributes being set during the schema generation, you can define for each (supported) one of them individually how a respective value should be resolved. Overall, you usually have the same configuration options either for:

The jsonschema-generator README contains a list of the supported JSON Schema attributes.
The following list of individual configuration options on the SchemaGeneratorConfigBuilder is to a large extent the inverse of that list.

"$id" Keyword

configBuilder.forTypesInGeneral()
    .withIdResolver(scope -> scope.getType().getErasedType() == MyClass.class ? "main-schema-id" : null);

withIdResolver() is expecting the "$id" attribute's value to be returned based on a given TypeScope – in case of multiple configurations, the first non-null value will be applied.

"$anchor" Keyword

configBuilder.forTypesInGeneral()
    .withAnchorResolver(scope -> scope.getType().getErasedType() == AnchorClass.class ? "anchor-value" : null);

withAnchorResolver() is expecting the "$anchor" attribute's value to be returned based on a given TypeScope – in case of multiple configurations, the first non-null value will be applied.

Order of entries in "properties" Keyword

configBuilder.forTypesInGeneral()
    .withPropertySorter(PropertySortUtils.SORT_PROPERTIES_FIELDS_BEFORE_METHODS
        .thenComparing((memberOne, memberTwo) ->
            // sort fields/methods alphabetically, while ignoring upper/lower case
            memberOne.getSchemaPropertyName().toLowerCase()
                .compareTo(memberTwo.getSchemaPropertyName().toLowerCase()));

withPropertySorter() is expecting a Comparator for sorting an object's fields and methods in the produced "properties" – this replaces any previously given sorting algorithm, i.e. only one Comparator can be set – by default, fields are listed before methods with each group in alphabetical order.

Names in global "$defs"/"definitions"

configBuilder.forTypesInGeneral()
    .withDefinitionNamingStrategy(new DefaultSchemaDefinitionNamingStrategy() {
        @Override
        public String getDefinitionNameForKey(DefinitionKey key, SchemaGenerationContext context) {
            return super.getDefinitionNameForKey(key, generationContext).toLowerCase();
        }
        @Override
        public void adjustDuplicateNames(Map<DefinitionKey, String> duplicateNames, SchemaGenerationContext context) {
            char suffix = 'a';
            duplicateNames.entrySet().forEach(entry -> entry.setValue(entry.getValue() + "-" + suffix++));
        }
        @Override
        public String adjustNullableName(DefinitionKey key, String definitionName, SchemaGenerationContext context) {
            return definitionName + "-nullable";
        }
    });

withDefinitionNamingStrategy() is expecting a SchemaDefinitionNamingStrategy that defines what keys to assign to subschemas in the "definitions"/"$defs".
Optionally, you can override the logic how to adjust them in case of multiple types having the same name and for a subschema's nullable alternative.

There is a DefaultSchemaDefinitionNamingStrategy, which is being applied if you don't set a specific naming strategy yourself:

Names of fields/methods in an object's properties

configBuilder.forFields()
    .withPropertyNameOverrideResolver(field -> Optional
            .ofNullable(field.getAnnotationConsideringFieldAndGetter(JsonProperty.class))
            .map(JsonProperty::value).orElse(null));
configBuilder.forMethods()
    .withPropertyNameOverrideResolver(method -> method.getName().startsWith("is") && method.getArgumentCount() == 0
            ? method.getName().substring(2, method.getName().length() - 2) : null);

withPropertyNameOverrideResolver() is expecting an alternative name to be returned for a given FieldScope/MethodScope to be used as key in the containing object's "properties" – the first non-null value will be applied.

Omitting/ignoring certain fields/methods

configBuilder.forFields()
    .withIgnoreCheck(field -> field.getName().startsWith("_"));
configBuilder.forMethods()
    .withIgnoreCheck(method -> !method.isVoid() && method.getType().getErasedType() == Object.class);

withIgnoreCheck() is expecting the indication to be returned whether a given FieldScope/MethodScope should be excluded from the generated schema. If any check returns true, the field/method will be ignored.

Decide whether a field's/method's value may be null

configBuilder.forFields()
    .withNullableCheck(field -> field.getAnnotationConsideringFieldAndGetter(Nullable.class) != null);
configBuilder.forMethods()
    .withNullableCheck(method -> method.getAnnotationConsideringFieldAndGetter(NotNull.class) == null);

withNullableCheck() is expecting the indication to be returned whether a given FieldScope/MethodScope may return null and should therefore include "null" in the generated schema's "type".

"required" Keyword

configBuilder.forFields()
    .withRequiredCheck(field -> field.getAnnotationConsideringFieldAndGetter(Nullable.class) == null);
configBuilder.forMethods()
    .withRequiredCheck(method -> method.getAnnotationConsideringFieldAndGetter(NotNull.class) != null);

withRequiredCheck() is expecting the indication to be returned whether a given FieldScope/MethodScope should be included in the "required" attribute – if any check returns true, the field/method will be deemed "required".

"readOnly" Keyword

configBuilder.forFields()
    .withReadOnlyCheck(field -> field.getAnnotationConsideringFieldAndGetter(ReadOnly.class) != null);
configBuilder.forMethods()
    .withReadOnlyCheck(method -> method.getAnnotationConsideringFieldAndGetter(ReadOnly.class) != null);

withReadOnlyCheck() is expecting the indication to be returned whether a given FieldScope/MethodScope should be included in the "readOnly" attribute – if any check returns true, the field/method will be deemed "readOnly".

"writeOnly" Keyword

configBuilder.forFields()
    .withWriteOnlyCheck(field -> field.getAnnotationConsideringFieldAndGetter(WriteOnly.class) != null);
configBuilder.forMethods()
    .withWriteOnlyCheck(method -> method.getAnnotationConsideringFieldAndGetter(WriteOnly.class) != null);

withWriteOnlyCheck() is expecting the indication to be returned whether a given FieldScope/MethodScope should be included in the "writeOnly" attribute – if any check returns true, the field/method will be deemed "writeOnly".

"title" Keyword

configBuilder.forTypesInGeneral()
    .withTitleResolver(scope -> scope.getType().getErasedType() == YourClass.class ? "main schema title" : null);
configBuilder.forFields()
    .withTitleResolver(field -> field.getType().getErasedType() == String.class ? "text field" : null);
configBuilder.forMethods()
    .withTitleResolver(method -> method.getName().startsWith("get") ? "getter" : null);

withTitleResolver() is expecting the "title" attribute's value to be returned based on a given TypeScope/FieldScope/MethodScope – the first non-null value will be applied.

"description" Keyword

configBuilder.forTypesInGeneral()
    .withDescriptionResolver(scope -> scope.getType().getErasedType() == YourClass.class ? "main schema description" : null);
configBuilder.forFields()
    .withDescriptionResolver(field -> field.getType().getErasedType() == String.class ? "text field" : null);
configBuilder.forMethods()
    .withDescriptionResolver(method -> method.getName().startsWith("get") ? "getter" : null);

withDescriptionResolver() is expecting the "description" attribute's value to be returned based on a given TypeScope/FieldScope/MethodScope – the first non-null value will be applied.

"default" Keyword

configBuilder.forTypesInGeneral()
    .withDefaultResolver(scope -> scope.getType().getErasedType() == boolean.class ? Boolean.FALSE : null);
configBuilder.forFields()
    .withDefaultResolver(field -> field.getType().getErasedType() == String.class ? "" : null);
configBuilder.forMethods()
    .withDefaultResolver(method -> Optional
            .ofNullable(method.getAnnotationConsideringFieldAndGetter(Default.class))
            .map(Default::value).orElse(null));

withDefaultResolver() is expecting the "default" attribute's value to be returned based on a given TypeScope/FieldScope/MethodScope – the first non-null value will be applied, which will be serialised through the ObjectMapper instance provided in the SchemaGeneratorConfigBuilder's constructor.

"const"/"enum" Keyword

configBuilder.forTypesInGeneral()
    .withEnumResolver(scope -> scope.getType().getErasedType().isEnum()
            ? Stream.of(scope.getType().getErasedType().getEnumConstants())
                    .map(v -> ((Enum) v).name()).collect(Collectors.toList())
            : null);
configBuilder.forFields()
    .withEnumResolver(field -> Optional
            .ofNullable(field.getAnnotationConsideringFieldAndGetter(AllowedValues.class))
            .map(AllowedValues::valueList).orElse(null));
configBuilder.forMethods()
    .withEnumResolver(method -> Optional
            .ofNullable(method.getAnnotationConsideringFieldAndGetter(SupportedValues.class))
            .map(SupportedValues::values).map(Arrays::asList).orElse(null));

withEnumResolver() is expecting the "const"/"enum" attribute's value(s) to be returned based on a given TypeScope/FieldScope/MethodScope – the first non-null value will be applied, which will be serialised through the ObjectMapper instance provided in the SchemaGeneratorConfigBuilder's constructor.

"additionalProperties" Keyword

Option 1: derive plain type from given scope

One version of the withAdditionalPropertiesResolver() is expecting the "additionalProperties" attribute's value to be returned based on a given TypeScope/FieldScope/MethodScope – the first non-null value will be applied.

configBuilder.forTypesInGeneral()
    .withAdditionalPropertiesResolver(scope -> Object.class);
configBuilder.forFields()
    .withAdditionalPropertiesResolver(field -> field.getType().getErasedType() == Object.class
            ? null : Void.class);
configBuilder.forMethods()
    .withAdditionalPropertiesResolver(method -> method.getType().getErasedType() == Map.class
            ? method.getTypeParameterFor(Map.class, 1) : Void.class);

Option 2: specify explicit subschema

Another version of the withAdditionalPropertiesResolver() is expecting the "additionalProperties" attribute's value to be provided directly as a JsonNode (e.g., ObjectNode) representing the desired subschema. In this case, both the TypeScope/FieldScope/MethodScope and the overall generation context are being provided as input parameters.

configBuilder.forTypesInGeneral()
    .withAdditionalPropertiesResolver((scope, context) -> BooleanNode.TRUE);
configBuilder.forFields()
    .withAdditionalPropertiesResolver((field, context) -> field.getType().getErasedType() == Object.class
            ? null : BooleanNode.FALSE);
configBuilder.forMethods()
    .withAdditionalPropertiesResolver((method, context) -> {
        if (!method.getType().isInstanceOf(Map.class)) {
            return null;
        }
        ResolvedType valueType = method.getTypeParameterFor(Map.class, 1);
        if (valueType == null || valueType.getErasedType() == Object.class) {
            return null;
        }
        return context.createStandardDefinitionReference(method.asFakeContainerItemScope(Map.class, 1), null);
    });

This usage of the FieldScope/MethodScope potentially via asFakeContainerItemScope() has the advantage of allowing the consideration of annotations on generic parameters, such as the one on Map<String, @Min(10) Integer> when that is the declared type of a field/method.

"patternProperties" Keyword

Option 1: derive plain types from given scope

One version of the withPatternPropertiesResolver() is expecting a Map of regular expressions to their corresponding allowed types to be returned based on a given TypeScope/FieldScope/MethodScope – the first non-null value will be applied.

configBuilder.forTypesInGeneral()
    .withPatternPropertiesResolver(scope -> scope.getType().isInstanceOf(Map.class)
            ? Collections.singletonMap("^[a-zA-Z]+$", scope.getTypeParameterFor(Map.class, 1)) : null);
configBuilder.forFields()
    .withPatternPropertiesResolver(field -> field.getType().isInstanceOf(TypedMap.class)
            ? Collections.singletonMap("_int$", int.class) : null);
configBuilder.forMethods()
    .withPatternPropertiesResolver(method -> method.getType().isInstanceOf(StringMap.class)
            ? Collections.singletonMap("^txt_", String.class) : null);

Each regular expression will be included as key in the "patternProperties" attribute with a schema representing the mapped type as the corresponding value.

Option 2: specify explicit subschema

Another version of the withPatternPropertiesResolver() is expecting a Map with each value being a JsonNode (e.g., ObjectNode) representing the respective desired subschema. In this case, both the TypeScope/FieldScope/MethodScope and the overall generation context are being provided as input parameters.

The generation of the subschema could look similar to the example given for the "additionalProperties" attribute above.

The usage of the FieldScope/MethodScope potentially via asFakeContainerItemScope() has the advantage of allowing the consideration of annotations on generic parameters, such as the one on Map<String, @Min(10) Integer> when that is the declared type of a field/method.

"minLength" Keyword

configBuilder.forTypesInGeneral()
    .withStringMinLengthResolver(scope -> scope.getType().getErasedType() == UUID.class ? 36 : null);
configBuilder.forFields()
    .withStringMinLengthResolver(field -> field
            .getAnnotationConsideringFieldAndGetterIfSupported(NotEmpty.class) == null ? null : 1);
configBuilder.forMethods()
    .withStringMinLengthResolver(method -> Optional
            .ofNullable(method.getAnnotationConsideringFieldAndGetterIfSupported(Size.class))
            .map(Size::min).orElse(null));

withStringMinLengthResolver() is expecting the "minLength" attribute's value to be returned based on a given TypeScope/FieldScope/MethodScope – the first non-null value will be applied.

"maxLength" Keyword

configBuilder.forTypesInGeneral()
    .withStringMaxLengthResolver(scope -> scope.getType().getErasedType() == UUID.class ? 36 : null);
configBuilder.forFields()
    .withStringMaxLengthResolver(field -> field
            .getAnnotationConsideringFieldAndGetterIfSupported(DbKey.class) == null ? null : 450);
configBuilder.forMethods()
    .withStringMaxLengthResolver(method -> Optional
            .ofNullable(method.getAnnotationConsideringFieldAndGetterIfSupported(Size.class))
            .map(Size::max).orElse(null));

withStringMaxLengthResolver() is expecting the "maxLength" attribute's value to be returned based on a given TypeScope/FieldScope/MethodScope – the first non-null value will be applied.

"format" Keyword

configBuilder.forTypesInGeneral()
    .withStringFormatResolver(scope -> scope.getType().getErasedType() == UUID.class ? "uuid" : null);
configBuilder.forFields()
    .withStringFormatResolver(field -> field
            .getAnnotationConsideringFieldAndGetterIfSupported(Email.class) == null ? null : "email");
configBuilder.forMethods()
    .withStringFormatResolver(method -> Optional
            .ofNullable(method.getAnnotationConsideringFieldAndGetterIfSupported(Schema.class))
            .map(Schema::format).orElse(null));

withStringFormatResolver() is expecting the "format" attribute's value to be returned based on a given TypeScope/FieldScope/MethodScope – the first non-null value will be applied.

"pattern" Keyword

configBuilder.forTypesInGeneral()
    .withStringPatternResolver(scope -> scope.getType().getErasedType() == UUID.class
            ? "^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[89aAbB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}$" : null);
configBuilder.forFields()
    .withStringPatternResolver(field -> field
            .getAnnotationConsideringFieldAndGetterIfSupported(Email.class) == null ? null : "^.+@.+\\..+$");
configBuilder.forMethods()
    .withStringPatternResolver(method -> Optional
            .ofNullable(method.getAnnotationConsideringFieldAndGetterIfSupported(Pattern.class))
            .map(Pattern::value).orElse(null));

withStringPatternResolver() is expecting the "pattern" attribute's value to be returned based on a given TypeScope/FieldScope/MethodScope – the first non-null value will be applied.

"minimum" Keyword

configBuilder.forTypesInGeneral()
    .withNumberInclusiveMinimumResolver(scope -> scope.getType().getErasedType() == PositiveInt.class
            ? BigDecimal.ONE : null);
configBuilder.forFields()
    .withNumberInclusiveMinimumResolver(field -> field
            .getAnnotationConsideringFieldAndGetterIfSupported(NonNegative.class) == null ? null : BigDecimal.ZERO);
configBuilder.forMethods()
    .withNumberInclusiveMinimumResolver(method -> Optional
            .ofNullable(method.getAnnotationConsideringFieldAndGetterIfSupported(Minimum.class))
            .filter(a -> !a.exclusive()).map(Minimum::value).orElse(null));

withNumberInclusiveMinimumResolver() is expecting the "minimum" attribute's value to be returned based on a given TypeScope/FieldScope/MethodScope – the first non-null value will be applied.

"exclusiveMinimum" Keyword

configBuilder.forTypesInGeneral()
    .withNumberExclusiveMinimumResolver(scope -> scope.getType().getErasedType() == PositiveDecimal.class
            ? BigDecimal.ZERO : null);
configBuilder.forFields()
    .withNumberExclusiveMinimumResolver(field -> field
            .getAnnotationConsideringFieldAndGetterIfSupported(Positive.class) == null ? null : BigDecimal.ZERO);
configBuilder.forMethods()
    .withNumberExclusiveMinimumResolver(method -> Optional
            .ofNullable(method.getAnnotationConsideringFieldAndGetterIfSupported(Minimum.class))
            .filter(Minimum::exclusive).map(Minimum::value).orElse(null));

withNumberExclusiveMinimumResolver() is expecting the "exclusiveMinimum" attribute's value to be returned based on a given TypeScope/FieldScope/MethodScope – the first non-null value will be applied.

"maximum" Keyword

configBuilder.forTypesInGeneral()
    .withNumberInclusiveMaximumResolver(scope -> scope.getType().getErasedType() == int.class
            ? new BigDecimal(Integer.MAX_VALUE) : null);
configBuilder.forFields()
    .withNumberInclusiveMaximumResolver(field -> field
            .getAnnotationConsideringFieldAndGetterIfSupported(NonPositive.class) == null ? null : BigDecimal.ZERO);
configBuilder.forMethods()
    .withNumberInclusiveMaximumResolver(method -> Optional
            .ofNullable(method.getAnnotationConsideringFieldAndGetterIfSupported(Maximum.class))
            .filter(a -> !a.exclusive()).map(Maximum::value).orElse(null));

withNumberInclusiveMaximumResolver() is expecting the "maximum" attribute's value to be returned based on a given TypeScope/FieldScope/MethodScope – the first non-null value will be applied.

"exclusiveMaximum" Keyword

configBuilder.forTypesInGeneral()
    .withNumberExclusiveMaximumResolver(scope -> scope.getType().getErasedType() == NegativeInt.class
            ? BigDecimal.ZERO : null);
configBuilder.forFields()
    .withNumberExclusiveMaximumResolver(field -> field
            .getAnnotationConsideringFieldAndGetterIfSupported(Negative.class) == null ? null : BigDecimal.ZERO);
configBuilder.forMethods()
    .withNumberExclusiveMaximumResolver(method -> Optional
            .ofNullable(method.getAnnotationConsideringFieldAndGetterIfSupported(Maximum.class))
            .filter(Maximum::exclusive).map(Maximum::value).orElse(null));

withNumberExclusiveMaximumResolver() is expecting the "exclusiveMaximum" attribute's value to be returned based on a given TypeScope/FieldScope/MethodScope – the first non-null value will be applied.

"multipleOf" Keyword

configBuilder.forTypesInGeneral()
    .withNumberMultipleOfResolver(scope -> scope.getType().getErasedType() == int.class
            ? BigDecimal.ONE : null);
configBuilder.forFields()
    .withNumberMultipleOfResolver(field -> field
            .getAnnotationConsideringFieldAndGetterIfSupported(Currency.class) == null ? null : new BigDecimal("0.01"));
configBuilder.forMethods()
    .withNumberMultipleOfResolver(method -> Optional
            .ofNullable(method.getAnnotationConsideringFieldAndGetterIfSupported(NumericConstraint.class))
            .map(NumericConstraint::multipleOf).orElse(null));

withNumberMultipleOfResolver() is expecting the "multipleOf" attribute's value to be returned based on a given TypeScope/FieldScope/MethodScope – the first non-null value will be applied.

"minItems" Keyword

configBuilder.forTypesInGeneral()
    .withArrayMinItemsResolver(scope -> scope.getType().isInstanceOf(MandatoryList.class) ? 1 : null);
configBuilder.forFields()
    .withArrayMinItemsResolver(field -> field
            .getAnnotationConsideringFieldAndGetterIfSupported(NotEmpty.class) == null ? null : 1);
configBuilder.forMethods()
    .withArrayMinItemsResolver(method -> Optional
            .ofNullable(method.getAnnotationConsideringFieldAndGetterIfSupported(Size.class))
            .map(Size::min).orElse(null));

withArrayMinItemsResolver() is expecting the "minItems" attribute's value to be returned based on a given TypeScope/FieldScope/MethodScope – the first non-null value will be applied.

"maxItems" Keyword

configBuilder.forTypesInGeneral()
    .withArrayMaxItemsResolver(scope -> scope.getType().isInstanceOf(Triple.class) ? 3 : null);
configBuilder.forFields()
    .withArrayMaxItemsResolver(field -> field
            .getAnnotationConsideringFieldAndGetterIfSupported(NoMoreThanADozen.class) == null ? null : 12);
configBuilder.forMethods()
    .withArrayMaxItemsResolver(method -> Optional
            .ofNullable(method.getAnnotationConsideringFieldAndGetterIfSupported(Size.class))
            .map(Size::max).orElse(null));

withArrayMaxItemsResolver() is expecting the "maxItems" attribute's value to be returned based on a given TypeScope/FieldScope/MethodScope – the first non-null value will be applied.

"uniqueItems" Keyword

configBuilder.forTypesInGeneral()
    .withArrayUniqueItemsResolver(scope -> scope.getType().isInstanceOf(Set.class) ? true : null);
configBuilder.forFields()
    .withArrayUniqueItemsResolver(field -> field
            .getAnnotationConsideringFieldAndGetterIfSupported(Unique.class) == null ? null : true);
configBuilder.forMethods()
    .withArrayUniqueItemsResolver(method -> Optional
            .ofNullable(method.getAnnotationConsideringFieldAndGetterIfSupported(ListConstraints.class))
            .map(ListConstraints::distinct).orElse(null));

withArrayUniqueItemsResolver() is expecting the "uniqueItems" attribute's value to be returned based on a given TypeScope/FieldScope/MethodScope – the first non-null value will be applied.

Generator – Advanced Configurations

When all of the above configuration options are insufficient to achieve your requirements, there are some more advanced configurations you can resort to.

Instance Attribute Overrides

configBuilder.forFields()
    .withInstanceAttributeOverride((node, field, context) -> node
            .put("$comment", "Field name in code: " + field.getDeclaredName()));
configBuilder.forMethods()
    .withInstanceAttributeOverride((node, method, context) -> node
            .put("readOnly", true));

If you want to set an attribute that is missing in the supported Individual Configurations for fields/methods or just want to have the last say in what combination of attribute values is being set for a field/method, you can use the following configurations:

All defined overrides will be applied in the order of having been added to the SchemaGeneratorConfigBuilder. Each receiving the then-current set of attributes on an ObjectNode which can be freely manipulated.

Type Attribute Overrides

configBuilder.forTypesInGeneral()
    .withTypeAttributeOverride((node, scope, context) -> node
            .put("$comment", "Java type: " + scope.getType().getErasedType().getName()));

Similarly to (but not quite the same as) the Instance Attribute Overrides for fields/methods you can add missing attributes or manipulate collected ones on a per-type level through the following configuration:

All defined overrides will be applied in the order of having been added to the SchemaGeneratorConfigBuilder. Each receiving the then-current type definition including the collected set of attributes on an ObjectNode which can be freely manipulated.

Target Type Overrides

E.g. for the value field in the following class you may know that the returned value is either a String or a Number but there is no common supertype but Object that can be declared:

class ExampleForTargetTypeOverrides {
    @ValidOneOfTypes({String.class, Number.class})
    private Object value;

    public void setValue(String textValue) {
        this.value = textValue;
    }
    public void setValue(Number numericValue) {
        this.value = numericValue;
    }
}

This could be solved by the following configuration:

configBuilder.forFields()
    .withTargetTypeOverridesResolver(field -> Optional
            .ofNullable(field.getAnnotationConsideringFieldAndGetterIfSupported(ValidOneOfTypes.class))
            .map(ValidOneOfTypes::value).map(Stream::of)
            .map(stream -> stream.map(specificSubtype -> field.getContext().resolve(specificSubtype)))
            .map(stream -> stream.collect(Collectors.toList()))
            .orElse(null));

The generated schema would look like this then:

{
    "type": "object",
    "properties": {
        "value": {
            "anyOf": [
                { "type": "string" },
                { "type": "number" }
            ]
        }
    }
}

Java does not support multiple type alternatives to be declared. This means you may have to declare a rather generic type on a field or as a method's return value even though there is only a finite list of types that you actually expect to be returned. To improve the generated schema by listing the actual alternatives via "anyOf", you can make use of the following configurations:

Subtype Resolvers

E.g. to replace every occurrence of the Animal interface with the Cat and Dog implementations:

configBuilder.forTypesInGeneral()
    .withSubtypeResolver((declaredType, generationContext) -> {
        if (declaredType.getErasedType() == Animal.class) {
            TypeContext typeContext = generationContext.getTypeContext();
            return Arrays.asList(
                    typeContext.resolveSubtype(declaredType, Cat.class),
                    typeContext.resolveSubtype(declaredType, Dog.class)
            );
        }
        return null;
    });

When a declared type is not too broad as in the example for Target Type Overrides above, but rather an appropriate supertype or interface. You may also want to list the alternative implementations via "anyOf" wherever you encounter an abstract class or interface. In order to reflect Java's polymorphism, you can make use of the following configuration:

This can of course be more generalised by employing your reflections library of choice for scanning your classpath for all implementations of an encountered type.

Custom Type Definitions

E.g. treat Collections as objects and not as "type": "array" (which is the default):

configBuilder.forTypesInGeneral()
    .withCustomDefinitionProvider((javaType, context) -> {
        if (!javaType.isInstanceOf(Collection.class)) {
            return null;
        }
        ResolvedType generic = context.getTypeContext().getContainerItemType(javaType);
        SchemaGeneratorConfig config = context.getGeneratorConfig();
        return new CustomDefinition(context.getGeneratorConfig().createObjectNode()
                .put(config.getKeyword(SchemaKeyword.TAG_TYPE),
                        config.getKeyword(SchemaKeyword.TAG_TYPE_OBJECT))
                .set(config.getKeyword(SchemaKeyword.TAG_PROPERTIES),
                        config.createObjectNode().set("stream().findFirst().orElse(null)",
                                context.makeNullable(context.createDefinitionReference(generic)))));
    });

When all the generic configurations are not enough to achieve your specific requirements, you can still directly define parts of the schema yourself through the following configuration:

(1) When including an unchanged schema of a different type, use createDefinitionReference():

configBuilder.forTypesInGeneral()
    .withCustomDefinitionProvider((javaType, context) ->
        javaType.isInstanceOf(UUID.class)
            ? new CustomDefinition(context.createDefinitionReference(
                    context.getTypeContext().resolve(String.class)))
            : null);

(2) When including an unchanged schema of the same type, use createStandardDefinitionReference():

CustomDefinitionProviderV2 thisProvider = (javaType, context) -> 
    javaType.isInstanceOf(Collection.class)
        ? new CustomDefinition(
            context.createStandardDefinitionReference(javaType, thisProvider),
            DefinitionType.STANDARD, AttributeInclusion.NO)
        : null;
configBuilder.forTypesInGeneral()
    .withCustomDefinitionProvider(thisProvider);

(3) When adjusting a schema of a different type, use createDefinition():

configBuilder.forTypesInGeneral()
    .withCustomDefinitionProvider((javaType, context) ->
        javaType.isInstanceOf(UUID.class)
            ? new CustomDefinition(context.createDefinition(
                    context.getTypeContext().resolve(String.class))
                        .put("format", "uuid"))
            : null);

(4) When adjusting a schema of the same type, use createStandardDefinition():

CustomDefinitionProviderV2 thisProvider = (javaType, context) -> 
    javaType.isInstanceOf(Collection.class)
        ? new CustomDefinition(
                context.createStandardDefinition(javaType, thisProvider)
                    .put("$comment", "collection without other attributes"),
                DefinitionType.STANDARD, AttributeInclusion.NO)
        : null;
configBuilder.forTypesInGeneral()
    .withCustomDefinitionProvider(thisProvider);
  1. SchemaGenerationContext.createDefinitionReference() creates a temporarily empty node which will be populated later with either a $ref or the appropriate inline schema, i.e. in order to not produce an inline definition – thereby allowing you to avoid endless loops in case of circular references.
  2. SchemaGenerationContext.createStandardDefinitionReference() to be used instead of the above when targeting the same type, to skip the current definition provider (and all previous ones) and thereby avoid endless loops.
  3. SchemaGenerationContext.createDefinition() creates an inline definition of the given scope, allowing you to apply changes on top (similar to attribute overrides); thereby avoiding the need to manually create everything from scratch.
  4. SchemaGenerationContext.createStandardDefinition() to be used instead of the above when targeting the same type, to skip the current definition provider (and all previous ones) and thereby avoid endless loops.

Other useful methods available in the context of a custom definition provider are:

Custom Property Definitions

// read a static schema string from an annotation
CustomPropertyDefinitionProvider provider = (member, context) -> Optional
        .ofNullable(member.getAnnotationConsideringFieldAndGetter(Subschema.class))
        .map(Subschema::value)
        .map(rawSchema -> {
            try {
                return context.getGeneratorConfig().getObjectMapper().readTree(rawSchema);
            } catch (Exception ex) {
                return null;
            }
        })
        .map(CustomPropertyDefinition::new)
        .orElse(null);
// if you don't rely on specific field/method functionality,
// you can reuse the same provider for both of them
configBuilder.forFields().withCustomDefinitionProvider(provider);
configBuilder.forMethods().withCustomDefinitionProvider(provider);

When not even the Custom Type Definitions are flexible enough for you and you need to consider the specific field/method context in which a type is being encountered, there is one last path you can take:

Jackson Module

The victools:jsonschema-module-jackson provides a number of standard configurations for deriving JSON Schema attributes from jackson annotations as well as looking up appropriate (annotated) subtypes.

import com.github.victools.jsonschema.generator.SchemaGeneratorConfigBuilder;
import com.github.victools.jsonschema.generator.SchemaVersion;
import com.github.victools.jsonschema.module.jackson.JacksonModule;
import com.github.victools.jsonschema.module.jackson.JacksonOption;


JacksonModule module = new JacksonModule(
        JacksonOption.FLATTENED_ENUMS_FROM_JSONVALUE
);
SchemaGeneratorConfigBuilder configBuilder = new SchemaGeneratorConfigBuilder(SchemaVersion.DRAFT_2019_09)
    .with(module);
  1. Set a field/method's "description" as per @JsonPropertyDescription
  2. Set a type's "description" as per @JsonClassDescription.
  3. Override a field's/method's property name as per @JsonProperty annotations.
  4. Ignore fields/methods that are marked with a @JsonBackReference annotation.
  5. Ignore fields (and their associated getter methods) that are deemed to be ignored according to various other jackson-annotations (e.g. @JsonIgnore, @JsonIgnoreType, @JsonIgnoreProperties) or are otherwise supposed to be excluded.
  6. Optionally: set a field/method as "required" as per @JsonProperty annotations, if the JacksonOption.RESPECT_JSONPROPERTY_REQUIRED was provided (i.e. this is an "opt-in").
  7. Optionally: treat enum types as plain strings as per the @JsonValue annotated method, if there is one and the JacksonOption.FLATTENED_ENUMS_FROM_JSONVALUE was provided (i.e. this is an "opt-in").
  8. Optionally: treat enum types as plain strings, as per each enum constant's @JsonProperty annotation, if all values of an enum have such annotations and the JacksonOption.FLATTENED_ENUMS_FROM_JSONPROPERTY was provided (i.e. this is an "opt-in").
  9. Optionally: sort an object's properties according to its @JsonPropertyOrder annotation, if the JacksonOption.RESPECT_JSONPROPERTY_ORDER was provided (i.e. this is an "opt-in").
  10. Subtype resolution according to @JsonSubTypes on a supertype in general or directly on specific fields/methods as an override of the per-type behavior unless JacksonOption.SKIP_SUBTYPE_LOOKUP was provided (i.e. this is an "opt-out").
  11. Apply structural changes for subtypes according to @JsonTypeInfo on a supertype in general or directly on specific fields/methods as an override of the per-type behavior unless JacksonOption.IGNORE_TYPE_INFO_TRANSFORM was provided (i.e. this is an "opt-out").
    • Considering @JsonTypeInfo.include with values As.PROPERTY, As.EXISTING_PROPERTY, As.WRAPPER_ARRAY, As.WRAPPER_OBJECT
    • Considering @JsonTypeInfo.use with values Id.CLASS, Id.NAME
  12. Consider @JsonProperty.access for marking a field/method as readOnly or writeOnly
  13. Optionally: ignore all methods but those with a @JsonProperty annotation, if the JacksonOption.INCLUDE_ONLY_JSONPROPERTY_ANNOTATED_METHODS was provided (i.e. this is an "opt-in").
  14. Optionally: respect @JsonIdentityReference(alwaysAsId=true) annotation if there is a corresponding @JsonIdentityInfo annotation on the type and the JacksonOption.JSONIDENTITY_REFERENCE_ALWAYS_AS_ID as provided (i.e., this is an "opt-in")

Schema attributes derived from annotations on getter methods are also applied to their associated fields.

To use it, just pass a module instance into SchemaGeneratorConfigBuilder.with(Module), optionally providing JacksonOption values in the module's constructor.


For a description of the available JacksonOption values and their meaning (apart from their mentionings in above enumeration), please refer to the JacksonOption class or its JavaDoc.

Jakarta Validation Module

The victools:jsonschema-module-jakarta-validation provides a number of standard configurations for deriving JSON Schema attributes from jakarta.validation.constraints annotations.

import com.github.victools.jsonschema.generator.SchemaGeneratorConfigBuilder;
import com.github.victools.jsonschema.generator.SchemaVersion;
import com.github.victools.jsonschema.module.jakarta.validation.JakartaValidationModule;
import com.github.victools.jsonschema.module.jakarta.validation.JakartaValidationOption;


JakartaValidationModule module = new JakartaValidationModule(JakartaValidationOption.PREFER_IDN_EMAIL_FORMAT)
    .forValidationGroups(YourGroupFlag.class);
SchemaGeneratorConfigBuilder configBuilder = new SchemaGeneratorConfigBuilder(SchemaVersion.DRAFT_2019_09)
    .with(module);
  1. Determine whether a member is not nullable, base assumption being that all fields and method return values are nullable if not annotated. Based on @NotNull/@Null/@NotEmpty/@NotBlank.
  2. Populate list of "required" fields/methods for objects if JakartaValidationOption.NOT_NULLABLE_FIELD_IS_REQUIRED/JakartaValidationOption.NOT_NULLABLE_METHOD_IS_REQUIRED is being provided respectively (i.e. this is an "opt-in").
  3. Populate "minItems" and "maxItems" for containers (i.e. arrays and collections). Based on @Size/@NotEmpty.
  4. Populate "minProperties" and "maxProperties" for map types. Based on @Size/@NotEmpty.
  5. Populate "minLength" and "maxLength" for strings. Based on @Size/@NotEmpty/@NotBlank.
  6. Populate "format" for strings. Based on @Email, can be "email" or "idn-email" depending on whether JakartaValidationOption.PREFER_IDN_EMAIL_FORMAT is being provided.
  7. Populate "pattern" for strings. Based on @Pattern/@Email, if JakartaValidationOption.INCLUDE_PATTERN_EXPRESSIONS is being provided (i.e. this is an "opt-in").
  8. Populate "minimum"/"exclusiveMinimum" for numbers. Based on @Min/@DecimalMin/@Positive/@PositiveOrZero.
  9. Populate "maximum"/"exclusiveMaximum" for numbers. Based on @Max/@DecimalMax/@Negative/@NegativeOrZero.

Schema attributes derived from validation annotations on fields are also applied to their respective getter methods.
Schema attributes derived from validation annotations on getter methods are also applied to their associated fields.

To use it, just pass a module instance into SchemaGeneratorConfigBuilder.with(Module), optionally providing JakartaValidationOption values in the module's constructor and/or specifying validation groups to filter by via .forValidationGroups().

module.forValidationGroups(JsonSchemaValidation.class);

The jakarta.validation.constraints annotations cater for a groups parameter to be added, to allow different sets of validations to be applied under different circumstances. Via .forValidationGroups() you're able to indicate which groups should be considered during the schema generation. Without specifying particular groups via .forValidationGroups(), no filtering will be applied – i.e. all supported jakarta.validation.constraints annotations will be considered regardless of their respective groups.

Javax Validation Module

The victools:jsonschema-module-javax-validation provides a number of standard configurations for deriving JSON Schema attributes from javax.validation annotations.

import com.github.victools.jsonschema.generator.SchemaGeneratorConfigBuilder;
import com.github.victools.jsonschema.generator.SchemaVersion;
import com.github.victools.jsonschema.module.javax.validation.JavaxValidationModule;
import com.github.victools.jsonschema.module.javax.validation.JavaxValidationOption;


JavaxValidationModule module = new JavaxValidationModule(JavaxValidationOption.PREFER_IDN_EMAIL_FORMAT)
    .forValidationGroups(YourGroupFlag.class);
SchemaGeneratorConfigBuilder configBuilder = new SchemaGeneratorConfigBuilder(SchemaVersion.DRAFT_2019_09)
    .with(module);
  1. Determine whether a member is not nullable, base assumption being that all fields and method return values are nullable if not annotated. Based on @NotNull/@Null/@NotEmpty/@NotBlank.
  2. Populate list of "required" fields/methods for objects if JavaxValidationOption.NOT_NULLABLE_FIELD_IS_REQUIRED/JavaxValidationOption.NOT_NULLABLE_METHOD_IS_REQUIRED is being provided respectively (i.e. this is an "opt-in").
  3. Populate "minItems" and "maxItems" for containers (i.e. arrays and collections). Based on @Size/@NotEmpty.
  4. Populate "minLength" and "maxLength" for strings. Based on @Size/@NotEmpty/@NotBlank.
  5. Populate "format" for strings. Based on @Email, can be "email" or "idn-email" depending on whether JavaxValidationOption.PREFER_IDN_EMAIL_FORMAT is being provided.
  6. Populate "pattern" for strings. Based on @Pattern/@Email, if JavaxValidationOption.INCLUDE_PATTERN_EXPRESSIONS is being provided (i.e. this is an "opt-in").
  7. Populate "minimum"/"exclusiveMinimum" for numbers. Based on @Min/@DecimalMin/@Positive/@PositiveOrZero.
  8. Populate "maximum"/"exclusiveMaximum" for numbers. Based on @Max/@DecimalMax/@Negative/@NegativeOrZero.

Schema attributes derived from validation annotations on fields are also applied to their respective getter methods.
Schema attributes derived from validation annotations on getter methods are also applied to their associated fields.

To use it, just pass a module instance into SchemaGeneratorConfigBuilder.with(Module), optionally providing JavaxValidationOption values in the module's constructor and/or specifying validation groups to filter by via .forValidationGroups().

module.forValidationGroups(JsonSchemaValidation.class);

The javax.validation annotations cater for a groups parameter to be added, to allow different sets of validations to be applied under different circumstances. Via .forValidationGroups() you're able to indicate which groups should be considered during the schema generation. Without specifying particular groups via .forValidationGroups(), no filtering will be applied – i.e. all supported javax.validation annotations will be considered regardless of their respective groups.

Swagger 1.5 Module

The victools:jsonschema-module-swagger-1.5 provides a number of standard configurations for deriving JSON Schema attributes from swagger (1.5.x) annotations.

import com.github.victools.jsonschema.generator.SchemaGeneratorConfigBuilder;
import com.github.victools.jsonschema.generator.SchemaVersion;
import com.github.victools.jsonschema.module.swagger15.SwaggerModule;
import com.github.victools.jsonschema.module.swagger15.SwaggerOption;


SwaggerModule module = new SwaggerModule(
        SwaggerOption.ENABLE_PROPERTY_NAME_OVERRIDES,
        SwaggerOption.IGNORING_HIDDEN_PROPERTIES
);
SchemaGeneratorConfigBuilder configBuilder = new SchemaGeneratorConfigBuilder(SchemaVersion.DRAFT_2019_09)
    .with(module);
  1. Set a field/method's "description" as per @ApiModelProperty(value = ...)
  2. Set a type's "title" as per @ApiModel(value = ...) unless SwaggerOption.NO_APIMODEL_TITLE was provided (i.e. this is an "opt-out")
  3. Set a type's "description" as per @ApiModel(description = ...) unless SwaggerOption.NO_APIMODEL_DESCRIPTION was provided (i.e. this is an "opt-out")
  4. Ignore a field/method if @ApiModelProperty(hidden = true) and SwaggerOption.IGNORING_HIDDEN_PROPERTIES was provided (i.e. this is an "opt-in")
  5. Override a field's property name as per @ApiModelProperty(name = ...) if SwaggerOption.ENABLE_PROPERTY_NAME_OVERRIDES was provided (i.e. this is an "opt-in")
  6. Indicate a number's (field/method) "minimum" (inclusive) according to @ApiModelProperty(allowableValues = "range[...")
  7. Indicate a number's (field/method) "exclusiveMinimum" according to @ApiModelProperty(allowableValues = "range(...")
  8. Indicate a number's (field/method) "maximum" (inclusive) according to @ApiModelProperty(allowableValues = "range...]")
  9. Indicate a number's (field/method) "exclusiveMaximum" according to @ApiModelProperty(allowableValues = "range...)")
  10. Indicate a field/method's "const"/"enum" as per @ApiModelProperty(allowableValues = ...) if it is not a numeric range declaration

Schema attributes derived from @ApiModelProperty on fields are also applied to their respective getter methods.
Schema attributes derived from @ApiModelProperty on getter methods are also applied to their associated fields.

To use it, just pass a module instance into SchemaGeneratorConfigBuilder.with(Module), optionally providing SwaggerOption values in the module's constructor.

Swagger 2 Module

The victools:jsonschema-module-swagger-2 provides a number of standard configurations for deriving JSON Schema attributes from OpenAPI/swagger (2.x) @Schema annotations.

import com.github.victools.jsonschema.generator.SchemaGeneratorConfigBuilder;
import com.github.victools.jsonschema.generator.SchemaVersion;
import com.github.victools.jsonschema.module.swagger2.Swagger2Module;


Swagger2Module module = new Swagger2Module();
SchemaGeneratorConfigBuilder configBuilder = new SchemaGeneratorConfigBuilder(SchemaVersion.DRAFT_2019_09)
    .with(module);
  1. From @Schema(description = …) on types in general, derive "description".
  2. From @Schema(title = …) on types in general, derive "title".
  3. From @Schema(subTypes = …) on types in general, derive "anyOf" alternatives.
  4. From @Schema(anyOf = …) on types in general (as alternative to subTypes), derive "anyOf" alternatives.
  5. From @Schema(name = …) on types in general, derive the keys/names in "definitions"/"$defs".
  6. From @Schema(description = …) on fields/methods, derive "description".
  7. From @Schema(title = …) on fields/methods, derive "title".
  8. From @Schema(implementation = …) on fields/methods, override represented type.
  9. From @Schema(hidden = true) on fields/methods, skip certain properties.
  10. From @Schema(name = …) on fields/methods, override property names.
  11. From @Schema(ref = …) on fields/methods, replace subschema with "$ref" to external/separate schema.
  12. From @Schema(allOf = …) on fields/methods, include "allOf" parts.
  13. From @Schema(anyOf = …) on fields/methods, include "anyOf" parts.
  14. From @Schema(oneOf = …) on fields/methods, include "oneOf" parts.
  15. From @Schema(not = …) on fields/methods, include the indicated "not" subschema.
  16. From @Schema(required = true) on fields/methods, mark property as "required" in the schema containing the property.
  17. From @Schema(requiredProperties = …) on fields/methods, derive its "required" properties.
  18. From @Schema(minProperties = …) on fields/methods, derive its "minProperties".
  19. From @Schema(maxProperties = …) on fields/methods, derive its "maxProperties".
  20. From @Schema(nullable = true) on fields/methods, include null in its "type".
  21. From @Schema(allowableValues = …) on fields/methods, derive its "const"/"enum".
  22. From @Schema(defaultValue = …) on fields/methods, derive its "default".
  23. From @Schema(accessMode = AccessMode.READ_ONLY) on fields/methods, to mark them as "readOnly".
  24. From @Schema(accessMode = AccessMode.WRITE_ONLY) on fields/methods, to mark them as "writeOnly".
  25. From @Schema(minLength = …) on fields/methods, derive its "minLength".
  26. From @Schema(maxLength = …) on fields/methods, derive its "maxLength".
  27. From @Schema(format = …) on fields/methods, derive its "format".
  28. From @Schema(pattern = …) on fields/methods, derive its "pattern".
  29. From @Schema(multipleOf = …) on fields/methods, derive its "multipleOf".
  30. From @Schema(minimum = …, exclusiveMinimum = …) on fields/methods, derive its "minimum"/"exclusiveMinimum".
  31. From @Schema(maximum = …, exclusiveMaximum = …) on fields/methods, derive its "maximum"/"exclusiveMaximum".
  32. From @ArraySchema(minItems = …) on fields/methods, derive its "minItems".
  33. From @ArraySchema(maxItems = …) on fields/methods, derive its "maxItems".
  34. From @ArraySchema(uniqueItems = …) on fields/methods, derive its "uniqueItems".

Schema attributes derived from @Schema/@ArraySchema on fields are also applied to their respective getter methods. Schema attributes derived from @Schema/@ArraySchema on getter methods are also applied to their associated fields.

To use it, just pass a module instance into SchemaGeneratorConfigBuilder.with(Module).

Maven Plugin

The victools:jsonschema-maven-plugin allows you to incorporate the generation of JSON Schemas from your code into your build process. There are a number of basic configuration options as well as the possibility to define any kind of configurations through a Module of your own.

Target Types to generate Schemas for

<plugin>
    <groupId>com.github.victools</groupId>
    <artifactId>jsonschema-maven-plugin</artifactId>
    <executions>
        <execution>
            <goals>
                <goal>generate</goal>
            </goals>
        </execution>
    </executions>
    <configuration>
        <classNames>com/myOrg/myApp/My*</classNames>
        <packageNames>com/myOrg/myApp/package?</packageNames>
        <excludeClassNames>com/myOrg/myApp/**Hidden*</excludeClassNames>
        <annotations>
            <annotation>com.myOrg.MySchemaAnnotation</annotation>
        </annotations>
        <classpath>PROJECT_ONLY</classpath>
        <failIfNoClassesMatch>false</failIfNoClassesMatch>
    </configuration>
</plugin>

The designated types can be mentioned separately (with dots as package separator) or in the form of glob patterns (with / as package separator) in <classNames> and/or included as part of their packages through <packageNames>. Through <excludeClassNames> you can further narrow down the type selection.
When specifying expected <annotations>, at least one of them need to be present on a given type to be considered – in addition to matching the aforementioned criteria, if those are present.
The considered <classpath> may be further specified as one of four values: - PROJECT_ONLY : only source files of the current project - WITH_COMPILE_DEPENDENCIES : PROJECT_ONLY and compile dependencies - WITH_RUNTIME_DEPENDENCIES : PROJECT_ONLY and runtime dependencies (default, if unspecified) - WITH_ALL_DEPENDENCIES : all of the above


By default, the plugin aborts if the glob pattern does not match any class. If this is not desired, the <failIfNoClassesMatch> property can be set to false.

Basic Configuration Options

There are some additional parameters available in the plugin <configuration>:

# Tag Default Description
1 <schemaFilePath> src/main/resources Directory to generate all schemas in
2 <schemaFileName> {0}-schema.json Relative path from the <schemaFilePath> including the file name pattern. Two placeholders are supported: {0} will be replaced with the respective simple class name (e.g. TypeA) {1} will be replaced with the respective package path (e.g. com/myOrg/myApp) in case you want to preserve the original package structure
3 <schemaVersion> DRAFT_7 JSON Schema version to apply (DRAFT_6, DRAFT_7, DRAFT_2019_09 or DRAFT_2020_12)

Configuring generated file names and locations

<configuration>
    <classNames>com.myOrg.myApp.MyClass</classNames>
    <schemaFilePath>src/main/resources/schemas</schemaFilePath>
</configuration>

The location where the files will be generated can be specified with the <schemaFilePath> element. The default path is src/main/resources

<configuration>
    <classNames>com.myOrg.myApp.MyClass</classNames>
    <schemaFileName>{0}.schema</schemaFileName>
</configuration>

The name of the generated schema files can be configured with the <schemaFileName> element. This is a substitution pattern that is used for all generated files. It following the MessageFormat syntax, where the following variables can be used: - {0} : This is the name of the class - {1} : This is the package path of the class

For example, the given configuration will create a MyClass.schema file.

<configuration>
    <packageNames>com.myOrg.myApp.utils</packageNames>
    <schemaFileName>{1}/{0}.schema</schemaFileName>
</configuration>

To store the generated schema files in the same directory structure as the originating classes, the following can be used <schemaFileName>{1}/{0}-schema.json</schemaFileName>.
The default <schemaFileName> is {0}-schema.json.

Selecting Options

<options>
    <preset>FULL_DOCUMENTATION</preset>
    <enabled>
        <option>DEFINITIONS_FOR_ALL_OBJECTS</option>
        <option>FORBIDDEN_ADDITIONAL_PROPERTIES_BY_DEFAULT</option>
    </enabled>
    <disabled>SCHEMA_VERSION_INDICATOR</disabled>
</options>

The standard generator Options can be included via the <options> tag.

Further configurations through Modules

<modules>
    <module>
        <name>Jackson</name>
        <options>
            <option>FLATTENED_ENUMS_FROM_JSONVALUE</option>
        </options>
    </module>
</modules>

Through the <modules> tag you can include the standard modules – potentially with their <options> if there are any.

<modules>
    <module>
        <className>com.myOrg.myApp.CustomModule</className>
    </module>
</modules>

You can also group any kind of configurations into a Module of your own and include it via its full class name.
Make sure your custom module is on the classpath (considering the project itself as well as all compile and runtime dependencies) and has a default constructor.
It is not possible to configure options for custom modules.

FAQ

Is there a Gradle Plugin?

import com.github.victools.jsonschema.generator.OptionPreset;
import com.github.victools.jsonschema.generator.SchemaGenerator;
import com.github.victools.jsonschema.generator.SchemaGeneratorConfigBuilder;
import com.github.victools.jsonschema.generator.SchemaVersion;

buildscript {
    repositories {
        mavenCentral()
    }
    dependencies {
        classpath group: 'com.github.victools', name: 'jsonschema-generator', version: '4.16.0'
    }
}
plugins {
    id 'java-library'
}

task generate {
    doLast {
        def configBuilder = new SchemaGeneratorConfigBuilder(SchemaVersion.DRAFT_2019_09, OptionPreset.PLAIN_JSON);
        // apply your configurations here
        def generator = new SchemaGenerator(configBuilder.build());
        // target the class for which to generate a schema
        def jsonSchema = generator.generateSchema(SchemaVersion.class);
        // handle generated schema, e.g. write it to the console or a file
        def jsonSchemaAsString = jsonSchema.toPrettyString();
        println jsonSchemaAsString
        new File(projectDir, "schema.json").text = jsonSchemaAsString
    }
}

There currently is no dedicated Gradle Plugin as such, but Gradle is flexible enough to allow you to use a java library straight from within the build.gradle file. CHeckout https://github.com/victools/jsonschema-gradle-example for the complete example.

What about enums?

If you have a custom serialization logic for converting enum values to strings, you can re-use it in order to generate the correct list of allowed values:

ObjectMapper objectMapper = new ObjectMapper();
// make use of your enum handling e.g. through your own serializer
// objectMapper.registerModule(new YourCustomEnumSerializerModule());
configBuilder.with(new EnumModule(possibleEnumValue -> {
    try {
        String valueInQuotes = objectMapper.writeValueAsString(possibleEnumValue);
        return valueInQuotes.substring(1, valueInQuotes.length() - 1);
    } catch (JsonProcessingException ex) {
        throw new IllegalStateException(ex);
    }
}));

Enums are a special construct for which there are multiple options:

  1. Option.FLATTENED_ENUMS (which is part of the OptionPreset.PLAIN_JSON)
    • This defines an enum as { "type": "string", "enum": ["VALUE1", "VALUE2"] } with the name() method being called on each possible enum value.
    • If there is only one enum value, it will be set as "const" instead.
    • Such an enum representation will always be in-lined and not moved into the "definitions"/"$defs".
  2. Option.SIMPLIFIED_ENUMS(which is part of the OptionPreset.JAVA_OBJECT and OptionPreset.FULL_DOCUMENTATION)
    • This treats enums like any other class but hiding some methods and listing the possible enum values as "enum"/"const" on the name() method.
  3. Using neither of the two Options above will let them be handled like any other class (unless there are further configurations taking care of enums).
  4. The JacksonModule comes with two more alternatives:
    • JacksonOption.FLATTENED_ENUMS_FROM_JSONVALUE, behaving like Option.FLATTENED_ENUMS but looking-up the respective values via the @JsonValue annotated method.
    • JacksonOption.FLATTENED_ENUMS_FROM_JSONPROPERTY, behaving like Option.FLATTENED_ENUMS but looking-up the respective values via the @JsonProperty annotation on each enum value/constant.
  5. Write your own custom definition provider or re-use the EnumModule class as in the shown example.

How to always inline enums?

class InlineAllEnumsDefinitionProvider implements CustomDefinitionProviderV2 {
    @Override
    public CustomDefinition provideCustomSchemaDefinition(ResolvedType javaType, SchemaGenerationContext context) {
        if (javaType.isInstanceOf(Enum.class)) {
            ObjectNode standardDefinition = context.createStandardDefinition(javaType, this);
            return new CustomDefinition(standardDefinition,
                    CustomDefinition.DefinitionType.INLINE,
                    CustomDefinition.AttributeInclusion.YES);
        }
        return null;
    }
}
configBuilder.forTypesInGeneral()
        .withCustomDefinitionProvider(new InlineAllEnumsDefinitionProvider())

If you want to generally avoid that enums are being referenced via the $defs/definitions (even with active Option.DEFINITIONS_FOR_ALL_OBJECTS), a construct like the InlineAllEnumsDefinitionProvider on the right can be used.
As usual, such a Custom Type Definition can be added via configBuilder.forTypesInGeneral().withCustomDefinitionProvider() accordingly.

Where can I find some more configuration examples?

Internally, a number of the standard Options are realized via Individual Configurations and/or Advanced Configurations – grouped into Modules.
These make for excellent examples to get you started into your own setup, if the existing Options do not cover your specific requirements.

How to represent a Map<K, V> in a generated schema?

configBuilder.forTypesInGeneral()
    .withPatternPropertiesResolver((scope) -> {
        if (scope.getType().isInstanceOf(Map.class)) {
            // within a Map<Key, Value> allow additional properties of the Value type, with purely alphabetic keys
            Map<String, Type> patternProperties = new HashMap<>();
            // theoretically, you could derive an appropriate schema from the key type, accessible via the same getTypeParameterFor() method
            // if no type parameters are defined, this will result in `{}` to be set as value schema and thereby allowing any values for matching keys
            patternProperties.put("^[a-zA-Z]+$", scope.getTypeParameterFor(Map.class, 1));
            return patternProperties;
        }
        return null;
    });

By default, a Map will be treated like any other type – i.e. most likely a simple { "type": "object" } without many further details if you use the OptionPreset.PLAIN_JSON or otherwise ignore methods. The following are the two most common approaches:

  1. Indicate the value type V as the expected type for any "additionalProperties" by including the Option.MAP_VALUES_AS_ADDITIONAL_PROPERTIES.
    You may also want to consider including the Option.FORBIDDEN_ADDITIONAL_PROPERTIES_BY_DEFAULT to forbid "additionalProperties" everywhere else.
  2. If you have a clear idea of how the key type K will be serialized, you could also describe valid keys via "patternProperties" instead – as per the example on the right.

Refer to https://json-schema.org/understanding-json-schema/reference/regular_expressions.html for a description of how to build valid patterns.

How to populate default values?

Example 1

configBuilder.forFields().withDefaultResolver(field -> {
    JsonProperty annotation = field.getAnnotationConsideringFieldAndGetter(JsonProperty.class);
    return annotation == null || annotation.defaultValue().isEmpty() ? null : annotation.defaultValue();
});

Example 2

ConcurrentMap<Class<?>, Object> instanceCache = new ConcurrentHashMap<>();
configBuilder.forFields().withDefaultResolver(field -> {
    Class<?> declaringClass = field.getDeclaringType().getErasedType();
    if (!field.isFakeContainerItemScope()
            && declaringClass.getName().startsWith("your.package")) {
        MethodScope getter = field.findGetter();
        if (getter != null) {
            try {
                Object instance = instanceCache.computeIfAbsent(declaringClass, declaringClass::newInstance);
                Object defaultValue = getter.getRawMember().invoke(instance);
                return defaultValue;
            } catch (Exception ex) {
                // most likely missing a no-args constructor
            }
        }
    }
    return null;
});

The short answer is: via the withDefaultResolver() – one of the Individual Configurations.
The exact details depend on how the default value can be determined.

  1. If the default value is explicitly stated via some kind of annotation, it might be as simple as "Example 1" on the right.
  2. If the default value is only set in code, and you cannot or don't want to maintain that piece of information twice this can get a bit more advanced. Here assuming your own classes all have a default no-args constructor and conventional getters as in "Example 2" on the right.

How to reference a separate schema/file?

configBuilder.forTypesInGeneral()
        .withCustomDefinitionProvider((javaType, context) -> {
            if (javaType.getErasedType() != MyExternalType.class) {
                // other types should be treated normally
                return null;
            }
            // define your custom reference value
            String refValue = "./" + javaType.getErasedType().getSimpleName();
            // produce the sub-schema that only contains your custom reference
            ObjectNode customNode = context.getGeneratorConfig().createObjectNode()
                    .put(context.getKeyword(SchemaKeyword.TAG_REF), refValue);
            return new CustomDefinition(customNode,
                    // avoid the creation of a reference to your custom reference schema
                    CustomDefinition.DefinitionType.INLINE,
                    // still allow for collected schema attributes to be added
                    CustomDefinition.AttributeInclusion.YES);
        });

By using withCustomDefinitionProvider() – one of the advanced configurations – you can fully control the contents of a type's sub-schema. Simply create a node that only contains your custom/external reference instead of the actual schema. It is recommended to mark the custom definition as "to be inlined", in order to avoid an extra entry in the "definitions"/"$defs".

Motivation

This started out as (and still is) a personal project. After writing Java code professionally for over a decade, I am more and more consumed with rather theoretical work in my role as a business analyst and product owner – i.e. writing specifications instead of code. However, my past still causes me to research available libraries and components when coming up with ideas for new features, based on which I can then (at least try to) write "realistic" specifications, i.e. features that can be feasibly implemented without requiring our developers to create something from scratch (who'd want to spend their valuable time and resources for that?). Open Source is awesome! Unfortunately, such research does not always yield satisfying results.

The background for this particular requirement was very close to my heart as it affected me personally quite often. I'm working at Torque IT Solutions – a small company that puts a lot of emphasis on providing software products instead of building custom applications for each customer. That means, such a product needs to be generic enough to be a fit for every customer. Amongst other things, we do this through heaps of configuration options. In order to achieve the desired flexibility, these configurations are sometimes very technical. E.g. allowing our customers (i.e. users) to define JavaScript expressions based on our own Java DOM. In reality this means quite often: customers can configure it themselves but will still ask us/me to provide those expressions. However, that poses the challenge of documenting our DOM in a way that it can be used without having access to the code itself or its JavaDoc. I couldn't find a nice way of doing the above without sentencing myself to constantly maintain a huge amount of documentation.

It was development time again! I only needed to find some existing standard for documenting data structures (there would surely be a way of automatically generating it from code then) and somehow visualize it, to allow non-developers to use it. I quickly decided on the JSON Schema specification (although still in Draft version 6 then) and started working on my react-jsonschema-inspector component. While I had already spent considerable amounts of my spare time on this frontend component (my first ReactJS component – I really missed the strong Java typing!), I realized that the existing JSON Schema generation libraries typically expected some specific annotations throughout the code base for the sole purpose of generating a JSON Schema. I surely wasn't expecting our developers to go through hundreds of classes and specifying schema parts (by this time Draft version 7) throughout the codebase. And none of the existing generation libraries seemed to allow for methods to be documented (understandable, if you only aim at documenting a JSON structure, but not good enough for my purposes after all).

Once more: development time! A new JSON Schema generation library needed to be created, as Open Source of course! At least I was back in my familiar Java world. The whole topic of introspecting java types was a fun challenge that ended in me adopting the use of the awesome com.fasterxml/classmate library – written by one of the maintainers of the Jackson library, i.e. one who had worked with this kind of thing for a number of years already. Then it was only about abstracting the actual schema generation from its configuration while still allowing almost all aspects of the process to be customized to not force someone else to create yet another generation library just because mine was too opinionated to be reusable (especially since Draft 2019-09 had just been published).

In our existing codebase, we already had annotations for various purposes: Jackson annotations to facilitate the (de)serialization for our Rest API, Swagger annotations to document that Rest API, javax.validation annotations for realizing automatic validations of incoming data and during persistence. I very much liked the modular configuration approach in Jackson so I ended up employing the same principle to wrap a few standard configurations for easier re-use. Feedback from some early adopters led to those few standard configurations to be further extended – especially the subtype resolution/polymorphism seemed to have been an important point also in the creation of other libraries, e.g. for the mbknor-jackson-jsonSchema.