๐Ÿ˜ธ kt-schema

Java Annotation Processor

Table of contents

Generate JSON Schema resources at compile time from plain Java records, classes, interfaces and enums using the kt-schema-apt JSR 269 (javax.annotation.processing) processor โ€” no Kotlin required in your own code.

Setup

Maven

xml

<plugin>
    <groupId>org.apache.maven.plugins</groupId>
    <artifactId>maven-compiler-plugin</artifactId>
    <configuration>
        <annotationProcessorPaths>
            <path>
                <groupId>me.kpavlov.kt.schema</groupId>
                <artifactId>kt-schema-apt</artifactId>
                <version>${kt-schema.version}</version>
            </path>
        </annotationProcessorPaths>
        <compilerArgs>
            <arg>-Ame.kpavlov.kt.schema.rootPackage=com.example</arg>
            <arg>-Ame.kpavlov.kt.schema.include=com.example.**</arg>
        </compilerArgs>
    </configuration>
</plugin>

    <!-- Only needed if you annotate types with @Schema/@Description directly -->
<dependencies>
<dependency>
    <groupId>me.kpavlov.kt.schema</groupId>
    <artifactId>kt-schema-annotations</artifactId>
    <version>${kt-schema.version}</version>
</dependency>
</dependencies>

Gradle (Java projects)

kotlin
plugins {
    java
}

dependencies {
    annotationProcessor("me.kpavlov.kt.schema:kt-schema-apt:<version>")
    // Only needed if you annotate types with @Schema/@Description directly:
    implementation("me.kpavlov.kt.schema:kt-schema-annotations:<version>")
}

Triggering schema generation

The processor discovers types as follows:

  1. Scope โ€” when the rootPackage option is set, only types declared under that package (and its sub-packages) are considered; otherwise the whole module is scanned. This applies to @Schema-annotated types and include-glob matches alike.
  2. Selection โ€” a record, class or interface is processed when it is annotated with @Schema (from kt-schema-annotations) or matches at least one include glob pattern.
  3. Exclusion โ€” a type matching any exclude glob pattern is dropped, even when selected by @Schema or an include pattern.
java
import me.kpavlov.kt.schema.Description;
import me.kpavlov.kt.schema.Schema;

@Description("A person with a first and last name and age.")
@Schema
public record Person(
    @Description("Given name of the person") String firstName,
    @Description("Family name of the person") String lastName,
    @Description("Age of the person in years") int age) {
}

With rootPackage + include configured, you can skip kt-schema-annotations entirely and reuse annotations you already depend on โ€” for example Jackson’s:

java
import com.fasterxml.jackson.annotation.JsonClassDescription;
import com.fasterxml.jackson.annotation.JsonPropertyDescription;

@JsonClassDescription("A person with a first and last name and age.")
public record Person(
    @JsonPropertyDescription("Given name of the person") String firstName,
    @JsonPropertyDescription("Family name of the person") String lastName,
    @JsonPropertyDescription("Age of the person in years") int age) {
}

kt-schema-apt recognizes @JsonPropertyDescription/@JsonClassDescription out of the box โ€” see Multi-Framework Annotation Support for the full list.

Configuration options

OptionTypeDefaultDescription
rootPackageStringnullScope discovery to types declared under this package (and sub-packages); absent means the whole module is scanned.
includeStringnullComma/semicolon-separated glob patterns; a type not annotated with @Schema is processed only when it matches at least one.
excludeStringnullComma/semicolon-separated glob patterns; a type matching any of them is dropped, even when @Schema-annotated or include-matched.
configStringstrictSchema flavour: strict, lenient, openapi or a JsonSchemaConfig class name. See Schema configuration .

Set them as javac -A compiler arguments:

kotlin
tasks.withType<JavaCompile>().configureEach {
    options.compilerArgs.addAll(
        listOf(
            "-Ame.kpavlov.kt.schema.rootPackage=com.example",
            "-Ame.kpavlov.kt.schema.include=com.example.**",
        ),
    )
}

Glob syntax: * matches any sequence of non-. characters, ** matches any sequence including ., ? matches a single non-. character โ€” the same syntax the KSP processor uses.

Generated output

Unlike the KSP processor โ€” which generates KClass<T>.jsonSchemaString/.jsonSchema Kotlin extensions โ€” kt-schema-apt has no Kotlin code to attach extension properties to. Instead, it writes one JSON Schema resource per processed type:

text
META-INF/kt-schema/schemas/<package-as-directories>/<ClassName>.json

For the Person record above, that’s META-INF/kt-schema/schemas/com/example/Person.json:

json
{
    "$schema": "https://json-schema.org/draft/2020-12/schema",
    "$id": "com.example.Person",
    "description": "A person with a first and last name and age.",
    "type": "object",
    "properties": {
        "firstName": {
            "type": "string",
            "description": "Given name of the person"
        },
        "lastName": {
            "type": "string",
            "description": "Family name of the person"
        },
        "age": {
            "type": "integer",
            "description": "Age of the person in years"
        }
    },
    "additionalProperties": false,
    "required": [
        "firstName",
        "lastName",
        "age"
    ]
}

This is the same Draft 2020-12 JSON Schema model the KSP processor produces โ€” both reuse the same TypeGraphToJsonSchemaTransformer, so $id/$defs/$ref/nullability handling is identical.

Extended example

Beyond plain scalars and nested records, the processor handles collections, maps, arrays, Object and type variables. Consider an Order record referencing an Address:

java
import me.kpavlov.kt.schema.Description;
import me.kpavlov.kt.schema.Schema;

@Schema
@Description("A customer order with tags, quantities, price points and billing address.")
public record Order(
    @Description("Unique order identifier") String id,
    @Description("Tags attached to the order") java.util.Set<String> tags,
    @Description("Line item quantities by SKU") java.util.Map<String, Integer> quantities,
    @Description("Historical price points") double[] prices,
    @Description("Free-form metadata") Object metadata,
    @Description("Billing address") Address address) {
}
java

@Schema
public record Address(
    @Description("City part of the address") String city,
    @Description("Street part of the address") String street) {
}

This generates META-INF/kt-schema/schemas/com/example/Order.json:

json
{
    "$schema": "https://json-schema.org/draft/2020-12/schema",
    "$id": "com.example.Order",
    "description": "A customer order with tags, quantities, price points and billing address.",
    "type": "object",
    "properties": {
        "id": {
            "type": "string",
            "description": "Unique order identifier"
        },
        "tags": {
            "type": "array",
            "description": "Tags attached to the order",
            "items": {
                "type": "string"
            }
        },
        "quantities": {
            "type": "object",
            "description": "Line item quantities by SKU",
            "additionalProperties": {
                "type": "integer"
            }
        },
        "prices": {
            "type": "array",
            "description": "Historical price points",
            "items": {
                "type": "number"
            }
        },
        "metadata": {
            "description": "Free-form metadata"
        },
        "address": {
            "$ref": "#/$defs/com.example.Address",
            "description": "Billing address"
        }
    },
    "additionalProperties": false,
    "required": [
        "id",
        "tags",
        "quantities",
        "prices",
        "metadata",
        "address"
    ],
    "$defs": {
        "com.example.Address": {
            "type": "object",
            "properties": {
                "city": {
                    "type": "string",
                    "description": "City part of the address"
                },
                "street": {
                    "type": "string",
                    "description": "Street part of the address"
                }
            },
            "additionalProperties": false,
            "required": [
                "city",
                "street"
            ]
        }
    }
}

Collections (List, Set, Collection, and subclasses thereof) map to array with items, Map maps to object with additionalProperties, arrays nest items per dimension, Object emits an empty schema {} (accepts any value), and nested records become $ref/$defs entries. Address is also emitted as its own root resource because it is @Schema-annotated.

Reading the resource at runtime

Read it like any other classpath resource:

java
try(InputStream in = Person.class.getClassLoader()
    .getResourceAsStream("META-INF/kt-schema/schemas/com/example/Person.json")){
String schema = new String(in.readAllBytes(), StandardCharsets.UTF_8);
}

Supported types

Not yet supported: sealed class hierarchies (polymorphic oneOf with discriminators). Processing an unsupported type fails the build with a descriptive error rather than emitting an incomplete schema.

Jackson enum names

When the application already uses Jackson, kt-schema-apt uses @JsonProperty on enum constants as their JSON Schema values and @JsonTypeName on the enum as its schema $id. The generated resource path still uses the declared Java type name.

java
import com.fasterxml.jackson.annotation.JsonProperty;
import com.fasterxml.jackson.annotation.JsonTypeName;

@JsonTypeName("PriorityLevel")
public enum Priority {
    @JsonProperty("low") LOW,
    @JsonProperty("medium") MEDIUM,
    @JsonProperty("high") HIGH
}

This produces a schema whose $id is PriorityLevel and whose enum is ["low", "medium", "high"], at META-INF/kt-schema/schemas/com/example/Priority.json for a com.example.Priority enum.

Default values

Java has no default-parameter expressions to evaluate, but two Jackson annotations are recognized as an explicit, compile-time-visible substitute:

java
import com.fasterxml.jackson.annotation.JsonEnumDefaultValue;
import com.fasterxml.jackson.annotation.JsonProperty;

public enum Priority {
    LOW,
    @JsonEnumDefaultValue MEDIUM,
    HIGH
}

public record Job(Priority priority, @JsonProperty(defaultValue = "30") int timeoutSeconds) {
}

@JsonEnumDefaultValue, placed on one enum constant, marks it as that enum’s default โ€” always shown on the enum’s own schema in $defs, since it describes the type, not any one property using it. @JsonProperty(defaultValue = "...") populates the property’s default internally, but โ€” like every property here โ€” kt-schema-apt’s generated resource always marks it required and never shows the default keyword for it, since Java has no reliable way to know whether a value truly behaves as optional. Both are configurable via kt-schema.properties (introspector.annotations.enumDefault.names, introspector.annotations.defaultValue.names, introspector.annotations.defaultValue.attributes) โ€” see Multi-Framework Annotation Support .

Marking a property nullable/optional

Java has no ? type syntax and no default-value expressions, so every property is non-nullable/required by default. Two independent conventions cover the two concerns โ€” nullable and optional are not the same thing and don’t imply each other:

Both conventions are configurable via kt-schema.properties (introspector.nullable.type.names, introspector.optional.type.names, introspector.annotations.nullable.names, introspector.annotations.optional.names) โ€” every one of these accepts a comma-separated list of glob patterns (*/?), not just exact names.

For example, use JSpecify to accept null while keeping the property required:

java
import org.jspecify.annotations.Nullable;

public record Person(String name, @Nullable String middleName) {
}

middleName is emitted with "type": ["string", "null"] and remains in required.

See Also