Skip to content

Latest commit

 

History

History

generator

Folders and files

NameName
Last commit message
Last commit date

parent directory

..
 
 
 
 
 
 

javadoc

Creek JSON schema generator

A command line tool for generating JSON schemas from code.

NOTE

There is a Gradle plugin for generating JSON schemas as part of your build process.

Executing the schema generator

The schema generator is designed to be run from build plugins, like the Creek Schema Gradle Plugin. However, it can be run directly as a command line tool:

  java \ 
    --module-path <lib-path> \
    --module creek.json.schema.generator/org.creekservice.api.json.schema.generator.JsonSchemaGenerator \
    --output-directory=some/path

(Run with --help for an up-to-date list of arguments)

...or you can interact programmatically with the main JsonSchemaGenerator class.

Schema generation

The generator searches the class and module path for types annotated with @GeneratesSchema and writes out a JSON schema for each. Internally it is using the Jackson JSON Schema Generator. It honours both Jackson's annotations and the generator's annotations.

The generator should work with any JVM based language. See the Kotlin example below for a non-Java example.

Schemas are currently written in YAML, as this is compatible with, but more succinct than, JSON. See issue-17 if you would like to vote on having an option to write schemas out in JSON.

See below for some examples and refer to both the Jackson and generators documentation for more information.

Simple model

A simple type, without any annotations other than @GeneratesSchema, will generate a schema compatible with how Jackson would serialize the type, i.e. it will include any properties with standard getter names:

For example:

@GeneatesSchema
public class SimpleModel {
   public int getIntProp() {
      // ...
   }

   public Optional<String> getStringProp() {
      // ...
   }
}

...will produce the schema:

---
$schema: http://json-schema.org/draft-07/schema#
title: Simple Model
type: object
additionalProperties: false
properties:
  intProp:
    type: integer
  stringProp:
    type: string
required:
- intProp

Properties for both the getters have been included in the above schema.

The integer property is marked as required, as primitive types can't be null.

The string property is not marked as required, as non-primitive types can be null. However, the schema intentionally does not allow the string property to be explicitly set to null. (Defaults in Creek encourage developers away from using null values). Instead, any JSON that omits the string property is valid, i.e. rather than setting stringProp to null if it's not provided, the stringProp property should just not be set.

While Creek strongly recommends avoiding the use of null in JSON documents, just as it recommends avoiding null in Java code, it is still possible to build a schema that accepts null values. See allowing null values for more info.

NOTE

It is important to ensure properties with null values are excluded when serialising data to JSON. Failure to do so may result in schema validation failure. Creek's own serializers do this by default.

It is recommended, but not required by the plugin, to use the Optional standard Java type for optional properties.

Non-primitive properties can be marked required using Jackson annotations.

Type mapping

As you can see from the simple model example above, the generator automatically converts some standard Java types to their JSON schema counterparts.

The generator will also leverage the JSON Schema's format specifier to convert the types below:

Java type Schema Type
URI type: string
format: uri
UUID type: string
format: uuid
OffsetTime type: string
format: time
OffsetDateTime type: string
format: date-time
LocalDate type: string
format: date
LocalTime type: string
format: none
see note 1 below
LocalDateTime type: string
format: date-time
ZonedDateTime type: string
format: date-time
see note 2 below
MonthDay type: string
format: none
see note 3 below
YearMonth type: string
format: none
see note 3 below
Year type: string
format: none
see note 3 below
Instant type: string
format: date-time
Duration type: number
format: none
see note 4 below
Period type: string
format: duration

Note 1: LocateTime properties will be of type string in the generated schema, but will not have a time format set. This is because, Jackson serialization does not include an offset when serializing LocalTime and the time format requires an offset. For this reason, the use of LocalTime is discouraged: use OffsetTime instead.

Note 2: ZonedDateTime properties will be of type string and a format of date-time in the generated schema. However, serialization of the textual zone information is not platform / language independent. Jackson does not serialize the zone information, only the offset information. The serialized form is the same as OffsetDateTime. For this reason, the use of ZonedDateTime is discouraged: use OffsetDateTime instead.

Note 3: Properties of type MonthDay, YearMonth & Year will be of type string, but with no format, in the generated schema. Jackson will serialize these types as strings, assuming the JavaTimeModule module is installed. However, there is no defined JSON format to match these types.

Note 4: Duration properties are serialized by Jackson as numbers, therefore the generated schema will define the property as type number with no format.

For example:

@GeneratesSchema
public class FormatModel {
    public URI getURI() {
        // ...
    }
    
    public Instant getInstant() {
        // ...
    }

    public OffsetDateTime getDateTime() { 
        // ...
    }
}

...will produce the schema:

---
$schema: http://json-schema.org/draft-07/schema#
title: Format Model
type: object
additionalProperties: false
properties:
  dateTime:
    type: string
    format: date-time
  instant:
     type: string
     format: date-time
  uri:
    type: string
    format: uri

Compatible Jackson configuration

The above type mapping is not automatically compatible with Jackson's default JSON serialization.

// The minimal configuration for Jackson is shown below:

class Example {
 JsonMapper mapper = JsonMapper.builder()
        .addModule(new Jdk8Module()) // Note 1
        .addModule(new JavaTimeModule()) // Note 2
        .serializationInclusion(JsonInclude.Include.NON_EMPTY) // Note 3
        .disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS) // Note 4
        .disable(DeserializationFeature.ADJUST_DATES_TO_CONTEXT_TIME_ZONE) // Note 5
        .build();
}

Note 1: The Jdk8Module is required if models include any optional JDK types, e.g. Optional, OptionalLong etc.

Note 2: The JavaTimeModule is required if models include any temporal JDK types, e.g. Instant, OffsetDateTime etc.

Note 3: The generated schema does not allow null values by default by design. Nulls are a common source of bugs and are best avoid in data, just like in code. As the generated schema doesn't allow null values, Jackson will need configuring to exclude properties with a null value. Setting serialization inclusion to NON_EMPTY will exclude properties with:

  • a null value
  • a Optional.empty() value
  • or return a empty collection or array.

See Allowing null values if you really want to allow nulls in your data.

Note 4: The generated schema will map JDK temporal times, e.g. OffsetDateTime, to type string with an appropriate format. Jackson, by default, writes dates as timestamps. This feature must be disabled so dates are written as schema-compatible strings.

Note 5: (Optional): If you require temporal types to maintain the serialized offset, disable ADJUST_DATES_TO_CONTEXT_TIME_ZONE. By default, Jackson will adjust temporal types to their local time equivalent when deserializing.

Jackson Annotations

The generator honours Jackson's annotations. For example:

@GeneratesSchema
public class JacksonModel {

    public JacksonModel(
            @JsonProperty(value = "required_prop", required = true) final String requiredProp,
            @JsonProperty("optional_prop") final Optional<String> optionalProp) {
        //...
    }

    @JsonIgnore
    public String getIgnoredProp() {
        // ...
    }

    @JsonGetter("required_prop")
    public String required() {
        // ...
    }

    @JsonGetter("optional_prop")
    public Optional<String> optional() {
        // ...
    }
}

...will produce the schema:

---
$schema: http://json-schema.org/draft-07/schema#
title: Jackson Model
type: object
additionalProperties: false
properties:
  optional_prop:
    type: string
  required_prop:
    type: string
required:
  - required_prop

Polymorphic types

Polymorphism is supported via the standard Jackson @JsonTypeInfo annotation.

For example:

@GeneratesSchema
@JsonTypeInfo(use = JsonTypeInfo.Id.NAME)
@JsonSubTypes({
        @JsonSubTypes.Type(value = ExplicitlyNamedType.class, name = "type_1"),
        @JsonSubTypes.Type(value = ImplicitlyNamedType.class)
})
public interface PolymorphicModel {}

public class ExplicitlyNamedType implements PolymorphicModel {
    public String getProp1() {
        // ...
    }
}

private class SubType2 implements PolymorphicModel {
    public String getProp2() {
        // ...
    }
}

...will produce the schema:

---
$schema: http://json-schema.org/draft-07/schema#
title: Polymorphic Model
oneOf:
  - $ref: '#/definitions/ExplicitlyNamedType'
  - $ref: '#/definitions/ImplicitlyNamedType'
definitions:
  SubType1:
    type: object
    additionalProperties: false
    properties:
      '@type':
        type: string
        enum:
          - type_1
        default: type_1
      prop1:
        type: string
    title: type_1
    required:
      - '@type'
  SubType2:
    type: object
    additionalProperties: false
    properties:
      '@type':
        type: string
        enum:
          - ImplicitlyNamedType
        default: ImplicitlyNamedType
      prop2:
        type: string
    title: ImplicitlyNamedType
    required:
      - '@type'
Subtype discovery

The generator will search the class and module paths for subtypes of any polymorphic types that are annotated without a @JsonSubTypes annotation to define explicit subtypes, using ClassGraph.

For example:

@GeneratesSchema
@JsonTypeInfo(use = JsonTypeInfo.Id.NAME)
public interface Thing {}

@JsonTypeName("big")
public class BigThing implements Thing {
    public String getProp1() {
        // ...
    }
}

private class SmallThing implements Thing {
    public String getProp2() {
        // ...
    }
}

...will produce the schema:

---
$schema: http://json-schema.org/draft-07/schema#
title: Thing
oneOf:
  - $ref: '#/definitions/SmallThing'
  - $ref: '#/definitions/big'
definitions:
  SmallThing:
    type: object
    additionalProperties: false
    properties:
      '@type':
        type: string
        enum:
          - SmallThing
        default: SmallThing
      prop2:
        type: SmallThing
    title: small
    required:
      - '@type'
  BigThing:
    type: object
    additionalProperties: false
    properties:
      '@type':
        type: string
        enum:
          - big
        default: big
      prop1:
        type: string
    title: big
    required:
      - '@type'

This behavior can be customised. See the type scanning section for more information.

JsonSchema Annotations

The generator also supports the JsonSchema annotations. These allow much more control of the generated schema, including allowing for arbitrary schema elements to be injected.

For example:

@GeneratesSchema
// Inject requirement for `thing` OR `listProp`:
@JsonSchemaInject(json =  "{\"anyOf\":[" +
        "{\"required\":[\"uuid\"]}," +
        "{\"required\":[\"with_description\"]}" +
        "]}")
@JsonSchemaTitle("Custom Title")
public final class JsonSchemaModel {

    // Add a description:
    @JsonSchemaDescription("This property has a text description.")
    public String getWithDescription() {
        // ..
    }

    // Inject minLength requirement,  i.e. if supplied, it can't be empty:
    @JsonSchemaInject(ints = @JsonSchemaInt(path = "minLength", value = 1))
    public String getNonEmpty() {
        // ...
    }

    // Specify a format:
    @JsonSchemaFormat("uuid")
    public String getUuid() {
        // ...
    }
    
    // Specify in schema that items are unique and collection is not empty:
    @JsonSchemaInject(
            ints = {@JsonSchemaInt(path = "minItems", value = 1)},
            bools = {@JsonSchemaBool(path = "uniqueItems", value = true)})
    public Set<Integer> getSet() {
        // ...
    }
}

...will produce the schema:

---
$schema: http://json-schema.org/draft-07/schema#
title: Custom Title
type: object
additionalProperties: false
anyOf:
  - required:
      - uuid
  - required:
      - with_description
properties:
  nonEmpty:
    type: string
    minLength: 1
  set:
    type: array
    items:
      type: integer
    minItems: 1
    uniqueItems: true
  uuid:
    type: string
    format: uuid
  withDescription:
    type: string
    description: This property has a text description.

Non-Java types

The generator should work with any JVM based language, for example here's a Kotlin class:

@GeneratesSchema
class KotlinModel(
   @get:JsonProperty(defaultValue = PROP3_DEFAULT_VAL) val prop3: String = PROP3_DEFAULT_VAL
) {

   companion object {
      const val PROP3_DEFAULT_VAL = "another default"
   }

   @JsonProperty(required = true)
   fun getProp1(): String {return "";}

   @JsonSchemaDefault("a default value")
   var prop2: String? = null
}

...will produce the schema:

---
$schema: http://json-schema.org/draft-07/schema#
title: Kotlin Model
type: object
additionalProperties: false
properties:
   prop1:
      type: string
   prop2:
      type: string
      default: a default value
   prop3:
      type: string
      default: another default
required:
   - prop1

Type Scanning

The generator scans the class path to:

  1. find types that require a schema generated, i.e. those annotated with @GeneratesSchema.
  2. find subtypes of any polymorphic types it encounters that do not define an explicit set of subtypes, i.e. types annotated with @JsonTypeInfo, but not @JsonSubTypes.

By default, scans include the full class and module paths. Such scans can be relatively slow and can result in unwanted schema generation, e.g. generating schema files for types found in dependencies.

Type scanning can be restricted by JPMS module name and/or Java package name. Both module and package names can include the glob wildcard {@code *} character.

Type scanning, i.e. scanning for @GeneratesSchema, can be restricted using the --type-scanning-allowed-module and --type-scanning-allowed-package command line parameters. Subtype scanning can be restricted using the --subtype-scanning-allowed-module and --subtype-scanning-allowed-package command line parameters. All of these parameters can be specified multiple times on the command line to add multiple allowed module or package names.

Running under JPMS

When running under JPMS, Java Platform Modular System, it is necessary to export all packages contained @GeneratesSchema annotated types to com.fasterxml.jackson.databind. This is required to allow Jackson to work its magic and walk the object model.

Additionally, the modular system encapsulates resources, such as the generated schema files. If the schema files need to be accessible from outside the module, then the module descriptor must opens the package containing the generated schema.

By default, the generator outputs schema files under the same directory structure as the source types. (See --output-strategy). This means if the --out-directory is a resource root for the project, then schema files are generated in the same package as their source type. To expose the schema within the module, add an opens statement for the package containing the source types.

For example, given types that generate schemas in an acme.finance.model package, ensure:

module acme.model {
    // Creek annotations, e.g. @GeneratesSchema
    requires transitive creek.base.annotation;
    // Jackson annotations, e.g. @JsonProperty
    requires transitive com.fasterxml.jackson.annotation;
    // Optionally, JSON Schema annotations, e.g. @JsonSchemaInject
    requires transitive mbknor.jackson.jsonschema;

    // Export the model to Jackson:
    exports acme.finance.model to com.fasterxml.jackson.databind;
    // Or more normally, export the models to everyone:
    exports acme.finance.model;

    // Allow other modules to access the schemas generated into the same package:
    opens acme.finance.model;
}

Allowing null values

While Creek strongly recommends avoid nulls in JSON documents, just as in code, it can support nulls. These are sometimes required when integrating with a 3rd-party system, which does not itself define a schema.

The @JsonSchemaInject annotation can be used to inject arbitrary JSON into the schema, and can be used to allow null values.

For example, the following Model type has a foo property annotated to explicitly allow either string or null types:

@GeneratesSchema
class Model {
 
   @JsonSchemaInject(json = "{\"type\": [\"null\", \"string\"]}")
   public Optional<String> getFoo() {
      return s;
   }

   //...
}

The above will generate a schema with the foo property defined as:

{
   "foo": {
      "type": [
         "null",
         "string"
      ]
   }
}

Meaning, documents can have foo set to either a string or null value.