Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Schemas and Types

Schemas describe the values an API accepts and returns. This chapter explains how the generator names fields, chooses collection types, applies defaults, and validates constraints. For enum variants and unions, see Enums and Unions .

The examples use fixtures/petstore.json unless stated otherwise. Rust excerpts may omit imports, attributes, and unrelated fields. Commands run from the repository checkout; replace cargo run -- with oas3-gen when using an installed executable, and substitute your document’s path for spec.json .

Generated Names

Schema and property names become Rust identifiers. When a name isn’t a valid identifier, or would collide with something Rust or the generated code already uses, the generator changes it as shown in the following table:

Name in the specificationGenerated nameReason
A property named after a keyword, such as type or matchr#typeKeywords need the raw identifier prefix
A property named crate, self, or supercrate_, self_, super_These keywords can’t be raw identifiers
A property that starts with a digit, such as 2fa_2faIdentifiers can’t start with a digit
A schema named after a prelude item, such as Option or VecOptionTypeThe name would shadow the prelude
A schema named SelfSelfTypeSelf can’t be a raw identifier
A schema named SSTypeGenerated server handlers use S for your service type
A schema that starts with a digit, such as 123ResponseT123ResponseIdentifiers can’t start with a digit

A renamed property keeps its original name on the wire through #[serde(rename)] , so the JSON stays the same. Enum variants follow the same rules as schema names. For example, the enum value self becomes the variant SelfType .

Ordering and Collections

By default, the generator preserves declaration order from the OpenAPI document for schemas, fields, variants, operations, and header constants. You can change enum ordering separately with --enum-layout .

The generator also uses collection types that preserve insertion order at runtime. Maps described by additionalProperties use indexmap::IndexMap<String, T> , and arrays with uniqueItems: true use indexmap::IndexSet<T> . For example, the tags of a petstore Pet are uniqueItems , so the field is Option<indexmap::IndexSet<String>> , and its vaccinations map is Option<indexmap::IndexMap<String, Vec<VaccineRecord>>> . If you include these types in an existing crate, add indexmap with its serde feature to your Cargo.toml . A generated workspace manifest includes that dependency when needed.

A uniqueItems field also carries a serde_with::SetPreventDuplicates adapter, so a JSON array with a repeated item fails to deserialize:

#[serde_as(as = "Option<serde_with::SetPreventDuplicates<_>>")]
pub tags: Option<indexmap::IndexSet<String>>,

Use this flag to choose standard library collections instead:

--no-ordered-collections

Set --no-ordered-collections to opt out of the indexmap runtime types. Map schemas then resolve to std::collections::HashMap<String, T> , and uniqueItems arrays resolve to Vec<T> , the same type as a normal array, and accept repeated items. Use this flag when your code cannot depend on indexmap , or when you do not need JSON key and element order to survive a deserialize-then-serialize round trip.

This flag does not change the declaration order of items in the generated source, such as struct fields, enum variants, and operation methods.

Field Defaults

The generator uses a property’s default for construction and for missing JSON fields. A #[default(...)] attribute sets the field’s value in the type’s Default implementation, and a struct-level #[serde(default)] tells Serde to take every missing field from that implementation during deserialization.

Let’s look at the petstore’s Order schema, which requires petId and gives quantity and complete defaults. Here it is without its shipDate and status properties:

"Order": {
  "type": "object",
  "required": ["petId"],
  "properties": {
    "id": { "type": "integer", "format": "int64", "readOnly": true },
    "petId": { "type": "string", "format": "uuid" },
    "quantity": { "type": "integer", "format": "int32", "minimum": 1, "maximum": 10, "default": 1 },
    "complete": { "type": "boolean", "default": false }
  }
}

The generated struct carries the defaults on its fields and a single Serde attribute on the struct:

#[serde(default)]
pub struct Order {
    pub id: Option<i64>,
    #[serde(rename = "petId")]
    pub pet_id: uuid::Uuid,
    #[validate(range(min = 1i32, max = 10i32))]
    #[default(Some(1i32))]
    pub quantity: Option<i32>,
    /* ... */
    #[default(Some(false))]
    pub complete: Option<bool>,
}

Deserializing {"petId": "..."} gives quantity the value Some(1) and complete the value Some(false) , the same values Order::default() holds.

The attribute applies to the whole struct, so it fills required fields too. JSON without petId deserializes with the nil UUID from Order::default() instead of failing. A struct only gets #[serde(default)] when at least one of its fields has a default or a const , so required fields in other structs are still rejected when they’re missing.

Client output also reads null in these structs’ required fields as a default. See Missing and Null Fields .

Union Defaults

When an untagged union can represent the schema’s default , the generator writes an impl Default that constructs it. It checks variants in the same order as deserialization. For example, ContentRequest.image_size defaults to auto_2K , selecting Self::Preset(ImageSizePreset::Auto2k) . A string default can produce Self::String("cheesecake".to_string()) .

Without a usable schema default, a union that derives Default selects the first variant whose construction won’t recurse back into the union. Eligible variants include constants, unit variants, arrays, nullable types, and payloads that don’t refer back to the union.

The generator chooses this fallback in specification order, or sorted order with --enum-layout sorted . Moving array or string variants for deserialization doesn’t change it. Nullable unions record the fallback too, in case a containing struct’s derived Default needs the union to implement Default .

A nullable union includes a null branch. Without a usable schema default, it starts without a Default implementation. Properties with that union type use Option<T> , including properties listed as required. If another generated type needs the union to implement Default , the generator can derive it using the recorded fallback variant.

Defaults Beside References

A property can also set a default next to its $ref , as OpenAPI 3.1 allows. When the reference names an enum, a const , or a union, the field’s default uses that value instead of the referenced schema’s own default . The petstore’s ChatPreference schema does this:

"fallback": { "$ref": "#/components/schemas/ChatModel", "default": "chat-1.1" }

This property becomes #[default(Some(ChatModel::Chat11))] pub fallback: Option<ChatModel> , along with the deserialization default described in Field Defaults . A union’s default selects its matching variant, so "default": "slow" on a oneOf of const values becomes #[default(Some(Mode::Slow))] . The generator ignores a default next to any other reference, because it can’t build that value for the referenced type. A default that matches no variant of an enum or union, such as an object default on a referenced or inline oneOf , gets no #[default(...)] attribute, so the field keeps its plain Default instead of a placeholder that picks the first variant.

Constant Properties

A property with const , or with a single-value enum , uses that value as its default. Deserialization supplies it when the property is absent. The optional object property of the petstore’s ChatPreference becomes:

#[default(Some("chat.preference".to_string()))]
pub object: Option<String>,

The client keeps a different value it receives rather than replacing it, as Constant Properties describes.

Field Validation

Schemas often limit the values they accept: a string’s length, a number’s range, or an array’s size. The generator turns the limits it supports into validator attributes on the generated fields, and validate() checks a value against them. The following table shows which attribute each schema keyword produces:

Schema keywordValidation attribute
minLength, maxLength on a stringlength(min = ..., max = ...)
minItems, maxItems on an arraylength(min = ..., max = ...)
minimum, maximum, exclusiveMinimum, exclusiveMaximumrange(...)
patternregex(path = ...), naming a generated static that holds the compiled expression
format: emailemail
format: uri or format: urlurl
multipleOfcustom(function = ...), calling a check in oas3-gen-support
A property whose own type has validationnested

A required string without minLength also gets length(min = 1) , so an empty string fails validation. Strings with a date , date-time , time , uuid , byte , or binary format don’t get length or pattern checks.

The petstore’s CollarTelemetry schema uses several of these keywords. Here’s an excerpt of the generated struct:

pub struct CollarTelemetry {
    #[serde(rename = "collarId")]
    #[validate(length(min = 1u64), regex(path = "REGEX_COLLAR_TELEMETRY_COLLAR_ID"))]
    pub collar_id: String,
    /* ... */
    /// Latitude and longitude
    #[validate(length(min = 2u64, max = 2u64))]
    pub location: Vec<f64>,
    /* ... */
    /// Validation: Must be a multiple of 5
    #[serde(rename = "batteryPercent")]
    #[validate(
        range(min = 0i32, max = 100i32),
        custom(function = oas3_gen_support::multiple_of::<5>)
    )]
    pub battery_percent: Option<i32>,
    /* ... */
    #[serde(rename = "ownerEmail")]
    #[validate(email)]
    pub owner_email: Option<String>,
    /* ... */
}

The collarId property is required and has a pattern , so collar_id gets both a minimum length and a regular expression check. batteryPercent declares minimum: 0 , maximum: 100 , and multipleOf: 5 . The same rules apply to a request body that isn’t an object. For example, the text/plain body of setPetNotes declares maxLength: 2000 , so the body field of SetPetNotesRequest carries length(max = 2_000u64) .

Multiples

A multipleOf check calls a function from oas3-gen-support . An integer field calls multiple_of::<N> , which accepts a value that divides evenly by N . With multipleOf: 5 , batteryPercent accepts 85 and rejects 87 . A number field calls multiple_of_ratio::<NUMERATOR, DENOMINATOR> , with the multiple written as a fraction: multipleOf: 0.25 becomes multiple_of_ratio::<1, 4> . That check applies only to f32 and f64 fields. It accepts a value when the nearest multiple of the fraction, rounded to the field’s type, is the value itself. It also accepts a value whose quotient by the fraction is within 1e-9 of a whole number, which absorbs rounding that accumulates in arithmetic. So 0.3 counts as a multiple of 0.1 in either type, as do 0.1 + 0.2 and a sum of one hundred 0.1 values, while 0.35 doesn’t. Where the field’s type is coarser than the multiple, every value rounds to a multiple, so an f32 field with multipleOf: 0.1 accepts 1_100_000.125 . Either check reports a failure as a validation error with the code multiple_of .

Every multipleOf adds a line such as Validation: Must be a multiple of 5 to the field’s documentation. These multiples get only that line:

  • A fractional multiple on an integer field, such as multipleOf: 0.5.
  • A multiple that can’t be written as a fraction whose numerator and denominator fit in a u32, such as 1e-20.

When Validation Runs

Generated types derive Validate only when the API accepts them, such as request structs and request bodies. A schema used only in responses, such as Pet, keeps no validation attributes. Request builders validate when you call build(), as the Builder Pattern chapter explains. Making a Request and Implementing the Service Trait cover when the client and server check a request.

OData Support

An OData API may omit metadata fields that its schema lists as required. If you encounter this with fields such as @odata.type , use --odata-support to make eligible fields optional in the generated types.

--odata-support

This rule applies to names starting with @odata. when the parent schema has no discriminator and isn’t an intersection using allOf .

Input Schema

Let’s use a schema that requires an ID and two metadata fields:

{
  "type": "object",
  "properties": {
    "id": { "type": "string" },
    "@odata.type": { "type": "string" },
    "@odata.id": { "type": "string" }
  },
  "required": ["id", "@odata.type", "@odata.id"]
}

Default (OData Support Disabled)

Without the flag, all three fields are required strings:

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Entity {
    pub id: String,
    #[serde(rename = "@odata.type")]
    pub odata_type: String,
    #[serde(rename = "@odata.id")]
    pub odata_id: String,
}

Deserialization fails if @odata.type or @odata.id are missing from the response.

With --odata-support

With the flag, the metadata fields become optional:

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Entity {
    pub id: String,
    #[serde(rename = "@odata.type")]
    pub odata_type: Option<String>,
    #[serde(rename = "@odata.id")]
    pub odata_id: Option<String>,
}

Now deserialization succeeds when either metadata field is absent, and the corresponding Rust field is None . The id field is still required because its name doesn’t start with @odata. .

Type Customization

Use --customize to choose a serde_with adapter for a generated field. An adapter controls how Serde reads or writes a value. The generated field keeps its Rust type, and the adapter appears in its #[serde_as] attribute.

-c, --customize <TYPE=PATH>

The following keys select commonly customized formats:

KeyOpenAPI FormatRust Field Type
date_timedate-timechrono::DateTime<chrono::Utc>
datedatechrono::NaiveDate
timetimechrono::NaiveTime
durationdurationstd::time::Duration
uuiduuiduuid::Uuid

The date-time type uses chrono::Utc as its time zone. For these examples, suppose your crate defines adapters named DateTimeAdapter and DateAdapter . Each adapter must implement serde_with::SerializeAs or serde_with::DeserializeAs as needed by the generated field. To use both adapters, repeat the flag:

cargo run -- generate types -i petstore.json -o types.rs \
  -c date_time=crate::DateTimeAdapter \
  -c date=crate::DateAdapter

Default (No Customization)

The petstore’s CollarTelemetry schema has a required recordedAt date-time and an optional recordedOn date. Without an override, the fields use their normal Serde implementations:

pub struct CollarTelemetry {
    /* ... */
    #[serde(rename = "recordedAt")]
    pub recorded_at: chrono::DateTime<chrono::Utc>,
    #[serde(rename = "recordedOn")]
    pub recorded_on: Option<chrono::NaiveDate>,
    /* ... */
}

With a Date-Time Adapter

If we pass only -c date_time=crate::DateTimeAdapter , the generator adds an adapter to recorded_at :

pub struct CollarTelemetry {
    /* ... */
    #[serde_as(as = "crate::DateTimeAdapter")]
    #[serde(rename = "recordedAt")]
    pub recorded_at: chrono::DateTime<chrono::Utc>,
    #[serde(rename = "recordedOn")]
    pub recorded_on: Option<chrono::NaiveDate>,
    /* ... */
}

Notice that recorded_at is still a chrono::DateTime<chrono::Utc> . The DateTimeAdapter controls its conversion during serialization and deserialization. The recorded_on field is unchanged because we haven’t supplied a date override in this example. The generator also adds #[serde_with::serde_as] to any struct that uses an adapter.

Handling Optional and Array Fields

The generator wraps the adapter in Option and Vec to match optional and array fields. For example, the same date-time override gives the optional homeVisit property of AdoptionRequest this attribute:

pub struct AdoptionRequest {
    /* ... */
    #[serde_as(as = "Option<crate::DateTimeAdapter>")]
    #[serde(rename = "homeVisit")]
    pub home_visit: Option<chrono::DateTime<chrono::Utc>>,
}

An array of date-times would get Vec<crate::DateTimeAdapter> , and an optional array Option<Vec<crate::DateTimeAdapter>> . Each attribute follows the shape of its field. You supply the adapter for one date-time value; the generator adds the surrounding container adapters.