Avro schemas describe the shape of data, not whether it makes sense. A string
field will hold a broken URL; a latitude of 512 degrees is still a valid
double. We wanted Rust types that refuse such data at the boundary, generated
from our Avro IDL
schemas. The generator we evaluated needed Apache Avro's Java implementation to
read IDL, lacked namespaces, could not validate values, and is unmaintained. So
we created one.
We have open-sourced vanavro under
your choice of the Apache 2.0 or MIT licence. It turns
.avsc and
.avdl schemas into
idiomatic Rust types, supporting records, enums, unions, recursive types,
namespaces, defaults, and aliases. We test every feature against Avro's reference
Java implementation: it decodes and re-encodes data written by the generated
Rust types, and Rust checks the result.
Where Avro's (logical) types stop
Avro's standard logical
types,
such as uuid and date, add meaning to an underlying type, and the
specification leaves room for custom ones. The Java implementation has supported
those for years through logical type
factories
and conversions.
vanavro brings the same idea to Rust, with
serde doing the conversion instead of
runtime-registered classes. You annotate the schema as you would for a standard
logical type:
@logicalType("wgs84")
record Wgs84Coordinate { double latitude; double longitude; double altitude; }
record UserProfile {
@logicalType("uuid") string id; // standard
@logicalType("url") string website; // custom
Wgs84Coordinate location; // custom, on the record
}
Then you tell vanavro which Rust type each annotation stands for:
VanAvro::builder()
.idl_path("schemas/example.avdl")
.with_logical_type(
// Url (de)serializes as a plain string, so no converter is needed
CustomLogicalType::builder("url", AvroType::String, "::url::Url").build(),
)
.with_logical_type(
CustomLogicalType::builder("wgs84", AvroType::Record, "crate::geo::Wgs84")
.converter("crate::geo::Wgs84AsRecord")
.build(),
)
.generate_to_path(out_dir.join("example.rs"))?;
The generated code uses your types (attributes trimmed):
::
pub struct UserProfile {
pub id: ::uuid::Uuid,
pub website: ::url::Url,
as = "crate::geo::Wgs84AsRecord"
pub location: crate::geo::Wgs84,
}
A UserProfile cannot hold a malformed URL, because Url cannot: its
deserialiser rejects anything invalid. Where a type needs help reading the wire
format, a serde_with converter handles the
translation and can enforce any rule you choose, such as the range of a
latitude:
impl<'de> DeserializeAs<'de, Wgs84> for Wgs84AsRecord {
fn deserialize_as<D: Deserializer<'de>>(d: D) -> Result<Wgs84, D::Error> {
// The wire shape of the `Wgs84Coordinate` record
Deserialize
= "Wgs84Coordinate"
struct Wgs84Record { latitude: f64, longitude: f64, altitude: f64 }
let Wgs84Record { latitude, longitude, altitude } =
Wgs84Record::deserialize(d)?;
// `Wgs84::new` rejects a latitude outside −90 to 90
Wgs84::new(latitude, longitude, altitude).map_err(de::Error::custom)
}
}
Either way, invalid data fails as it is decoded rather than wherever it is first used. The check lives in your Rust types, not in the schema. Avro schema readers must ignore logical types they do not recognise. A consumer without your mappings, such as a plain Java reader, still sees the underlying record, strings, and doubles, and keeps working.
Same schema, same bytes
Custom logical types sit next to the standard ones in the same schema, in almost
any position a type can appear. Annotate a whole record, as with
Wgs84Coordinate, and vanavro generates no struct for it; every reference
becomes your type instead. Nothing changes on the wire, and the embedded schema
still describes the record in full.
The schemas stay portable, and the Rust side refuses bad data the moment it arrives.
If you find this interesting, we encourage you to try
vanavro. We look forward to your
feedback and contributions!