Reference resolution helpers for the openapiv3 crate.
This crate adds traits that resolve $ref pointers (e.g.
#/components/schemas/Pet) against an OpenAPI document, returning a
borrowed reference to the resolved item. It walks chains of references
transparently, supports the boxed variant ReferenceOr<Box<T>> used by fields
such as ArrayType::items, and reports why a reference could not be resolved
instead of collapsing every failure into "not found".
use openapiv3::{OpenAPI, StatusCode};
use openapiv3_resolve::{ResolveOptionalWithOpenAPI, ResolveWithOpenAPI};
fn main() -> Result<(), Box<dyn std::error::Error>> {
let spec = r##"{
"openapi": "3.0.0",
"info": { "title": "Pets", "version": "1.0.0" },
"paths": {
"/pets": {
"get": {
"responses": { "200": { "$ref": "#/components/responses/PetList" } }
}
}
},
"components": {
"responses": {
"PetList": {
"description": "a list of pets",
"content": {
"application/json": { "schema": { "$ref": "#/components/schemas/Pet" } }
}
}
},
"schemas": {
"Pet": { "title": "Pet", "type": "string" }
}
}
}"##;
let openapi: OpenAPI = serde_json::from_str(spec)?;
let path = openapi.paths.paths.get("/pets").ok_or("no /pets")?;
let get = path.resolve(&openapi)?.get.as_ref().ok_or("no GET")?;
// `responses` holds a `ReferenceOr<Response>`; `resolve` follows the `$ref`.
let response = get
.responses
.responses
.get(&StatusCode::Code(200))
.ok_or("no 200")?
.resolve(&openapi)?;
// `schema` is an `Option<ReferenceOr<Schema>>`: absent is not an error,
// so it gets its own method.
let media = response.content.get("application/json").ok_or("no JSON body")?;
let schema = media.schema.resolve_optional(&openapi)?.ok_or("untyped body")?;
assert_eq!(schema.schema_data.title.as_deref(), Some("Pet"));
Ok(())
}Resolve— implemented onOpenAPI.openapi.resolve_ref::<Schema>(ptr)takes a full pointer like#/components/schemas/Pet. The type argument decides which section is searched.ResolveWithOpenAPI<T>— implemented onReferenceOr<T>andReferenceOr<Box<T>>; returns the inline item, or resolves the reference.ResolveOptionalWithOpenAPI<T>— implemented onOption<R>for any resolvableR;resolve_optionalreturnsOk(None)for an absent field and an error only for a reference that is present but broken.
Resolvable targets are the nine #/components sections plus #/paths, listed
by the Section enum. A $ref is read as a URI reference: the fragment is
percent-decoded first, then RFC 6901 unescaped, so the path /pets/{id} is
reachable as #/paths/~1pets~1%7Bid%7D.
The Component trait that maps a Rust type to its section is sealed, so the
two can never disagree. Note that openapiv3::Callback is a transparent alias
for IndexMap<String, PathItem> rather than a distinct type, so any value of
that shape resolves as a callback.
ResolvedOpenAPI is the document with every $ref followed up front: a
mirror of openapiv3::OpenAPI in which each ReferenceOr<T> has become a
Shared<ResolvedT> (or Shared<T> for Example, Link and
SecurityScheme, which hold no references). Shared dereferences to the
item. Every reference to the same component shares one allocation, so
Shared::as_ptr tells whether two sites named the same component, and
components holds those same allocations. A schema's discriminator.mapping
is resolved too: a value containing # is followed as a $ref, any other
value is the name of a schema under components/schemas, and either way the
entry becomes a NestedSchema edge to that schema. On a oneOf or anyOf
schema each value must name one of the alternatives and the entry is that
alternative's edge; a value naming anything else, or a dangling one, fails the
document.
use openapiv3::OpenAPI;
use openapiv3_resolve::{ResolvedOpenAPI, ResolvedParameterSchemaOrContent, Shared};
use std::ptr;
fn main() -> Result<(), Box<dyn std::error::Error>> {
let spec = r##"{
"openapi": "3.0.0",
"info": { "title": "Pets", "version": "1.0.0" },
"paths": {
"/pets": {
"get": {
"parameters": [ { "$ref": "#/components/parameters/Limit" } ],
"responses": {}
}
}
},
"components": {
"parameters": {
"Limit": {
"name": "limit", "in": "query",
"schema": { "$ref": "#/components/schemas/Limit" }
}
},
"schemas": { "Limit": { "type": "integer" } }
}
}"##;
let openapi: OpenAPI = serde_json::from_str(spec)?;
let resolved = ResolvedOpenAPI::try_from(&openapi)?;
let get = resolved.paths().paths["/pets"].get.as_ref().ok_or("no GET")?;
let limit = get.parameters.first().ok_or("no parameter")?;
let ResolvedParameterSchemaOrContent::Schema(schema) = &limit.parameter_data().format else {
return Err("limit has content, not a schema".into());
};
let components = resolved.components().ok_or("no components")?;
assert!(ptr::eq(Shared::as_ptr(limit), Shared::as_ptr(&components.parameters["Limit"])));
assert!(ptr::eq(Shared::as_ptr(schema), Shared::as_ptr(&components.schemas["Limit"])));
Ok(())
}Resolution fails on the first reference that does not resolve, with the same
ResolveError the borrowing traits return.
A schema nested inside another schema is a NestedSchema; get() borrows
it. Nearly always that is a schema like any other. The exception is a $ref
that points back at a schema which contains it (a tree node whose children
are nodes, say): that edge is_recursive(), and holds only a weak pointer,
because a cycle of owning pointers would never be freed. get() still just
works, because the target lives in components and the edge can only be
reached by borrowing from the document.
That guarantee is why the document is only ever borrowed from: its fields are
behind getters, and Shared is not Clone, so no piece of it can outlive
the whole. Put the document in an Arc to share it.
use openapiv3::OpenAPI;
use openapiv3_resolve::{ResolvedOpenAPI, ResolvedSchemaKind, ResolvedType};
fn main() -> Result<(), Box<dyn std::error::Error>> {
let spec = r##"{
"openapi": "3.0.0",
"info": { "title": "Trees", "version": "1.0.0" },
"paths": {},
"components": {
"schemas": {
"Node": {
"title": "Node",
"type": "object",
"properties": { "next": { "$ref": "#/components/schemas/Node" } }
}
}
}
}"##;
let openapi: OpenAPI = serde_json::from_str(spec)?;
let resolved = ResolvedOpenAPI::try_from(&openapi)?;
let node = &resolved.components().ok_or("no components")?.schemas["Node"];
let ResolvedSchemaKind::Type(ResolvedType::Object(object)) = &node.schema_kind else {
return Err("Node is not an object".into());
};
let next = &object.properties["next"];
assert!(next.is_recursive());
// `get` borrows the target; no `Option`, no match.
assert_eq!(next.get().schema_data.title.as_deref(), Some("Node"));
Ok(())
}Which edge of a cycle is the recursive one is decided by document order: the
first $ref, walking components then paths, that closes the cycle. A
component that contains itself in any other way (a header whose content
encoding names that same header is the only one a document can express) has
no finite tree form and fails with CyclicReference.
Every failure is a distinct ResolveError variant, so a caller can tell a
typo in the document (NotFound, SectionMismatch) from a reference this
crate structurally does not follow (ExternalDocument, PointerTooDeep) from
a document that is broken (ReferenceChainTooLong, which is what a cycle of
bare $refs looks like, and CyclicReference for a non-schema component
that contains itself, which only a full resolution can detect).
Reference chains are walked iteratively and capped at MAX_REFERENCE_HOPS, so
a cyclic document returns an error rather than overflowing the stack.
OpenAPI and every resolvable component are Send + Sync, resolution takes
&self, and the returned borrow is Send + Sync too — so a resolved reference
can be held across an .await in a Send future.
Resolving a #/components/... pointer allocates nothing, however long the
reference chain. Pointers carrying an escape (~0, ~1, %XX) are the
exception: the decoded name has to be built. Both are pinned by a test.
1.85, which is the floor indexmap imposes rather than anything this crate
needs, and it is checked by its own CI job. A dependency raising its MSRV
raises this one; that is a minor version bump.
Licensed under the MIT license.