Course – LS (cat=JSON/Jackson)

Get started with Spring and Spring Boot, through the Learn Spring course:

>> CHECK OUT THE COURSE

1. Overview

In this tutorial, we’ll create JSON Schemas from Java using the Java JSON Schema Generator library.

First, we’ll see how to generate simple and recursive JSON Schemas. Next, we’ll look at the different schema configurations available. Moving on, we’ll successively derive JSON Schemas from some of the library’s modules: Jackson and Jakarta validation. Finally, we’ll set up a Maven plugin to have JSON Schemas at the Maven generate goal.

2. Setup

Let’s set up the necessary dependencies for our project.

2.1. Core Dependency

First, let’s install jsonschema-generator:

<dependency>
    <groupId>com.github.victools</groupId>
    <artifactId>jsonschema-generator</artifactId>
    <version>4.31.1</version>
</dependency>

It contains the main APIs for schema generation and configuration.

2.2. Modules

Next, we’ll install three modules to generate JSON Schema attributes from class annotations. Let’s start by adding jsonschema-module-jackson:

<dependency>
    <groupId>com.github.victools</groupId>
    <artifactId>jsonschema-module-jackson</artifactId>
    <version>4.31.1</version>
</dependency>

This module derives JSON Schema attributes from Jackson annotations.

Continuing, we’ll install jsonschema-module-jakarta-validation to get the schema from Jakarta validation annotations:

<dependency>
    <groupId>com.github.victools</groupId>
    <artifactId>jsonschema-module-jakarta-validation</artifactId>
    <version>4.31.1</version>
</dependency>

2.3. Maven Plugin

Finally, let’s add jsonschema-maven-plugin:

<plugin>
    <groupId>com.github.victools</groupId>
    <artifactId>jsonschema-maven-plugin</artifactId>
    <version>4.31.1</version>
    <executions>
        <execution>
            <goals>
                <goal>generate</goal>
            </goals>
        </execution>
    </executions>
</plugin>

Later on, we’ll define the configuration entry. It takes the classes to generate a schema from, the schema configuration, and the modules to be used.

Let’s note that since version 4.7 of Java JSON Schema Generator, it’s highly recommended that modules and plugins use the same version as the core dependency.

3. Basics

In this section, we’ll explore the building blocks of the jsonschema-generator by creating simple and recursive schemas.

3.1. Simple Schema

Let’s define an Article:

public class Article { 
    private UUID id;
    private String title;
    private String content;
    private Date createdAt;
    private Area area;
    // getters and setters omitted
 }

We’ll generate a schema from the Article class:

SchemaGeneratorConfigBuilder configBuilder = new SchemaGeneratorConfigBuilder(SchemaVersion.DRAFT_2020_12, OptionPreset.PLAIN_JSON);
SchemaGeneratorConfig config = configBuilder.with(Option.EXTRA_OPEN_API_FORMAT_VALUES)
  .without(Option.FLATTENED_ENUMS_FROM_TOSTRING)
  .build();

SchemaGenerator generator = new SchemaGenerator(config);
JsonNode jsonSchema = generator.generateSchema(Article.class);

Here, we’re targeting DRAFT_2020-12, the latest JSON Schema draft at this time. If not specified, the schema will be generated using DRAFT-7 specification.

The PLAIN_JSON OptionPreset holds a lot of default configurations to use every non-static class field for schema generation. The other available presets are JAVA_OBJECT and FULL_DOCUMENTATION. The first includes public fields and methods in the schema while the second includes all fields and public methods. If not specified, the preset defaults to FULL_DOCUMENTATION.

The generated schema respects the DRAFT_2020-12 structure:

{
    "$schema":"https://json-schema.org/draft/2020-12/schema",
    "type":"object",
    "properties":{
        "area":{
            "type":"string",
            "enum":[
                "JAVA",
                "KOTLIN",
                "SCALA",
                "LINUX"
            ]
        },
        "content":{
            "type":"string"
        },
        "createdAt":{
            "type":"string",
            "format":"date-time"
        },
        "id":{
            "type":"string",
            "format":"uuid"
        },
        "title":{
            "type":"string"
        }
    }
}

Let’s note a couple of things here. First, Java Date and UUID are strings in the schema. Fortunately, their real types are specified in the field format, thanks to the EXTRA_OPEN_API_FORMAT_VALUES generator option. It adds extra information for special JSON Schema strings.

Finally, Java enums are represented by calling their name() method.

3.2. Recursive Schema

Let’s have an Author class:

public class Author {
    private UUID id;
    private String name;
    private String role;
    private List<AuthoredArticle> articles;
    // getters, setters, omitted
}

An author has a list of AuthoredArticle. Conversely, an AuthoredArticle has an author:

public class AuthoredArticle {
    private Author author;
    // getters and setters omitted
}

Keeping all configurations from the previous section, the schema for the AuthoredArticle class is a recursive schema.

Interestingly, the articles field of the author property references the actual schema being generated:

{
    "author":{
        "type":"object",
        "properties":{
            "articles":{
                "type":"array",
                "items":{
                    "$ref":"#"
                }
            }
        }
    }
}

This kind of circular referencing is allowed by the specification. However, a $ref cannot point to another $ref.

4. Configuration

In the previous section, we used some built-in presets. Now, we’ll see how to achieve fine-grained configurations.

First, we’ll customize the generated schema attributes using individual configurations. Then, we’ll have a sneak peek at advanced configurations.

4.1. Individual Configuration

Let’s configure schema fields for our Author class:

configBuilder.forFields()
  .withRequiredCheck(field -> field.getAnnotationConsideringFieldAndGetter(Nullable.class) == null)
  .withArrayUniqueItemsResolver(scope -> scope.getType().getErasedType() == (List.class) ? true : null);

The resulting schema marks properties that aren’t nullable as required. It also makes the articles property a unique array:

{
    "$schema":"https://json-schema.org/draft/2020-12/schema",
    "type":"object",
    "properties":{
        "articles":{
            "uniqueItems":true,
            "type":"array",
            "items":{
                "type":"object",
                "properties":{
                    "area":{
                        "type":"string",
                        "enum":[
                            "JAVA",
                            "KOTLIN",
                            "SCALA",
                            "LINUX"
                        ]
                    },
                    "author":{
                        "$ref":"#"
                    },
                    "content":{
                        "type":"string"
                    },
                    "createdAt":{
                        "type":"string",
                        "format":"date-time",
                        "default":1690565063847
                    },
                    "id":{
                        "type":"string",
                        "format":"uuid"
                    },
                    "title":{
                        "type":"string"
                    }
                },
                "required":[
                    "area",
                    "author",
                    "content",
                    "createdAt",
                    "id",
                    "title"
                ]
            },
            "default":[
                
            ]
        },
        "id":{
            "type":"string",
            "format":"uuid"
        },
        "name":{
            "type":"string"
        },
        "role":{
            "type":"string"
        }
    },
    "required":[
        "articles",
        "id",
        "name",
        "role"
    ]
}

The schema above has also default values for the createdAt and articles properties. It’s due to our configuration for types:

configBuilder.forTypesInGeneral()
  .withArrayUniqueItemsResolver(scope -> scope.getType().getErasedType() == (List.class) ? true : null)
  .withDefaultResolver(scope -> scope.getType().getErasedType() == List.class ? Collections.EMPTY_LIST : null)
  .withDefaultResolver(scope -> scope.getType().getErasedType() == Date.class ? Date.from(Instant.now()) : null);

The ArrayUniqueItemsResolver ensures that an array is marked unique if it was generated from a List type.

Just like we’ve configured fields and types, we’re also able to configure methods:

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

We mark fields annotated @NotNull as required. They are also required if that annotation is on their getter.

Besides, for each configuration, returning null doesn’t set the field in the schema.

4.2. Advanced Configuration

In this section, we’ll use our AdvancedArticle class:

public class AdvancedArticle {
    private UUID id;
    private String title;
    private String content;
    @AllowedTypes({Timestamp.class, String.class, Date.class})
    private Object createdAt;
    private Area area;
    // getters and setters omitted 
}

Advanced configuration is the ultimate resort to customize JSON Schema generation. It’s particularly useful when we need attributes that aren’t provided by the individual configuration:

configBuilder.forFields()
  .withInstanceAttributeOverride((node, field, context) -> node.put("readOnly", field.getDeclaredType().isInstanceOf(UUID.class)));

Here, we’ve added a readOnly attribute to every property. It defaults to false except for the UUID class:

{
    "id":{
        "type":"string",
        "format":"uuid",
        "readOnly":true
    },
    "title":{
        "type":"string",
        "readOnly":false
    }
}

Another interesting configuration is the ability to specify allowed types in a given field. In our AdvancedArticle class, the createdAt property accepts both Date and Timestamp types:

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

Under the hood, the TargetTypeOverride class processes every field annotated @AllowedTypes. Then, it adds those types to the resulting createdAt property:

{
    "createdAt":{
        "anyOf":[
            {
                "type":"object",
                "properties":{
                    "nanos":{
                        "type":"integer",
                        "format":"int32",
                        "readOnly":false
                    }
                },
                "readOnly":false
            },
            {
                "type":"string",
                "format":"date-time",
                "readOnly":false
            }
        ]
    }
}

As we can see, the resulting union type is specified by the anyOf attribute.

Let’s keep in mind that the configuration possibilities are endless. We can even add custom type definitions or custom property definitions. It’s up to us to choose which level of customization to cover our needs.

5. Modules

Java JSON Schema Generator allows us to group configurations in modules. We can create our own modules by implementing the Module interface. In the next sections, we’ll see how to use some of the built-in modules. We’ll explore Jackson and Jakarta Validation modules.

5.1. Jackson

The Jackson module processes Jackson annotations to create JSON Schema configuration. Let’s consider our Person class:

class Person {

    @JsonProperty(access = JsonProperty.Access.READ_ONLY)
    UUID id;

    @JsonProperty(access = JsonProperty.Access.READ_WRITE, required = true)
    String name;

    @JsonProperty(access = JsonProperty.Access.READ_WRITE, required = true)
    String surname;

    @JsonProperty(access = JsonProperty.Access.READ_WRITE, required = true)
    Address address;

    @JsonIgnore
    String fullName;

    @JsonProperty(access = JsonProperty.Access.READ_ONLY)
    Date createdAt;

    @JsonProperty(access = JsonProperty.Access.READ_WRITE)
    List<Person> friends;
    //getters and setters omitted
}

Let’s add JacksonModule to our SchemaGeneratorConfigBuilder:

JacksonModule module = new JacksonModule(RESPECT_JSONPROPERTY_REQUIRED);
SchemaGeneratorConfigBuilder configBuilder = new SchemaGeneratorConfigBuilder(DRAFT_2020_12, PLAIN_JSON).with(module)
  .with(EXTRA_OPEN_API_FORMAT_VALUES);

SchemaGenerator generator = new SchemaGenerator(configBuilder.build());
JsonNode jsonSchema = generator.generateSchema(Person.class);

The module accepts certain options for further customization. The RESPECT_JSONPROPERTY_REQUIRED option instructs the module to consider JsonProperty.Access in the generation of the readOnly field in the schema.

The resulting schema has required and readOnly fields properly set:

{
    "type":"object",
    "properties":{
        "createdAt":{
            "type":"string",
            "format":"date-time",
            "readOnly":true
        },
        "friends":{
            "type":"array",
            "items":{
                "$ref":"#"
            }
        },
        "id":{
            "type":"string",
            "format":"uuid",
            "readOnly":true
        }
    },
    "required":[
        "address",
        "name",
        "surname"
    ]
}

Non-annotated properties and properties annotated with @JsonIgnore are ignored. Nested fields for Address class and recursive schema for friends property are properly referenced.

5.2. Jakarta Validation

Jakarta Validation module generates schema configuration from jakarta.validation.constraints annotations. Let’s decorate our Person class:

class Person {

    @NotNull
    UUID id;

    @NotNull
    String name;

    @NotNull
    @Email
    @Pattern(regexp = "\\b[A-Za-z0-9._%+-]+@baeldung\\.com\\b")
    String email;

    @NotNull
    String surname;

    @NotNull
    Address address;

    @Null
    String fullName;

    @NotNull
    Date createdAt;

    @Size(max = 10)
    List<Person> friends;
    //getters and setters omitted

}

Next, let’s configure JakartaValidationModule:

JakartaValidationModule module = new JakartaValidationModule(NOT_NULLABLE_FIELD_IS_REQUIRED, INCLUDE_PATTERN_EXPRESSIONS);
SchemaGeneratorConfigBuilder configBuilder = new SchemaGeneratorConfigBuilder(DRAFT_2020_12, PLAIN_JSON).with(module);

SchemaGenerator generator = new SchemaGenerator(configBuilder.build());
JsonNode jsonSchema = generator.generateSchema(Person.class);

The module optionally takes validation groups via its forValidationGroups() method.

The NOT_NULLABLE_FIELD_IS_REQUIRED option makes fields annotated with @NotNull required. Thanks to the INCLUDE_PATTERN_EXPRESSIONS, the generated schema includes a pattern field for all properties annotated with @Pattern:

{
    "type":"object",
    "properties":{
        "createdAt":{
            "type":"string"
        },
        "email":{
            "type":"string",
            "format":"email",
            "pattern":"\\b[A-Za-z0-9._%+-]+@baeldung\\.com\\b"
        },
        "friends":{
            "maxItems":10,
            "type":"array",
            "items":{
                "$ref":"#"
            }
        },
        "fullName":{
            "type":[
                "string",
                "null"
            ]
        }
    },
    "required":[
        "createdAt",
        "email",
        "id",
        "name",
        "surname"
    ]
}

Let’s note that the email property has a format field due to it being annotated with @Email in the Person class. Likewise, the friends property has its maxItems field properly set.

6. Maven Plugin

Java JSON Schema Generator has a Maven plugin to generate schema from our build process. Let’s configure the plugin:

<plugin>
    <groupId>com.github.victools</groupId>
    <artifactId>jsonschema-maven-plugin</artifactId>
    <version>4.31.1</version>
    <executions>
        <execution>
            <goals>
                <goal>generate</goal>
            </goals>
        </execution>
    </executions>
    <configuration>
        <packageNames>
            <packageName>com.baeldung.jsonschemageneration.plugin</packageName>
        </packageNames>
        <classNames>
            <className>com.baeldung.jsonschemageneration.plugin.Person</className>
        </classNames>
        <schemaVersion>DRAFT_2020_12</schemaVersion>
        <schemaFilePath>src/main/resources/schemas</schemaFilePath>
        <schemaFileName>{1}/{0}.json</schemaFileName>
        <failIfNoClassesMatch>true</failIfNoClassesMatch>
        <options>
            <preset>PLAIN_JSON</preset>
            <enabled>
                <option>DEFINITIONS_FOR_ALL_OBJECTS</option>
                <option>FORBIDDEN_ADDITIONAL_PROPERTIES_BY_DEFAULT</option>
            </enabled>
            <disabled>SCHEMA_VERSION_INDICATOR</disabled>
        </options>
        <modules>
            <module>
                <name>Jackson</name>
                <options>
                    <option>RESPECT_JSONPROPERTY_REQUIRED</option>
                </options>
            </module>
            <module>
                <name>JakartaValidation</name>
                <options>
                    <option>NOT_NULLABLE_FIELD_IS_REQUIRED</option>
                    <option>INCLUDE_PATTERN_EXPRESSIONS</option>
                </options>
            </module>
        </modules>
    </configuration>
</plugin>

We’ll generate a schema based on the Person class located in the com.baeldung.jsonschemageneration.plugin package. We can still define the modules to use, and pass them some options. Yet, the plugin doesn’t allow configuring options for custom modules.

Finally, the generated file name pattern is composed of {1} which is the package name and {0}, the class name. It will be located at src\main\resources\schemas\com\baeldung\jsonschemageneration\plugin\Person.json. To generate it, let’s run mvn compile.

The resulting schema respects every condition specified in the plugin configuration.

7. Conclusion

In this article, we’ve used Java JSON Schema Generator to generate JSON Schemas in Java. After some basics of schema generation, we’ve seen how to fine-tune the schema configuration. Next, we explored various modules from the library to generate JSON schema. Ultimately, we’ve created JSON Schemas as part of our Maven generate goal using a dedicated plugin.

As always, the code for this article is available over on GitHub.

Course – LS (cat=JSON/Jackson)

Get started with Spring and Spring Boot, through the Learn Spring course:

>> CHECK OUT THE COURSE
res – Jackson (eBook) (cat=Jackson)
Comments are closed on this article!