Skip to main content

Crate jsonapi_core

Crate jsonapi_core 

Source
Expand description

§jsonapi_core

A typed JSON:API v1.1 serialization library for Rust.

jsonapi_core gives you a complete type model for JSON:API documents — resources, relationships, links, errors — with a derive macro that handles the envelope format so you work with plain Rust structs. It also provides a query builder, content negotiation, sparse fieldset filtering, and a registry for resolving included resources.

§Defining a Resource

Use #[derive(JsonApi)] to map a Rust struct to a JSON:API resource. Fields become attributes by default. Annotate relationships, meta, and links explicitly.

use jsonapi_core::{JsonApi, Relationship};

#[derive(Debug, Clone, PartialEq, JsonApi)]
#[jsonapi(type = "articles")]
struct Article {
    #[jsonapi(id)]
    id: String,
    title: String,
    body: String,
    #[jsonapi(relationship, type = "people")]
    author: Relationship<Person>,
}

#[derive(Debug, Clone, PartialEq, JsonApi)]
#[jsonapi(type = "people")]
struct Person {
    #[jsonapi(id)]
    id: String,
    name: String,
}

§Serializing

Wrap a resource in a Document and serialize with serde. The derive macro produces the JSON:API envelope (type, id, attributes, relationships).

let article = Article {
    id: "1".into(),
    title: "JSON:API paints my bikeshed!".into(),
    body: "The shortest article. Ever.".into(),
    author: Relationship::new(RelationshipData::ToOne(Some(ResourceIdentifier {
        type_: "people".into(),
        identity: Identity::Id("9".into()),
        meta: None,
    }))),
};

let doc: Document<Article> = Document::Data {
    data: PrimaryData::Single(Box::new(article)),
    included: vec![],
    meta: None,
    jsonapi: None,
    links: None,
};

let json = serde_json::to_string_pretty(&doc).unwrap();
assert!(json.contains("\"type\": \"articles\""));

§Deserializing and the Registry

Parse a JSON:API response and use the Registry to look up included resources. Document<P> is generic over the primary type P and, separately, the included type (which defaults to Resource so mixed included arrays Just Work). Write Document<Article> for a typed primary with dynamic included, Document<Resource> for fully dynamic, or Document<Article, Article> for a homogeneous typed document.

use jsonapi_core::{JsonApi, Document, PrimaryData, Resource, ResourceObject};

#[derive(Debug, Clone, PartialEq, JsonApi)]
#[jsonapi(type = "people")]
struct Person {
    #[jsonapi(id)]
    id: String,
    name: String,
}

let json = r#"{
    "data": {
        "type": "articles", "id": "1",
        "attributes": {"title": "Hello JSON:API"},
        "relationships": {
            "author": {"data": {"type": "people", "id": "9"}}
        }
    },
    "included": [{
        "type": "people", "id": "9",
        "attributes": {"name": "Dan Gebhardt"}
    }]
}"#;

let doc: Document<Resource> = serde_json::from_str(json).unwrap();
let registry = doc.registry().unwrap();

// Typed lookup — deserializes the stored Value into a Person
let author: Person = registry.get_by_id("people", "9").unwrap();
assert_eq!(author.name, "Dan Gebhardt");

§Document Accessors

Pattern-matching on Document and PrimaryData for the common single-primary case is verbose. The accessors on Document do the unwrap for you and return Error::UnexpectedDocumentShape when the shape is wrong:

let json = r#"{"data":{"type":"articles","id":"1","attributes":{"title":"Hi"}}}"#;
let doc: Document<Resource> = serde_json::from_str(json).unwrap();

let article = doc.into_single().unwrap();
assert_eq!(article.resource_type(), "articles");
assert_eq!(article.resource_id(), Some("1"));

When a resource carries a #[jsonapi(links)] or #[jsonapi(meta)] field, the derive auto-implements the HasLinks / HasMeta traits so consumers can read those blocks uniformly without pattern-matching on concrete types. Resources without those fields do not implement the traits — the absence is part of the type’s contract.

use jsonapi_core::{HasMeta, JsonApi, Meta};

#[derive(Debug, Clone, PartialEq, JsonApi)]
#[jsonapi(type = "articles")]
struct Article {
    #[jsonapi(id)]
    id: String,
    title: String,
    #[jsonapi(meta)]
    extra: Option<Meta>,
}

fn meta_count<R: HasMeta>(r: &R) -> usize {
    r.meta().map(|m| m.len()).unwrap_or(0)
}

§Working with Relationships

Relationship<T> and Identity expose small helper methods so consumers don’t need to match on the #[non_exhaustive] variants directly.

§Dynamic Resources

When you don’t know the schema at compile time, use Resource as an open-set fallback. It stores attributes as serde_json::Value and relationships as a HashMap.

use jsonapi_core::{Document, PrimaryData, Resource, ResourceObject};

let json = r#"{"data": {"type": "widgets", "id": "42", "attributes": {"color": "red"}}}"#;
let doc: Document<Resource> = serde_json::from_str(json).unwrap();

if let Document::Data { data: PrimaryData::Single(widget), .. } = &doc {
    assert_eq!(widget.resource_type(), "widgets");
    assert_eq!(widget.attributes["color"], "red");
}

§Recursive Resolver

The Registry::resolve() method produces kitsu-core-style flattened output: attributes are hoisted onto the resource, relationships are resolved and inlined recursively, and the JSON:API envelope is stripped.

use jsonapi_core::{Document, Registry, ResolveConfig, Resource};

let json = r#"{
    "data": {
        "type": "articles", "id": "1",
        "attributes": {"title": "Hello"},
        "relationships": {
            "author": {"data": {"type": "people", "id": "9"}}
        }
    },
    "included": [{
        "type": "people", "id": "9",
        "attributes": {"name": "Dan"}
    }]
}"#;

let doc: Document<Resource> = serde_json::from_str(json).unwrap();
let registry = doc.registry().unwrap();
let value: serde_json::Value = serde_json::to_value(&doc).unwrap();
let data = &value["data"];

let flat = registry.resolve(data, &ResolveConfig::default());
assert_eq!(flat["title"], "Hello");
assert_eq!(flat["author"]["name"], "Dan");

§Sparse Fieldsets

Use FieldsetConfig to filter which fields appear in serialized output. Two paths are available: SparseSerializer wraps a typed resource, and sparse_filter() operates on a raw serde_json::Value document.

// Only include the "title" field for articles
let config = FieldsetConfig::new().fields("articles", &["title"]);
let json = serde_json::to_value(SparseSerializer::new(&article, &config)).unwrap();

assert_eq!(json["attributes"]["title"], "Hello");
assert!(json["attributes"].get("body").is_none());

§Include Path Validation

TypeRegistry stores static type metadata and validates that include paths are traversable through the relationship graph.

let mut registry = TypeRegistry::new();
registry.register::<Article>();

// "author" is a valid include path from articles
assert!(registry.validate_include_paths("articles", &["author"]).is_ok());

// "editor" is not a relationship on articles
assert!(registry.validate_include_paths("articles", &["editor"]).is_err());

§Query Builder

QueryBuilder produces JSON:API-compliant query strings with correct bracket encoding and RFC 3986 percent-encoding.

use jsonapi_core::QueryBuilder;

let qs = QueryBuilder::new()
    .include(&["author", "comments"])
    .fields("articles", &["title", "body"])
    .filter("published", "true")
    .sort(&["-created", "title"])
    .page("number", "1")
    .build();

assert!(qs.contains("include=author,comments"));
assert!(qs.contains("fields[articles]=title,body"));
assert!(qs.contains("filter[published]=true"));

§Case Conversion

The derive macro generates fuzzy deserialization aliases for all common case variants of each field name. Output casing is controlled by CaseConvention via the #[jsonapi(case = "...")] attribute.

use jsonapi_core::{CaseConvention, CaseConfig};

let config = CaseConfig { member_case: CaseConvention::CamelCase };
assert_eq!(config.member_case.convert("first_name"), "firstName");
assert_eq!(CaseConvention::KebabCase.convert("firstName"), "first-name");
assert_eq!(CaseConvention::None.convert("first_name"), "first_name");

§Content Negotiation

Validate incoming Content-Type headers and negotiate Accept headers per the JSON:API 1.1 protocol.

use jsonapi_core::{validate_content_type, negotiate_accept, JsonApiMediaType};

// Validate a Content-Type header (rejects unknown parameters)
let mt = validate_content_type("application/vnd.api+json").unwrap();
assert!(mt.ext.is_empty());

// Negotiate an Accept header (returns server capabilities)
let response = negotiate_accept(
    "application/vnd.api+json, application/json",
    &[],  // server extensions
    &[],  // server profiles
).unwrap();
assert_eq!(response.to_header_value(), "application/vnd.api+json");

§Atomic Operations

The atomic module (feature atomic-ops) implements the JSON:API Atomic Operations extension for bundling add/update/remove operations into a single request.

use std::collections::BTreeMap;
use jsonapi_core::{
    atomic::{AtomicOperation, AtomicRequest, OperationTarget, ATOMIC_EXT_URI},
    PrimaryData, Resource,
};

let req = AtomicRequest {
    operations: vec![AtomicOperation::Add {
        target: OperationTarget::default(),
        data: PrimaryData::Single(Box::new(Resource {
            type_: "articles".into(),
            id: None,
            lid: Some("a1".into()),
            attributes: serde_json::json!({"title": "Hello"}),
            relationships: BTreeMap::new(),
            links: None,
            meta: None,
        })),
    }],
};

assert_eq!(ATOMIC_EXT_URI, "https://jsonapi.org/ext/atomic");
let json = serde_json::to_string(&req).unwrap();
assert!(json.contains("\"op\":\"add\""));
req.validate_lid_refs().unwrap();

§Member Name Validation

Validate member names at runtime per JSON:API 1.1 rules. The derive macro also performs compile-time validation of type strings and #[jsonapi(rename)] values.

use jsonapi_core::{validate_member_name, MemberNameKind};

// Standard member name
assert!(matches!(validate_member_name("first-name"), Ok(MemberNameKind::Standard)));

// @-member (extension namespaced)
match validate_member_name("@ext:comments").unwrap() {
    MemberNameKind::AtMember { namespace, member } => {
        assert_eq!(namespace, "ext");
        assert_eq!(member, "comments");
    }
    _ => unreachable!(),
}

// Invalid: empty string
assert!(validate_member_name("").is_err());

§Feature Flags

FeatureDefaultDescription
deriveyesRe-exports #[derive(JsonApi)] from jsonapi_core_derive
atomic-opsnoAtomic Operations extension types (atomic module)

Re-exports§

pub use case::CaseConfig;
pub use case::CaseConvention;
pub use error::Error;
pub use error::Result;
pub use fieldset::FieldsetConfig;
pub use fieldset::SparseSerializer;
pub use fieldset::sparse_filter;
pub use media_type::JsonApiMediaType;
pub use media_type::negotiate_accept;
pub use media_type::validate_content_type;
pub use model::ApiError;
pub use model::Document;
pub use model::ErrorSource;
pub use model::HasMeta;
pub use model::Hreflang;
pub use model::Identity;
pub use model::JsonApiObject;
pub use model::LinkObject;
pub use model::Meta;
pub use model::PrimaryData;
pub use model::Relationship;
pub use model::RelationshipData;
pub use model::Resource;
pub use model::ResourceIdentifier;
pub use model::ResourceObject;
pub use query::QueryBuilder;
pub use registry::Registry;
pub use registry::ResolveConfig;
pub use type_registry::TypeInfo;
pub use type_registry::TypeRegistry;
pub use validation::MemberNameKind;
pub use validation::validate_member_name;
pub use atomic::ATOMIC_EXT_URI;
pub use atomic::AtomicOperation;
pub use atomic::AtomicRequest;
pub use atomic::AtomicResponse;
pub use atomic::AtomicResult;
pub use atomic::OperationRef;
pub use atomic::OperationTarget;

Modules§

atomic
JSON:API Atomic Operations extension (v1.1).
case
Output case convention configuration.
error
Crate-level error types.
fieldset
Sparse fieldset filtering.
media_type
JSON:API 1.1 media-type parsing and content negotiation.
model
Core JSON:API types.
query
JSON:API query string builder.
registry
Included resource registry and recursive resolver.
type_registry
Static type metadata and include-path validation.
validation
Member-name validation per JSON:API 1.1.

Derive Macros§

JsonApi
Derive macro for JSON:API resource types.