Skip to content

About

No description, website, or topics provided.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Latest commit

 

History

23 Commits

Folders and files

Repository files navigation

openapiv3-resolve

crates.io docs.rs

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".

Usage

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(())
}

Traits

  • Resolve — implemented on OpenAPI. openapi.resolve_ref::<Schema>(ptr) takes a full pointer like #/components/schemas/Pet. The type argument decides which section is searched.
  • ResolveWithOpenAPI<T> — implemented on ReferenceOr<T> and ReferenceOr<Box<T>>; returns the inline item, or resolves the reference.
  • ResolveOptionalWithOpenAPI<T> — implemented on Option<R> for any resolvable R; resolve_optional returns Ok(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.

Resolving a whole document

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.

Recursive schemas

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.

Errors

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.

Thread safety

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.

Minimum supported Rust version

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.

License

Licensed under the MIT license.

About

No description, website, or topics provided.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages