Skip to content

WIP: Add partial resources #77

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Closed
wants to merge 1 commit into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 23 additions & 5 deletions src/api.rs
Original file line number Diff line number Diff line change
@@ -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<Resource>;
/// Vector of `PartialResource`
pub type PartialResources = Vec<PartialResource>;
/// Vector of `ResourceIdentifiers`
pub type ResourceIdentifiers = Vec<ResourceIdentifier>;
pub type Links = HashMap<String, JsonApiValue>;
Expand Down Expand Up @@ -54,6 +56,20 @@ pub struct Resource {
pub meta: Option<Meta>,
}

/// 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<Links>,
#[serde(skip_serializing_if = "Option::is_none")]
pub relationships: Option<Relationships>,
}

/// Relationship with another object
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
pub struct Relationship {
Expand All @@ -70,6 +86,8 @@ pub enum PrimaryData {
None,
Single(Box<Resource>),
Multiple(Resources),
SinglePartial(Box<PartialResource>),
MultiplePartial(PartialResources),
}

/// Valid Resource Identifier (can be None)
Expand Down Expand Up @@ -108,8 +126,9 @@ pub struct DocumentData {
pub jsonapi: Option<JsonApiInfo>,
}

/// 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)]
Expand Down Expand Up @@ -163,7 +182,6 @@ pub struct Pagination {
pub last: Option<String>,
}


#[derive(Debug)]
pub struct Patch {
pub patch_type: PatchType,
Expand Down
194 changes: 194 additions & 0 deletions src/model.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<Relationships>;

fn from_partial_jsonapi_resource(resource: &PartialResource) -> Result<Self> {
Self::from_serializable(Self::partial_resource_to_attrs(resource))
}

fn from_jsonapi_document(doc: &DocumentData) -> Result<Self> {
match doc.data.as_ref() {
Some(primary_data) => match *primary_data {
PrimaryData::None => bail!("Document had no data"),
PrimaryData::Single(_) => unimplemented!(),
Copy link
Author

@johnchildren johnchildren Oct 28, 2020

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This needs an implementation - presumably you should be able to build a partial resource from a full resource, though not the other way around.

PrimaryData::Multiple(_) => unimplemented!(),
PrimaryData::SinglePartial(ref resource) => {
Self::from_partial_jsonapi_resource(resource)
}
PrimaryData::MultiplePartial(ref resources) => {
let all: Vec<ResourceAttributes> = 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<String, Value>) -> 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::<Vec<_>>(),
)
.expect("Casting Multiple relation to value"),
None => Value::Null,
};
new_attrs.insert(name.to_string(), value);
}
}
new_attrs
}

#[doc(hidden)]
fn from_serializable<S: Serialize>(s: S) -> Result<Self> {
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<Relationships> { 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<Relationships> {
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::<Vec<_>>())
}
);
)*
Some(relationships)
}
}
);
}
44 changes: 42 additions & 2 deletions tests/model_test.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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))));
}
Expand Down Expand Up @@ -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");
Expand All @@ -130,3 +130,43 @@ fn from_jsonapi_document() {
}
}
}

#[derive(Debug, PartialEq, Serialize, Deserialize)]
struct NewAuthor {
name: String,
books: Vec<String>,
}
partial_jsonapi_model!(NewAuthor; "authors"; has many books);

#[derive(Debug, PartialEq, Serialize, Deserialize)]
struct NewBook {
Copy link
Author

@johnchildren johnchildren Oct 28, 2020

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should include a test that this model generates relationships and doesn't just smuggle things past seralisation as an attribute.

title: String,
first_chapter: String,
chapters: Vec<String>,
}
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);
}