jsonapi_core 1.0.0-rc.1

A typed JSON:API v1.1 serialization library for Rust
Documentation
use std::collections::BTreeMap;

use serde::de;
use serde::ser::SerializeMap;
use serde::{Deserialize, Deserializer, Serialize, Serializer};

use super::{HasLinks, HasMeta, Links, Meta, ResourceRelationship};

/// Unifying trait for typed resources and the dynamic `Resource` fallback.
pub trait ResourceObject: Serialize + for<'de> Deserialize<'de> {
    /// The JSON:API type string (e.g. "articles").
    fn resource_type(&self) -> &str;

    /// The server-assigned identifier.
    fn resource_id(&self) -> Option<&str>;

    /// The local identifier (1.1 feature).
    fn resource_lid(&self) -> Option<&str> {
        None
    }

    /// Field names for sparse fieldset support.
    fn field_names() -> &'static [&'static str];

    /// Static type metadata for registry and fieldset support.
    ///
    /// Implemented by `#[derive(JsonApi)]`. Manual implementors must provide this
    /// so that [`TypeRegistry`](crate::TypeRegistry) registration is correct; a
    /// missing implementation is a compile error rather than a runtime panic.
    fn type_info() -> crate::type_registry::TypeInfo
    where
        Self: Sized;
}

/// The statically-known JSON:API type string of a resource.
///
/// Generated by `#[derive(JsonApi)]` from `#[jsonapi(type = "...")]`. Its purpose
/// is to let the derive infer a relationship's target type from `Relationship<T>`
/// (the inner `T::TYPE`) without an explicit `#[jsonapi(relationship, type = "...")]`
/// on every relationship field — the omission of which silently broke
/// [`TypeRegistry::validate_include_paths`](crate::TypeRegistry::validate_include_paths).
pub trait ResourceType {
    /// The JSON:API type string (e.g. `"authors"`).
    const TYPE: &'static str;
}

/// Dynamic fallback for resources whose type is not known at compile time.
#[derive(Debug, Clone, PartialEq, Default)]
pub struct Resource {
    /// The JSON:API type string (e.g. "articles").
    pub r#type: String,
    /// Server-assigned identifier. None for create payloads.
    pub id: Option<String>,
    /// Client-generated local identifier (JSON:API 1.1).
    pub lid: Option<String>,
    /// Resource attributes as a raw JSON value.
    pub attributes: serde_json::Value,
    /// Relationship linkage data, keyed by relationship name.
    pub relationships: BTreeMap<String, ResourceRelationship>,
    /// Resource-level links.
    pub links: Option<Links>,
    /// Resource-level meta information.
    pub meta: Option<Meta>,
}

impl Resource {
    /// Derive a Resource from a typed ResourceObject.
    ///
    /// Not exposed as `TryFrom<&T>`: a blanket `impl<T> TryFrom<&T> for Resource`
    /// collides with the standard library's `impl<T, U: Into<T>> TryFrom<U> for T`,
    /// so an inherent constructor is the correct shape here.
    pub fn from_typed<T: ResourceObject>(value: &T) -> crate::Result<Resource> {
        let resource: Resource = serde_json::from_value(serde_json::to_value(value)?)?;
        Ok(resource)
    }
}

impl ResourceObject for Resource {
    fn resource_type(&self) -> &str {
        &self.r#type
    }

    fn resource_id(&self) -> Option<&str> {
        self.id.as_deref()
    }

    fn resource_lid(&self) -> Option<&str> {
        self.lid.as_deref()
    }

    fn field_names() -> &'static [&'static str] {
        &[] // Dynamic — fields not known at compile time
    }

    fn type_info() -> crate::type_registry::TypeInfo {
        crate::type_registry::TypeInfo::new("", &[], &[])
    }
}

impl ResourceType for Resource {
    /// The dynamic resource has no statically-known type. Used as a heterogeneous
    /// relationship target (`Relationship<Resource>`), where the concrete type
    /// varies per identifier; include-path validation treats `""` as an
    /// unregistered terminal target (traversable one hop, not beyond).
    const TYPE: &'static str = "";
}

impl HasLinks for Resource {
    fn links(&self) -> Option<&Links> {
        self.links.as_ref()
    }
}

impl HasMeta for Resource {
    fn meta(&self) -> Option<&Meta> {
        self.meta.as_ref()
    }
}

impl Serialize for Resource {
    fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
        let mut map = serializer.serialize_map(None)?;

        map.serialize_entry("type", &self.r#type)?;
        if let Some(ref id) = self.id {
            map.serialize_entry("id", id)?;
        }
        if let Some(ref lid) = self.lid {
            map.serialize_entry("lid", lid)?;
        }
        if !self.attributes.is_null() {
            map.serialize_entry("attributes", &self.attributes)?;
        }
        if !self.relationships.is_empty() {
            let mut rels = serde_json::Map::new();
            for (name, rel) in &self.relationships {
                let rel_obj = rel.to_json_object().map_err(serde::ser::Error::custom)?;
                rels.insert(name.clone(), serde_json::Value::Object(rel_obj));
            }
            map.serialize_entry("relationships", &rels)?;
        }
        if let Some(ref links) = self.links {
            map.serialize_entry("links", links)?;
        }
        if let Some(ref meta) = self.meta {
            map.serialize_entry("meta", meta)?;
        }

        map.end()
    }
}

impl<'de> Deserialize<'de> for Resource {
    fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
        let value = serde_json::Value::deserialize(deserializer)?;
        let obj = value
            .as_object()
            .ok_or_else(|| de::Error::custom("resource must be a JSON object"))?;

        let type_ = obj
            .get("type")
            .and_then(|v| v.as_str())
            .ok_or_else(|| de::Error::custom("resource must have a `type` string"))?
            .to_string();

        let id = obj.get("id").and_then(|v| v.as_str()).map(String::from);
        let lid = obj.get("lid").and_then(|v| v.as_str()).map(String::from);

        let attributes = obj
            .get("attributes")
            .cloned()
            .unwrap_or(serde_json::Value::Null);

        let relationships = if let Some(rels_value) = obj.get("relationships") {
            let rels_obj = rels_value
                .as_object()
                .ok_or_else(|| de::Error::custom("`relationships` must be an object"))?;
            let mut map = BTreeMap::new();
            for (name, rel_value) in rels_obj {
                let rel_obj = rel_value
                    .as_object()
                    .ok_or_else(|| de::Error::custom("each relationship must be an object"))?;
                let rel =
                    ResourceRelationship::from_json_object(rel_obj).map_err(de::Error::custom)?;
                map.insert(name.clone(), rel);
            }
            map
        } else {
            BTreeMap::new()
        };

        let links = obj
            .get("links")
            .map(|v| serde_json::from_value(v.clone()))
            .transpose()
            .map_err(de::Error::custom)?;

        let meta = obj
            .get("meta")
            .map(|v| serde_json::from_value(v.clone()))
            .transpose()
            .map_err(de::Error::custom)?;

        Ok(Resource {
            r#type: type_,
            id,
            lid,
            attributes,
            relationships,
            links,
            meta,
        })
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::model::{Identity, RelationshipData};

    #[test]
    fn test_resource_deserialize_simple() {
        let json = r#"{
            "type": "articles",
            "id": "1",
            "attributes": {
                "title": "Rails is Omakase"
            }
        }"#;
        let resource: Resource = serde_json::from_str(json).unwrap();
        assert_eq!(resource.r#type, "articles");
        assert_eq!(resource.id.as_deref(), Some("1"));
        assert_eq!(resource.attributes["title"], "Rails is Omakase");
    }

    #[test]
    fn test_resource_serialize_simple() {
        let resource = Resource {
            r#type: "articles".into(),
            id: Some("1".into()),
            lid: None,
            attributes: serde_json::json!({"title": "Hello"}),
            relationships: BTreeMap::new(),
            links: None,
            meta: None,
        };
        let json = serde_json::to_value(&resource).unwrap();
        assert_eq!(json["type"], "articles");
        assert_eq!(json["id"], "1");
        assert_eq!(json["attributes"]["title"], "Hello");
        assert!(json.get("relationships").is_none());
    }

    #[test]
    fn test_resource_with_relationships() {
        let json = r#"{
            "type": "articles",
            "id": "1",
            "attributes": {"title": "Hello"},
            "relationships": {
                "author": {
                    "data": {"type": "people", "id": "9"}
                }
            }
        }"#;
        let resource: Resource = serde_json::from_str(json).unwrap();
        assert!(resource.relationships.contains_key("author"));
        match &resource.relationships["author"].data {
            Some(RelationshipData::ToOne(Some(rid))) => {
                assert_eq!(rid.r#type, "people");
                assert_eq!(rid.identity, Identity::Id("9".into()));
            }
            _ => panic!("expected to-one relationship"),
        }
    }

    #[test]
    fn test_resource_round_trip() {
        let resource = Resource {
            r#type: "articles".into(),
            id: Some("1".into()),
            lid: None,
            attributes: serde_json::json!({"title": "Hello"}),
            relationships: BTreeMap::new(),
            links: None,
            meta: None,
        };
        let json = serde_json::to_string(&resource).unwrap();
        let deserialized: Resource = serde_json::from_str(&json).unwrap();
        assert_eq!(resource.r#type, deserialized.r#type);
        assert_eq!(resource.id, deserialized.id);
        assert_eq!(resource.attributes, deserialized.attributes);
    }

    #[test]
    fn test_resource_round_trip_preserves_relationship_links_and_meta() {
        let json = r#"{
            "type": "articles",
            "id": "1",
            "attributes": {"title": "Hello"},
            "relationships": {
                "author": {
                    "data": {"type": "people", "id": "9"},
                    "links": {"related": "/articles/1/author"},
                    "meta": {"count": 1}
                }
            }
        }"#;
        let resource: Resource = serde_json::from_str(json).unwrap();
        let rel = &resource.relationships["author"];
        assert!(
            rel.links.is_some(),
            "relationship links must survive deserialize"
        );
        assert!(
            rel.meta.is_some(),
            "relationship meta must survive deserialize"
        );

        // Re-serialize and confirm the members are present.
        let out = serde_json::to_value(&resource).unwrap();
        let author = &out["relationships"]["author"];
        assert_eq!(author["links"]["related"], "/articles/1/author");
        assert_eq!(author["meta"]["count"], 1);
        assert_eq!(author["data"]["id"], "9");
    }

    #[test]
    fn test_resource_rejects_empty_relationship_object() {
        let json = r#"{
            "type": "articles",
            "id": "1",
            "attributes": {},
            "relationships": { "author": {} }
        }"#;
        let err = serde_json::from_str::<Resource>(json).unwrap_err();
        assert!(
            err.to_string().contains("at least one of"),
            "unexpected error: {err}"
        );
    }

    #[test]
    fn test_resource_with_lid() {
        let json = r#"{"type":"articles","lid":"temp-1","attributes":{}}"#;
        let resource: Resource = serde_json::from_str(json).unwrap();
        assert_eq!(resource.lid.as_deref(), Some("temp-1"));
        assert!(resource.id.is_none());
    }

    #[test]
    fn test_resource_object_trait() {
        let resource = Resource {
            r#type: "articles".into(),
            id: Some("1".into()),
            lid: None,
            attributes: serde_json::json!({}),
            relationships: BTreeMap::new(),
            links: None,
            meta: None,
        };
        assert_eq!(resource.resource_type(), "articles");
        assert_eq!(resource.resource_id(), Some("1"));
    }
}