diff --git a/src/api.rs b/src/api.rs index 707e709..982dfdd 100644 --- a/src/api.rs +++ b/src/api.rs @@ -1,16 +1,18 @@ //! Defines custom types and structs primarily that composite the JSON:API //! document +use crate::errors::*; use serde_json; +use std; use std::collections::HashMap; -use crate::errors::*; use std::str::FromStr; -use std; /// Permitted JSON-API values (all JSON Values) pub type JsonApiValue = serde_json::Value; /// Vector of `Resource` pub type Resources = Vec; +/// Vector of `PartialResource` +pub type PartialResources = Vec; /// Vector of `ResourceIdentifiers` pub type ResourceIdentifiers = Vec; pub type Links = HashMap; @@ -54,6 +56,20 @@ pub struct Resource { pub meta: Option, } +/// Representation of a JSON:API resource that lacks an id. +/// This is a struct that contains attributes that map to the +/// JSON:API specification of `id`, `type`, +/// `attributes`, `relationships`, `links`, and `meta` +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Default)] +pub struct PartialResource { + pub _type: String, + pub attributes: ResourceAttributes, + #[serde(skip_serializing_if = "Option::is_none")] + pub links: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub relationships: Option, +} + /// Relationship with another object #[derive(Serialize, Deserialize, Debug, Clone, PartialEq)] pub struct Relationship { @@ -70,6 +86,8 @@ pub enum PrimaryData { None, Single(Box), Multiple(Resources), + SinglePartial(Box), + MultiplePartial(PartialResources), } /// Valid Resource Identifier (can be None) @@ -108,8 +126,9 @@ pub struct DocumentData { pub jsonapi: Option, } -/// An enum that defines the possible composition of a JSON:API document - one which contains `error` or -/// `data` - but not both. Rely on Rust's type system to handle this basic validation instead of +/// An enum that defines the possible composition of a JSON:API document - one which contains +/// `error`, `data` or `partial_data` - but not multiple. +/// Rely on Rust's type system to handle this basic validation instead of /// running validators on parsed documents #[derive(Serialize, Deserialize, Debug, Clone, PartialEq)] #[serde(untagged)] @@ -163,7 +182,6 @@ pub struct Pagination { pub last: Option, } - #[derive(Debug)] pub struct Patch { pub patch_type: PatchType, diff --git a/src/model.rs b/src/model.rs index 7870922..8b119e0 100644 --- a/src/model.rs +++ b/src/model.rs @@ -409,3 +409,197 @@ macro_rules! jsonapi_model { } ); } + +/// A trait for any struct that can be converted from/into a +/// [`PartialResource`](api/struct.PartialResource.tml). The only requirement is +/// that your struct does not have a `id: String` field. +/// You shouldn't be implementing PartialJsonApiModel manually, look at the +/// `jsonapi_model!` macro instead. +pub trait PartialJsonApiModel: Serialize +where + for<'de> Self: Deserialize<'de>, +{ + #[doc(hidden)] + fn jsonapi_type(&self) -> String; + #[doc(hidden)] + fn relationship_fields() -> Option<&'static [&'static str]>; + #[doc(hidden)] + fn build_relationships(&self) -> Option; + + fn from_partial_jsonapi_resource(resource: &PartialResource) -> Result { + Self::from_serializable(Self::partial_resource_to_attrs(resource)) + } + + fn from_jsonapi_document(doc: &DocumentData) -> Result { + match doc.data.as_ref() { + Some(primary_data) => match *primary_data { + PrimaryData::None => bail!("Document had no data"), + PrimaryData::Single(_) => unimplemented!(), + PrimaryData::Multiple(_) => unimplemented!(), + PrimaryData::SinglePartial(ref resource) => { + Self::from_partial_jsonapi_resource(resource) + } + PrimaryData::MultiplePartial(ref resources) => { + let all: Vec = resources + .iter() + .map(|r| Self::partial_resource_to_attrs(r)) + .collect(); + Self::from_serializable(all) + } + }, + None => error_chain::bail!("Document had no data"), + } + } + + fn to_partial_jsonapi_resource(&self) -> PartialResource { + if let Value::Object(attrs) = to_value(self).unwrap() { + PartialResource { + _type: self.jsonapi_type(), + relationships: self.build_relationships(), + attributes: Self::extract_attributes(&attrs), + ..Default::default() + } + } else { + panic!(format!("{} is not a Value::Object", self.jsonapi_type())) + } + } + + fn to_jsonapi_document(&self) -> JsonApiDocument { + let partial_resource = self.to_partial_jsonapi_resource(); + JsonApiDocument::Data(DocumentData { + data: Some(PrimaryData::SinglePartial(Box::new(partial_resource))), + ..Default::default() + }) + } + + #[doc(hidden)] + fn build_has_one(_type: &str, id: &str) -> Relationship { + Relationship { + data: Some(IdentifierData::Single(ResourceIdentifier { + _type: _type.to_string(), + id: id.to_string(), + })), + links: None, + } + } + + #[doc(hidden)] + fn build_has_many(_type: &str, ids: &[String]) -> Relationship { + Relationship { + data: Some(IdentifierData::Multiple( + ids.iter() + .map(|id| ResourceIdentifier { + _type: _type.to_string(), + id: id.to_string(), + }) + .collect(), + )), + links: None, + } + } + + #[doc(hidden)] + fn extract_attributes(attrs: &Map) -> ResourceAttributes { + attrs + .iter() + .filter(|&(key, _)| { + if let Some(fields) = Self::relationship_fields() { + if fields.contains(&key.as_str()) { + return false; + } + } + true + }) + .map(|(k, v)| (k.clone(), v.clone())) + .collect() + } + + #[doc(hidden)] + fn partial_resource_to_attrs(resource: &PartialResource) -> ResourceAttributes { + let mut new_attrs = HashMap::new(); + new_attrs.clone_from(&resource.attributes); + + // Add relationships to new attributes + if let Some(relations) = resource.relationships.as_ref() { + for (name, relation) in relations { + let value = match relation.data { + Some(IdentifierData::None) => Value::Null, + Some(IdentifierData::Single(ref identifier)) => { + to_value(&identifier.id).expect("Casting Single id to value") + } + Some(IdentifierData::Multiple(ref identifiers)) => to_value( + identifiers + .iter() + .map(|identifier| identifier.id.clone()) + .collect::>(), + ) + .expect("Casting Multiple relation to value"), + None => Value::Null, + }; + new_attrs.insert(name.to_string(), value); + } + } + new_attrs + } + + #[doc(hidden)] + fn from_serializable(s: S) -> Result { + from_value(to_value(s)?).map_err(Error::from) + } +} + +#[macro_export] +macro_rules! partial_jsonapi_model { + ($model:ty; $type:expr) => ( + impl PartialJsonApiModel for $model { + fn jsonapi_type(&self) -> String { $type.to_string() } + fn relationship_fields() -> Option<&'static [&'static str]> { None } + fn build_relationships(&self) -> Option { None } + } + ); + ($model:ty; $type:expr; + has one $( $has_one:ident ),* + ) => ( + partial_jsonapi_model!($model; $type; has one $( $has_one ),*; has many); + ); + ($model:ty; $type:expr; + has many $( $has_many:ident ),* + ) => ( + partial_jsonapi_model!($model; $type; has one; has many $( $has_many ),*); + ); + ($model:ty; $type:expr; + has one $( $has_one:ident ),*; + has many $( $has_many:ident ),* + ) => ( + impl PartialJsonApiModel for $model { + fn jsonapi_type(&self) -> String { $type.to_string() } + + fn relationship_fields() -> Option<&'static [&'static str]> { + static FIELDS: &'static [&'static str] = &[ + $( stringify!($has_one),)* + $( stringify!($has_many),)* + ]; + + Some(FIELDS) + } + + fn build_relationships(&self) -> Option { + let mut relationships = HashMap::new(); + $( + relationships.insert(stringify!($has_one).into(), + Self::build_has_one(stringify!($has_one).into(), &self.$has_one.to_string()) + ); + )* + $( + relationships.insert( + stringify!($has_many).into(), + { + Self::build_has_many(stringify!($has_many).into(), &self.$has_many.iter().map(ToString::to_string).collect::>()) + } + ); + )* + Some(relationships) + } + } + ); +} diff --git a/tests/model_test.rs b/tests/model_test.rs index 1f6518a..6be2074 100644 --- a/tests/model_test.rs +++ b/tests/model_test.rs @@ -77,7 +77,7 @@ fn numeric_id() { let doc = chapter.to_jsonapi_document(); assert!(doc.is_valid()); match &doc { - JsonApiDocument::Error(_) => assert!(false), + JsonApiDocument::Error(_) => panic!(), JsonApiDocument::Data(x) => { assert_eq!(x.data, Some(PrimaryData::Single(Box::new(res)))); } @@ -120,7 +120,7 @@ fn from_jsonapi_document() { // This assumes that the fixture we're using is a "valid" document with data match author_doc { - JsonApiDocument::Error(_) => assert!(false), + JsonApiDocument::Error(_) => panic!(), JsonApiDocument::Data(doc) => { let author = Author::from_jsonapi_document(&doc) .expect("Author should be generated from the author_doc"); @@ -130,3 +130,43 @@ fn from_jsonapi_document() { } } } + +#[derive(Debug, PartialEq, Serialize, Deserialize)] +struct NewAuthor { + name: String, + books: Vec, +} +partial_jsonapi_model!(NewAuthor; "authors"; has many books); + +#[derive(Debug, PartialEq, Serialize, Deserialize)] +struct NewBook { + title: String, + first_chapter: String, + chapters: Vec, +} +partial_jsonapi_model!(NewBook; "books"; has one first_chapter; has many chapters); + +#[derive(Debug, PartialEq, Serialize, Deserialize)] +struct NewChapter { + title: String, + ordering: i32, +} +partial_jsonapi_model!(NewChapter; "chapters"); + +#[test] +fn to_jsonapi_document_and_back_partial() { + let new_book = NewBook { + title: "The Fellowship of the Ring".into(), + first_chapter: "1".into(), + chapters: vec!["1".into(), "2".into(), "3".into()], + }; + + let doc = new_book.to_jsonapi_document(); + let json = serde_json::to_string(&doc).unwrap(); + let new_book_doc: DocumentData = serde_json::from_str(&json) + .expect("Book DocumentData should be created from the book json"); + let new_book_again = NewBook::from_jsonapi_document(&new_book_doc) + .expect("NewBook should be generated from the new_book_doc"); + + assert_eq!(new_book, new_book_again); +}