Contract-first OpenAPI generators for Avaje HTTP API.
This repository currently focuses on an Avaje-owned Maven plugin that reads an OpenAPI YAML/JSON file and generates Java source using Avaje annotations:
- API interfaces using
io.avaje.http.apiannotations - DTO records and enums
- optional Avaje Jsonb annotations
- optional Jakarta or Avaje validation annotations
- optional Avaje Record Builder support for DTO records
The generated API interfaces are then consumed by the existing Avaje annotation processors:
avaje-http-client-generatorgenerates typed HTTP clients- server generators consume the same
avaje-http-apicontract for Nima/Helidon, Avaje Jex, Javalin, and other Avaje HTTP targets avaje-jsonb-generatorgenerates JSON adapters for generated DTO recordsavaje-record-buildergenerates builders for generated DTO records when enabled
| Module | Purpose |
|---|---|
avaje-openapi-generator-core |
Reusable OpenAPI parser and Java source generator |
avaje-openapi-maven-plugin |
Maven plugin with the avaje-openapi:generate goal |
avaje-openapi-plugin-tests |
Compile-level tests for generated source |
avaje-openapi-sample |
Example project showing generated contracts, HTTP client, Nima/Helidon route generation, JSON adapters, and record builders |
The published generator modules target Java 11. The sample module overrides the compiler release to Java 21 because it compiles generated DTO records together with the Nima/Helidon sample server.
mvn clean verifyAfter a successful build, inspect generated sample files under:
avaje-openapi-sample/target/generated-sources/
Useful files:
avaje-openapi-sample/target/generated-sources/avaje-openapi/org/example/api/PetsApi.java
avaje-openapi-sample/target/generated-sources/avaje-openapi/org/example/api/model/Pet.java
avaje-openapi-sample/target/generated-sources/annotations/org/example/api/httpclient/PetsApiHttpClient.java
avaje-openapi-sample/target/generated-sources/annotations/org/example/server/PetsController$Route.java
avaje-openapi-sample/target/generated-sources/annotations/org/example/api/model/PetBuilder.java
For server generation, this plugin generates an avaje-http-api contract. The generated interface uses annotations such as @Path, @Get, @Post, @QueryParam, and @Header from io.avaje.http.api.
That same generated interface can be implemented by a controller and then processed by any compatible Avaje HTTP server target:
| Server target | Consuming processor/runtime |
|---|---|
| Avaje Nima / Helidon | avaje-http-helidon-generator |
| Avaje Jex | avaje-http-jex-generator |
| Javalin | avaje-http-javalin-generator |
Switching server targets is a consuming-project dependency and annotation-processor choice; the OpenAPI-generated API interface and DTOs remain the same.
<plugin>
<groupId>io.avaje</groupId>
<artifactId>avaje-openapi-maven-plugin</artifactId>
<version>${avaje.openapi.version}</version>
<executions>
<execution>
<goals>
<goal>generate</goal>
</goals>
<configuration>
<inputSpec>${project.basedir}/src/main/openapi/openapi.yaml</inputSpec>
<apiPackage>org.example.api</apiPackage>
<modelPackage>org.example.api.model</modelPackage>
<validationStyle>AVAJE</validationStyle>
<generateRecordBuilders>true</generateRecordBuilders>
</configuration>
</execution>
</executions>
</plugin>Generated OpenAPI contract source defaults to:
target/generated-sources/avaje-openapi
The plugin adds that directory to Maven compile source roots.
The generator itself is intended to run on Java 11+:
avaje-openapi-generator-coreavaje-openapi-maven-pluginavaje-openapi-plugin-tests
Generated DTO models currently use Java records, so projects that compile the generated model source need a Java version with record support. Use Java 17+ as the practical baseline for generated DTO projects. The avaje-openapi-sample module uses Java 21 because it demonstrates the Nima/Helidon server target.
Enable DTO builder generation with:
<generateRecordBuilders>true</generateRecordBuilders>Then add Avaje Record Builder to the consuming project:
<dependency>
<groupId>io.avaje</groupId>
<artifactId>avaje-record-builder</artifactId>
<version>${avaje.record.builder.version}</version>
<scope>provided</scope>
</dependency>And add it to annotation processor paths:
<path>
<groupId>io.avaje</groupId>
<artifactId>avaje-record-builder</artifactId>
<version>${avaje.record.builder.version}</version>
</path>Example generated DTO:
@RecordBuilder
@Json
public record Pet(
@NotNull @Min(1) Long id,
@NotNull @Size(min = 1, max = 100) String name,
Instant createdAt
) {
public static PetBuilder builder() {
return PetBuilder.builder();
}
public static PetBuilder builder(Pet from) {
return PetBuilder.builder(from);
}
}By default the plugin generates both the API interfaces and the DTO model
records. Set generateModels to false to generate only the API interfaces.
The generated interfaces still reference modelPackage types, which are expected
to be provided by an existing (hand-written) module on the classpath.
<configuration>
<inputSpec>${project.basedir}/src/main/openapi/openapi.yaml</inputSpec>
<apiPackage>org.example.api</apiPackage>
<modelPackage>org.example.model</modelPackage>
<generateModels>false</generateModels>
</configuration>This is useful for adopting contract-first on an existing API where the model records are already hand-maintained (with their own Javadoc, field types and conventions) and should remain the single source of truth — the OpenAPI spec then defines only the operations, and the DTO schemas exist purely so the generated interface signatures resolve to those existing types.
The class-level @Path on each generated interface is derived from two sources,
concatenated:
- the path component of the first
serversURL, then - the longest literal path prefix shared by every operation in that interface (i.e. the leading path segments common to all operations, stopping at the first path variable).
servers:
- url: https://api.example.com/v1 # absolute URL, or a relative "/v1"
paths:
/pets/{id}: { get: { tags: [store], ... } }
/owners/{id}: { get: { tags: [store], ... } }generates:
@Path("/v1")
public interface StoreApi {
@Get("/pets/{id}")
Pet getPet(Long id);
@Get("/owners/{id}")
Owner getOwner(Long id);
}The servers URL may be absolute (https://host/v1) or a root-relative path
(/v1); only its path component is used, a trailing / is trimmed, and a bare
/ contributes nothing. Server URLs containing template variables
(https://{host}/v1) cannot form a static prefix and are ignored with a warning.
Equivalently, you can omit servers and put the version directly in the paths
(/v1/pets/{id}, /v1/owners/{id}); the shared /v1 segment is then picked up
by the common-prefix step and produces the same @Path("/v1").
Validation annotations are enabled by default:
<generateValidationAnnotations>true</generateValidationAnnotations>The default validation style is Jakarta:
<validationStyle>JAKARTA</validationStyle>This emits imports such as:
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Size;To use Avaje Validator constraint annotations instead:
<validationStyle>AVAJE</validationStyle>This emits imports such as:
import io.avaje.validation.constraints.NotNull;
import io.avaje.validation.constraints.Size;Add Avaje Validator constraints to the consuming project:
<dependency>
<groupId>io.avaje</groupId>
<artifactId>avaje-validator-constraints</artifactId>
<version>${avaje.validator.version}</version>
</dependency>Schema keywords map to constraint annotations on the generated record components:
| Schema keyword | Annotation |
|---|---|
required |
@NotNull |
minLength / maxLength |
@Size(min, max) |
minItems / maxItems (arrays) |
@Size(min, max) |
minimum / maximum (whole, inclusive) |
@Min / @Max |
minimum / maximum (decimal) |
@DecimalMin / @DecimalMax |
exclusiveMinimum / exclusiveMaximum |
@DecimalMin / @DecimalMax with inclusive = false |
pattern |
@Pattern(regexp = ...) |
format: email |
@Email |
| object / array-of / map-of a generated model | @Valid |
Both OpenAPI 3.0 (exclusiveMinimum: true) and 3.1 (exclusiveMinimum: <number>)
exclusive-bound forms are honoured. multipleOf has no Bean Validation equivalent
and is not mapped.
Record components whose type is a generated model — directly, or as the element of
a List/Map — are annotated @Valid so Bean Validation cascades into them:
public record Order(
@NotNull @Valid Customer customer,
@Valid List<Item> items,
@Valid Map<String, Item> attachments,
List<String> labels,
OrderStatus status
) {}References to enums and scalar types are not cascaded. Jakarta places @Valid in
the root jakarta.validation package; the Avaje style uses
io.avaje.validation.constraints.Valid.
Schema properties marked readOnly: true or writeOnly: true get a JSON
annotation so the serialisation library enforces the constraint:
| OpenAPI | Avaje Jsonb (jsonStyle: AVAJE, default) |
Jackson (jsonStyle: JACKSON) |
|---|---|---|
readOnly: true |
@Json.Ignore(deserialize = true) |
@JsonProperty(access = JsonProperty.Access.READ_ONLY) |
writeOnly: true |
@Json.Ignore(serialize = true) |
@JsonProperty(access = JsonProperty.Access.WRITE_ONLY) |
The default style targets Avaje Jsonb:
@Json
public record UserProfile(
@Json.Ignore(deserialize = true) Long id, // readOnly — in responses only
@NotNull String username,
@Json.Ignore(serialize = true) String password, // writeOnly — in requests only
@Nullable String email
) {}To target Jackson instead, set jsonStyle in the plugin configuration:
<jsonStyle>JACKSON</jsonStyle>public record UserProfile(
@JsonProperty(access = JsonProperty.Access.READ_ONLY) Long id,
@NotNull String username,
@JsonProperty(access = JsonProperty.Access.WRITE_ONLY) String password,
@Nullable String email
) {}These annotations are only emitted when generateJsonAnnotations is true
(the default).
OpenAPI response definitions can declare headers that the server will return.
Since avaje-http has no response-header annotation, these are surfaced as
@apiNote Javadoc tags on the generated method so callers know what to
expect:
/**
* List items with rate-limit headers.
*
* @apiNote Response headers: X-Rate-Limit (integer — Request limit per hour), X-Rate-Limit-Remaining (integer — Remaining requests in window), X-Rate-Limit-Reset (string)
*/
@Get
List<Item> listItems();Each header entry is formatted as Name (type) or Name (type — description)
when a description is present. Headers are only emitted for the 2xx response.
Operations with no declared response headers produce no @apiNote.
(required: false, without a default) and model fields
declared nullable: true are annotated with @Nullable. The default annotation
is JSpecify:
<nullableAnnotation>org.jspecify.annotations.Nullable</nullableAnnotation>import org.jspecify.annotations.Nullable;
List<Pet> listPets(@Nullable @QueryParam("status") PetStatus status);JSpecify is already a transitive dependency of avaje-http-client. If you only
depend on avaje-http-api, add it to the consuming project:
<dependency>
<groupId>org.jspecify</groupId>
<artifactId>jspecify</artifactId>
<version>${jspecify.version}</version>
</dependency>Point it at a different annotation (for example jakarta.annotation.Nullable), or
set it to NONE to disable @Nullable generation entirely:
<nullableAnnotation>NONE</nullableAnnotation>The NONE sentinel (case-insensitive) is used rather than an empty element because
build tools such as Maven collapse an empty configuration element to null and then
apply the parameter default, so a blank value cannot disable generation.
Disabling @Nullable is also a way to keep contract-first server controllers working
on avaje-http server generators older than 3.10: the JSpecify @Nullable is a
TYPE_USE annotation that those generators fail to match between the interface
method and the controller @Override, silently dropping the route. With @Nullable
disabled the signatures match. See the contract-first guide for details.
A field that is both required and nullable: true is annotated @Nullable
(not @NotNull); with @Nullable disabled it falls back to @NotNull.
When a parameter schema declares a default, the generator emits an
@Default annotation alongside the location annotation, and uses the
primitive form of the type (since a default guarantees a value):
parameters:
- name: useMaster
in: query
schema:
type: boolean
default: falsegenerates:
Pet getPet(Long id, @QueryParam("useMaster") @Default("false") boolean useMaster);Wrapper types Boolean, Integer, Long, Double and Float are unboxed to
their primitive form when a default is present. Other types keep their declared
type and simply gain the @Default("...") annotation.
Each @QueryParam / @Header / @Cookie is always generated with an explicit
wire-name value:
List<Pet> listPets(@QueryParam("status") PetStatus status);
Pet getPet(Long id, @Header("X-Request-Id") String xRequestId);The explicit name keeps the generated interface robust as a contract-first
artifact. avaje-http can fall back to the Java parameter name when the annotation
value is blank, but that fallback only works when parameter names are present in
the bytecode — which is not the case for an interface consumed from a
precompiled jar via @Client.Import unless that jar was compiled with
-parameters. Emitting the name explicitly removes that requirement so consumers
need no special compiler configuration.
Set generateOverloads to true to emit convenience default method overloads
that omit a trailing run of omittable parameters and delegate to the full
method. The overloads carry no HTTP annotation, so the Avaje HTTP server and
client generators ignore them — they exist purely for caller ergonomics and are
inherited by both the controller and the generated HTTP client.
<generateOverloads>true</generateOverloads>
<overloadPolicy>NULLABLE_ONLY</overloadPolicy>
<!-- EXPLICIT | NULLABLE_ONLY (default) | ALL_OPTIONAL -->Only a contiguous run of omittable parameters at the end of the signature can
be dropped (Java overloads can only omit trailing arguments). Path parameters and
request bodies are never omittable. The overloadPolicy decides which parameters
are omittable by default:
| Policy | Omittable parameters | Value passed when omitted |
|---|---|---|
EXPLICIT |
only those marked x-overload: true |
default, or null |
NULLABLE_ONLY |
optional parameters without a default (default) |
null |
ALL_OPTIONAL |
every optional parameter (including defaulted ones) | its default literal |
A per-parameter x-overload vendor extension overrides the policy for that
parameter (true forces omittable, false forces required):
parameters:
- name: modifiedSince # optional, no default -> dropped under NULLABLE_ONLY
in: query
schema:
type: string
format: date-time
- name: withMachines # defaulted, but opted in -> dropped, passing its default
in: query
x-overload: true
schema:
type: boolean
default: falseFor an endpoint findFleet(fleetGid, useMaster, withMachines, withDrivers) where
withMachines/withDrivers are marked x-overload: true and useMaster keeps
its default, the generator emits one overload per trailing suffix length:
FleetDetail findFleet(UUID fleetGid, @QueryParam("useMaster") @Default("false") boolean useMaster,
@QueryParam("withMachines") @Default("false") boolean withMachines,
@QueryParam("withDrivers") @Default("false") boolean withDrivers);
default FleetDetail findFleet(UUID fleetGid, boolean useMaster, boolean withMachines) {
return findFleet(fleetGid, useMaster, withMachines, false);
}
default FleetDetail findFleet(UUID fleetGid, boolean useMaster) {
return findFleet(fleetGid, useMaster, false, false);
}OpenAPI's format: date-time (RFC 3339) carries a timezone offset, so it maps to
java.time.OffsetDateTime by default. Set the global dateTimeType to change the
type used for all format: date-time properties:
<dateTimeType>INSTANT</dateTimeType>
<!-- INSTANT | OFFSET_DATE_TIME (default) | LOCAL_DATE_TIME | ZONED_DATE_TIME -->format: date always maps to java.time.LocalDate.
Two mechanisms override the global default for an individual property. Precedence, highest first:
-
x-java-typevendor extension — keeps the spec standard and takes any fully qualified class name:externalLastModified: type: string format: date-time x-java-type: java.time.OffsetDateTime
-
Extended
formatvalues — concise shorthand for the commonjava.timetypes:format:valueJava type instantjava.time.Instantoffset-date-timejava.time.OffsetDateTimelocal-date-timejava.time.LocalDateTimezoned-date-timejava.time.ZonedDateTime -
Global
dateTimeType— applied to plainformat: date-time.
typeMappings globally overrides the Java type generated for a schema format or
type, without editing each schema. Keys are a schema format (e.g. uuid,
date-time, binary) or a bare type (e.g. string); values are fully-qualified
Java type names:
<typeMappings>
<uuid>com.example.MyUuid</uuid>
<date-time>java.time.Instant</date-time>
</typeMappings>Precedence, highest first:
- per-property
x-java-typevendor extension typeMappingsentry keyed byformattypeMappingsentry keyed bytype- the built-in default type
So given the mappings above, { type: string, format: uuid } becomes
com.example.MyUuid, and a plain { type: string } keeps String unless a
string key is also configured. The import is derived from the fully-qualified
value (java.lang and unqualified names are emitted without an import).
Supported:
- OpenAPI 3 YAML/JSON
- REST paths and common HTTP methods (with interface
@Pathfromserversbase path + shared path prefix) - JSON request/response bodies
- path/query/header/cookie parameters (with
@Defaultfor parameter defaults; location annotations always carry an explicit wire-name value) - component object schemas, enums, arrays, maps, date/time/UUID formats
- validation constraints (
@NotNull,@Size,@Min/@Max,@DecimalMin/@DecimalMax,@Pattern,@Email,@Validcascade; Jakarta or Avaje style) readOnly/writeOnlyfields annotated for Avaje Jsonb (@Json.Ignore) or Jackson (@JsonProperty) viajsonStyleconfig- response headers documented as
@apiNoteJavadoc on generated methods allOfcomposition (members are flattened/merged into a single record)- inline object/array/map schemas (extracted into named nested records)
description/summaryrendered as Javadoc anddeprecatedas@Deprecated(schemas, enums, operations, fields, parameters)@Nullableon optional parameters andnullable: truefields (configurablenullableAnnotation)- configurable
date-timeJava type (globaldateTimeType, extended formats,x-java-type) - global
typeMappings(override the Java type for a schemaformatortype) - convenience
defaultmethod overloads (generateOverloads,overloadPolicy,x-overload)
Unsupported features currently produce diagnostics:
oneOf/anyOf/ discriminator polymorphism- multipart upload
- callbacks, links, webhooks
- multiple request body content types per operation