├── data ├── links_001.json ├── jsonapi_info_001.json ├── links_002.json ├── resource_003.json ├── resource_all_attributes.json ├── resource_004.json ├── resource_001.json ├── resource_002.json ├── resource_post_001.json ├── resource_post_002.json ├── errors.json ├── results.json ├── pagination.json ├── compound_document.json ├── collection.json └── author_tolkien.json ├── .gitignore ├── .github ├── dependabot.yml └── lock.yml ├── src ├── errors.rs ├── array.rs ├── lib.rs ├── query.rs ├── model.rs └── api.rs ├── .clog.toml ├── .codecov.yml ├── tests ├── helper.rs ├── model_test.rs ├── query_test.rs └── api_test.rs ├── Cargo.toml ├── .travis.yml ├── LICENSE ├── README.md └── CHANGELOG.md /data/links_001.json: -------------------------------------------------------------------------------- 1 | { 2 | "self": "http://example.com/posts" 3 | } 4 | -------------------------------------------------------------------------------- /data/jsonapi_info_001.json: -------------------------------------------------------------------------------- 1 | { 2 | "jsonapi": { 3 | "version": "1.0" 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | target 2 | Cargo.lock 3 | *.racertmp 4 | src/*.bk 5 | tests/*.bk 6 | *.swp 7 | *.swo 8 | -------------------------------------------------------------------------------- /data/links_002.json: -------------------------------------------------------------------------------- 1 | { 2 | "related": { 3 | "href": "http://example.com/articles/1/comments", 4 | "meta": { 5 | "count": 10 6 | } 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: cargo 4 | directory: "/" 5 | schedule: 6 | interval: daily 7 | time: "19:30" 8 | open-pull-requests-limit: 10 9 | -------------------------------------------------------------------------------- /data/resource_003.json: -------------------------------------------------------------------------------- 1 | { 2 | "type": "articles", 3 | "id": "1", 4 | "attributes": { 5 | "title": "Rails is Omakase" 6 | }, 7 | "links": { 8 | "self": "http://example.com/articles/1" 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /data/resource_all_attributes.json: -------------------------------------------------------------------------------- 1 | { 2 | "type": "articles", 3 | "id": "1", 4 | "attributes": { 5 | "title": "Rails is Omakase", 6 | "likes": 250, 7 | "published": true, 8 | "draft": false, 9 | "tags": ["rails", "news"] 10 | }, 11 | "links": { 12 | "self": "http://example.com/articles/1" 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /src/errors.rs: -------------------------------------------------------------------------------- 1 | error_chain!{ 2 | foreign_links { 3 | SerdeJson(serde_json::Error); 4 | } 5 | errors { 6 | ResourceToModelError(t: String) { 7 | description("Error converting Resource to Model") 8 | display("Error converting Resource to Model: '{}'", t) 9 | } 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /data/resource_004.json: -------------------------------------------------------------------------------- 1 | { 2 | "type": "articles", 3 | "id": "1", 4 | "attributes": { 5 | "title": "Rails is Omakase" 6 | }, 7 | "relationships": { 8 | "author": { 9 | "links": { 10 | "self": "/articles/1/relationships/author", 11 | "related": "/articles/1/author" 12 | } 13 | } 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /data/resource_001.json: -------------------------------------------------------------------------------- 1 | { 2 | "type": "articles", 3 | "id": "1", 4 | "attributes": { 5 | "title": "Rails is Omakase" 6 | }, 7 | "relationships": { 8 | "author": { 9 | "links": { 10 | "self": "/articles/1/relationships/author", 11 | "related": "/articles/1/author" 12 | }, 13 | "data": { "type": "people", "id": "9" } 14 | } 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /.clog.toml: -------------------------------------------------------------------------------- 1 | [clog] 2 | repository = "https://github.com/michiel/jsonapi-rust" 3 | link-style = "github" 4 | changelog = "CHANGELOG.md" 5 | output-format = "markdown" 6 | from-latest-tag = true 7 | 8 | [sections] 9 | Features = ["feat", "feature"] 10 | Bugfixes = ["bug", "bugs", "bugfix"] 11 | Documentation = ["doc", "docs"] 12 | Tests = ["test", "tests"] 13 | Non-Functional = ["refactor"] 14 | 15 | -------------------------------------------------------------------------------- /data/resource_002.json: -------------------------------------------------------------------------------- 1 | { 2 | "type": "articles", 3 | "id": "1", 4 | "attributes": { 5 | "title": "Rails is Omakase" 6 | }, 7 | "relationships": { 8 | "author": { 9 | "links": { 10 | "self": "http://example.com/articles/1/relationships/author", 11 | "related": "http://example.com/articles/1/author" 12 | }, 13 | "data": { "type": "people", "id": "9" } 14 | } 15 | }, 16 | "links": { 17 | "self": "http://example.com/articles/1" 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /.codecov.yml: -------------------------------------------------------------------------------- 1 | codecov: 2 | notify: 3 | require_ci_to_pass: yes 4 | 5 | coverage: 6 | precision: 2 7 | round: down 8 | range: "60...100" 9 | 10 | status: 11 | project: yes 12 | patch: yes 13 | changes: no 14 | 15 | parsers: 16 | gcov: 17 | branch_detection: 18 | conditional: yes 19 | loop: yes 20 | method: no 21 | macro: no 22 | 23 | comment: 24 | layout: "reach, diff, flags, files, footer" 25 | behavior: default 26 | require_changes: no 27 | 28 | -------------------------------------------------------------------------------- /data/resource_post_001.json: -------------------------------------------------------------------------------- 1 | { 2 | "type": "posts", 3 | "id": "1", 4 | "attributes": { 5 | "title": "Rails is Omakase", 6 | "heading_a": null, 7 | "likes": 250, 8 | "published": true, 9 | "draft": false, 10 | "tags": ["rails", "news"] 11 | }, 12 | "relationships": { 13 | "author": { 14 | "links": { 15 | "self": "/posts/1/relationships/author", 16 | "related": "/posts/1/author" 17 | }, 18 | "data": { "type": "people", "id": "9" } 19 | } 20 | }, 21 | "links": { 22 | "self": "http://example.com/posts/1" 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /data/resource_post_002.json: -------------------------------------------------------------------------------- 1 | { 2 | "type": "posts", 3 | "id": "1", 4 | "attributes": { 5 | "title": "Rails was Omakase", 6 | "heading_a": null, 7 | "likes": 500, 8 | "published": false, 9 | "draft": true, 10 | "tags": ["random", "unpublished"] 11 | }, 12 | "relationships": { 13 | "author": { 14 | "links": { 15 | "self": "/posts/10/relationships/author", 16 | "related": "/posts/1/author" 17 | }, 18 | "data": { "type": "people", "id": "10" } 19 | } 20 | }, 21 | "links": { 22 | "self": "http://example.com/posts/1" 23 | } 24 | } 25 | 26 | -------------------------------------------------------------------------------- /tests/helper.rs: -------------------------------------------------------------------------------- 1 | use std::error::Error; 2 | use std::fs::File; 3 | use std::io::prelude::*; 4 | use std::path::Path; 5 | 6 | pub fn read_json_file(filename: &str) -> String { 7 | let path = Path::new(filename); 8 | let display = path.display(); 9 | 10 | let mut file = match File::open(&path) { 11 | Err(why) => panic!("couldn't open {}: {}", display, Error::description(&why)), 12 | Ok(file) => file, 13 | }; 14 | 15 | let mut s = String::new(); 16 | 17 | if let Err(why) = file.read_to_string(&mut s) { 18 | panic!("couldn't read {}: {}", display, Error::description(&why)); 19 | }; 20 | 21 | s 22 | } 23 | -------------------------------------------------------------------------------- /data/errors.json: -------------------------------------------------------------------------------- 1 | { 2 | "errors": [ 3 | { 4 | "status": "403", 5 | "source": { "pointer": "/data/attributes/secret-powers" }, 6 | "detail": "Editing secret powers is not authorized on Sundays." 7 | }, 8 | { 9 | "status": "422", 10 | "source": { "pointer": "/data/attributes/volume" }, 11 | "detail": "Volume does not, in fact, go to 11." 12 | }, 13 | { 14 | "status": "500", 15 | "source": { "pointer": "/data/attributes/reputation" }, 16 | "title": "The backend responded with an error", 17 | "detail": "Reputation service not responding after three requests." 18 | } 19 | ] 20 | } 21 | -------------------------------------------------------------------------------- /data/results.json: -------------------------------------------------------------------------------- 1 | { 2 | "data": [{ 3 | "type": "articles", 4 | "id": "1", 5 | "attributes": { 6 | "title": "JSON API paints my bikeshed!", 7 | "body": "The shortest article. Ever.", 8 | "created": "2015-05-22T14:56:29.000Z", 9 | "updated": "2015-05-22T14:56:28.000Z" 10 | }, 11 | "relationships": { 12 | "author": { 13 | "data": {"id": "42", "type": "people"} 14 | } 15 | } 16 | }], 17 | "included": [ 18 | { 19 | "type": "people", 20 | "id": "42", 21 | "attributes": { 22 | "name": "John", 23 | "age": 80, 24 | "gender": "male" 25 | } 26 | } 27 | ] 28 | } 29 | -------------------------------------------------------------------------------- /Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "jsonapi" 3 | version = "0.7.0" 4 | authors = ["Michiel Kalkman "] 5 | description = "JSONAPI implementation" 6 | documentation = "https://docs.rs/jsonapi" 7 | homepage = "https://github.com/michiel/jsonapi-rust" 8 | repository = "https://github.com/michiel/jsonapi-rust.git" 9 | readme = "README.md" 10 | keywords = ["jsonapi"] 11 | categories = [] 12 | license = "MIT" 13 | 14 | [dependencies] 15 | serde = "^1.0.21" 16 | serde_json = "^1.0.6" 17 | serde_derive = "^1.0.21" 18 | queryst = "3" 19 | log = "0.4" 20 | error-chain = "^0.12.0" 21 | 22 | [dev-dependencies] 23 | env_logger = "0.9" 24 | 25 | [badges] 26 | travis-ci = { repository = "michiel/jsonapi-rust", branch = "master" } 27 | -------------------------------------------------------------------------------- /data/pagination.json: -------------------------------------------------------------------------------- 1 | { 2 | "meta": { 3 | "total-pages": 13 4 | }, 5 | "data": [ 6 | { 7 | "type": "articles", 8 | "id": "3", 9 | "attributes": { 10 | "title": "JSON API paints my bikeshed!", 11 | "body": "The shortest article. Ever.", 12 | "created": "2015-05-22T14:56:29.000Z", 13 | "updated": "2015-05-22T14:56:28.000Z" 14 | } 15 | } 16 | ], 17 | "links": { 18 | "self": "http://example.com/articles?page[number]=3&page[size]=1", 19 | "first": "http://example.com/articles?page[number]=1&page[size]=1", 20 | "prev": "http://example.com/articles?page[number]=2&page[size]=1", 21 | "next": "http://example.com/articles?page[number]=4&page[size]=1", 22 | "last": "http://example.com/articles?page[number]=13&page[size]=1" 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /src/array.rs: -------------------------------------------------------------------------------- 1 | //! Defines trait and implementations that allow a `has many` relationship to be optional 2 | use crate::model::JsonApiModel; 3 | 4 | /// Trait which allows a `has many` relationship to be optional. 5 | pub trait JsonApiArray { 6 | fn get_models(&self) -> &[M]; 7 | fn get_models_mut(&mut self) -> &mut [M]; 8 | } 9 | 10 | impl JsonApiArray for Vec { 11 | fn get_models(&self) -> &[M] { self } 12 | fn get_models_mut(&mut self) -> &mut [M] { self } 13 | } 14 | 15 | impl JsonApiArray for Option> { 16 | fn get_models(&self) -> &[M] { 17 | self.as_ref() 18 | .map(|v| v.as_slice()) 19 | .unwrap_or(&[][..]) 20 | } 21 | 22 | fn get_models_mut(&mut self) -> &mut [M] { 23 | self.as_mut() 24 | .map(|v| v.as_mut_slice()) 25 | .unwrap_or(&mut [][..]) 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: rust 2 | rust: 3 | - stable 4 | - beta 5 | - nightly 6 | matrix: 7 | allow_failures: 8 | - rust: nightly 9 | deploy: 10 | provider: cargo 11 | condition: $TRAVIS_RUST_VERSION = stable 12 | token: 13 | secure: pJiAM/156C/ZSHUkwcG8DbsbZMeaJD8Wj2EOLCeXpCBJptAdbE4csircY36SaKp0AQrGWfAZsmJRALSwdjV7TUjsnUsMiGfKjiWlEzYrRIqP4Tc4lKnToPt0NWgauV2fFEhzXYDFppKwob4AbW87GzJdfPnijL+Yyg9KQTh4KcKs5nWaxBBdZhiQGR05Ciqnlady9t0qmq3keAXzoplT83fvxtkVH1fDMzyxjshsejJ+QG1pTljiPawOvR6P8vkpVArcrwUHh4De5I0/ZKp0ATgfuEqIcL8N+sEg4uAmOlUERhqWGKqslvXs5Mbz/N3ryknLsYo6UOA8CH0WCODb1WKtdCHEspZwKE/woEUaBqhgD994V45v64ea3X9waXRutotGfj1PNSjLDwnj8nbVog+cxNaNXV2OIKvMEE0qplWUZUUxiqmk97IBYYEGL2uv26bHz9YWCJUlVHFiG+nd8XkQ1jB63voRpvgfLMFni4l+BBgQhRs+9qgiiBjcd025gfMcA56uDh4Z4XqijW9DES1CWhs76rhtKNR9ZdH9LXrlS7vXVFrFAW1c9QP3/Ip5hBkQ7KvCuEnf/qC5ALt84/3KethdgDcIvbkIrOzwhFr+Ka+0sPzg+UEZTHgYBMFFFAelyz79Xu93vmf3DnXFw/t8ZAUqTDtGzXEIFIvK1p0= 14 | on: 15 | tags: true 16 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2017 Michiel Kalkman 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | 23 | -------------------------------------------------------------------------------- /.github/lock.yml: -------------------------------------------------------------------------------- 1 | # Configuration for Lock Threads - https://github.com/dessant/lock-threads 2 | 3 | # Number of days of inactivity before a closed issue or pull request is locked 4 | daysUntilLock: 365 5 | 6 | # Skip issues and pull requests created before a given timestamp. Timestamp must 7 | # follow ISO 8601 (`YYYY-MM-DD`). Set to `false` to disable 8 | skipCreatedBefore: false 9 | 10 | # Issues and pull requests with these labels will be ignored. Set to `[]` to disable 11 | exemptLabels: [] 12 | 13 | # Label to add before locking, such as `outdated`. Set to `false` to disable 14 | lockLabel: false 15 | 16 | # Comment to post before locking. Set to `false` to disable 17 | lockComment: > 18 | This thread has been automatically locked since there has not been 19 | any recent activity after it was closed. Please open a new issue for 20 | related bugs. 21 | 22 | # Assign `resolved` as the reason for locking. Set to `false` to disable 23 | setLockReason: true 24 | 25 | # Limit to only `issues` or `pulls` 26 | # only: issues 27 | 28 | # Optionally, specify configuration settings just for `issues` or `pulls` 29 | # issues: 30 | # exemptLabels: 31 | # - help-wanted 32 | # lockLabel: outdated 33 | 34 | # pulls: 35 | # daysUntilLock: 30 36 | 37 | # Repository to extend settings from 38 | # _extends: repo 39 | -------------------------------------------------------------------------------- /data/compound_document.json: -------------------------------------------------------------------------------- 1 | { 2 | "data": [{ 3 | "type": "articles", 4 | "id": "1", 5 | "attributes": { 6 | "title": "JSON API paints my bikeshed!" 7 | }, 8 | "links": { 9 | "self": "http://example.com/articles/1" 10 | }, 11 | "relationships": { 12 | "author": { 13 | "links": { 14 | "self": "http://example.com/articles/1/relationships/author", 15 | "related": "http://example.com/articles/1/author" 16 | }, 17 | "data": { "type": "people", "id": "9" } 18 | }, 19 | "comments": { 20 | "links": { 21 | "self": "http://example.com/articles/1/relationships/comments", 22 | "related": "http://example.com/articles/1/comments" 23 | }, 24 | "data": [ 25 | { "type": "comments", "id": "5" }, 26 | { "type": "comments", "id": "12" } 27 | ] 28 | } 29 | } 30 | }], 31 | "included": [{ 32 | "type": "people", 33 | "id": "9", 34 | "attributes": { 35 | "first-name": "Dan", 36 | "last-name": "Gebhardt", 37 | "twitter": "dgeb" 38 | }, 39 | "links": { 40 | "self": "http://example.com/people/9" 41 | } 42 | }, { 43 | "type": "comments", 44 | "id": "5", 45 | "attributes": { 46 | "body": "First!" 47 | }, 48 | "relationships": { 49 | "author": { 50 | "data": { "type": "people", "id": "2" } 51 | } 52 | }, 53 | "links": { 54 | "self": "http://example.com/comments/5" 55 | } 56 | }, { 57 | "type": "comments", 58 | "id": "12", 59 | "attributes": { 60 | "body": "I like XML better" 61 | }, 62 | "relationships": { 63 | "author": { 64 | "data": { "type": "people", "id": "9" } 65 | } 66 | }, 67 | "links": { 68 | "self": "http://example.com/comments/12" 69 | } 70 | }] 71 | } 72 | -------------------------------------------------------------------------------- /data/collection.json: -------------------------------------------------------------------------------- 1 | { 2 | "links": { 3 | "self": "http://example.com/articles", 4 | "next": "http://example.com/articles?page[offset]=2", 5 | "last": "http://example.com/articles?page[offset]=10" 6 | }, 7 | "data": [{ 8 | "type": "articles", 9 | "id": "1", 10 | "attributes": { 11 | "title": "JSON API paints my bikeshed!" 12 | }, 13 | "relationships": { 14 | "author": { 15 | "links": { 16 | "self": "http://example.com/articles/1/relationships/author", 17 | "related": "http://example.com/articles/1/author" 18 | }, 19 | "data": { "type": "people", "id": "9" } 20 | }, 21 | "comments": { 22 | "links": { 23 | "self": "http://example.com/articles/1/relationships/comments", 24 | "related": "http://example.com/articles/1/comments" 25 | }, 26 | "data": [ 27 | { "type": "comments", "id": "5" }, 28 | { "type": "comments", "id": "12" } 29 | ] 30 | } 31 | }, 32 | "links": { 33 | "self": "http://example.com/articles/1" 34 | } 35 | }], 36 | "included": [{ 37 | "type": "people", 38 | "id": "9", 39 | "attributes": { 40 | "first-name": "Dan", 41 | "last-name": "Gebhardt", 42 | "twitter": "dgeb" 43 | }, 44 | "links": { 45 | "self": "http://example.com/people/9" 46 | } 47 | }, { 48 | "type": "comments", 49 | "id": "5", 50 | "attributes": { 51 | "body": "First!" 52 | }, 53 | "relationships": { 54 | "author": { 55 | "data": { "type": "people", "id": "2" } 56 | } 57 | }, 58 | "links": { 59 | "self": "http://example.com/comments/5" 60 | } 61 | }, { 62 | "type": "comments", 63 | "id": "12", 64 | "attributes": { 65 | "body": "I like XML better" 66 | }, 67 | "relationships": { 68 | "author": { 69 | "data": { "type": "people", "id": "9" } 70 | } 71 | }, 72 | "links": { 73 | "self": "http://example.com/comments/12" 74 | } 75 | }] 76 | } 77 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # jsonapi-rust 2 | 3 | [![Build Status](https://travis-ci.org/michiel/jsonapi-rust.svg?branch=master)](https://travis-ci.org/michiel/jsonapi-rust) 4 | [![Crates.io Status](http://meritbadge.herokuapp.com/jsonapi)](https://crates.io/crates/jsonapi) 5 | [![License](https://img.shields.io/badge/license-MIT-blue.svg)](https://raw.githubusercontent.com/michiel/jsonapi-rust/master/LICENSE) 6 | [![Documentation](https://docs.rs/jsonapi/badge.svg)](https://docs.rs/jsonapi) 7 | [![FOSSA Status](https://app.fossa.io/api/projects/git%2Bgithub.com%2Fmichiel%2Fjsonapi-rust.svg?type=shield)](https://app.fossa.io/projects/git%2Bgithub.com%2Fmichiel%2Fjsonapi-rust?ref=badge_shield) 8 | 9 | This is an implementation of the JSON-API v1 specification at [jsonapi.org](http://jsonapi.org/). 10 | 11 | * [API Documentation at docs.rs](https://docs.rs/jsonapi) 12 | * [CHANGELOG](https://github.com/michiel/jsonapi-rust/blob/master/CHANGELOG.md) 13 | 14 | ## Use 15 | 16 | Add this crate to your _Cargo.toml_ file, 17 | 18 | [dependencies] 19 | jsonapi = "*" 20 | 21 | Or use the master branch directly from github, 22 | 23 | [dependencies] 24 | jsonapi = { git = "https://github.com/michiel/jsonapi-rust", branch = "master" } 25 | 26 | Examples of most serialization and deserialization cases can be found in the [_tests/_](https://github.com/michiel/jsonapi-rust/tree/master/tests) directory or the [documentation](https://docs.rs/jsonapi). 27 | 28 | ## Development 29 | 30 | _Note - Until this crate reaches v1.0.0 breaking changes that are not backwards compatible will be announced in the [CHANGELOG](https://github.com/michiel/jsonapi-rust/blob/master/CHANGELOG.md)._ 31 | 32 | ### Testing 33 | 34 | The command `cargo test` will run all tests. For more verbose output or output with _cargo watch_, 35 | 36 | RUST_BACKTRACE=1 cargo test -- --nocapture 37 | RUST_BACKTRACE=1 cargo watch "test -- --nocapture" 38 | 39 | ## Contributing 40 | 41 | Contributions are welcome. Please add tests and write commit messages using 42 | using [conventional](https://github.com/conventional-changelog/conventional-changelog/blob/a5505865ff3dd710cf757f50530e73ef0ca641da/conventions/angular.md) format. The Changelog is updated using the [clog](https://github.com/clog-tool/clog-cli) tool. The configuration is found in `.clog.toml`. 43 | 44 | The current configuration works for commit messages prefixed with `feat:`, `bug:`, `test:`, `doc:` and `refactor:`. 45 | 46 | 47 | 48 | 49 | ## License 50 | [![FOSSA Status](https://app.fossa.io/api/projects/git%2Bgithub.com%2Fmichiel%2Fjsonapi-rust.svg?type=large)](https://app.fossa.io/projects/git%2Bgithub.com%2Fmichiel%2Fjsonapi-rust?ref=badge_large) 51 | -------------------------------------------------------------------------------- /src/lib.rs: -------------------------------------------------------------------------------- 1 | #![deny(missing_debug_implementations, 2 | missing_copy_implementations, 3 | trivial_casts, 4 | trivial_numeric_casts, 5 | unsafe_code, 6 | unstable_features, 7 | unused_import_braces, 8 | unused_qualifications 9 | )] 10 | 11 | #![doc(html_root_url = "https://docs.rs/jsonapi/")] 12 | 13 | //! This is documentation for the `jsonapi` crate. 14 | //! The crate is meant to be used for serializing, deserializing and validating 15 | //! [JSON:API] requests and responses. 16 | //! 17 | //! [JSON:API]: https://jsonapi.org/ 18 | //! [serde]: https://serde.rs 19 | //! [JsonApiDocument]: api/struct.JsonApiDocument.html 20 | //! [Resource]: api/struct.Resource.html 21 | //! [jsonapi_model]: macro.jsonapi_model.html 22 | //! 23 | //! ## Examples 24 | //! 25 | //! ### Basic Usage with Macro 26 | //! 27 | //! Using the [`jsonapi_model!`][jsonapi_model] macro a struct can be converted 28 | //! into a [`JsonApiDocument`][JsonApiDocument] or [`Resource`][Resource]. It is 29 | //! required that the struct have an `id` property whose type is `String`. The 30 | //! second argument in the [`jsonapi_model!`][jsonapi_model] marco defines the 31 | //! `type` member as required by the [JSON:API] specification 32 | //! 33 | //! ```rust 34 | //! #[macro_use] extern crate serde_derive; 35 | //! #[macro_use] extern crate jsonapi; 36 | //! use jsonapi::api::*; 37 | //! use jsonapi::model::*; 38 | //! 39 | //! #[derive(Debug, PartialEq, Serialize, Deserialize)] 40 | //! struct Flea { 41 | //! id: String, 42 | //! name: String, 43 | //! }; 44 | //! 45 | //! jsonapi_model!(Flea; "flea"); 46 | //! 47 | //! let example_flea = Flea { 48 | //! id: "123".into(), 49 | //! name: "Mr.Flea".into(), 50 | //! }; 51 | //! 52 | //! // Convert into a `JsonApiDocument` 53 | //! let doc = example_flea.to_jsonapi_document(); 54 | //! assert!(doc.is_valid()); 55 | //! 56 | //! // Convert into a `Resource` 57 | //! let resource = example_flea.to_jsonapi_resource(); 58 | //! ``` 59 | //! 60 | //! ### Deserializing a JSON:API Document 61 | //! 62 | //! Deserialize a JSON:API document using [serde] by explicitly declaring the 63 | //! variable type in `Result` 64 | //! 65 | //! ```rust 66 | //! let serialized = r#" 67 | //! { 68 | //! "data": [{ 69 | //! "type": "articles", 70 | //! "id": "1", 71 | //! "attributes": { 72 | //! "title": "JSON:API paints my bikeshed!", 73 | //! "body": "The shortest article. Ever." 74 | //! }, 75 | //! "relationships": { 76 | //! "author": { 77 | //! "data": {"id": "42", "type": "people"} 78 | //! } 79 | //! } 80 | //! }], 81 | //! "included": [ 82 | //! { 83 | //! "type": "people", 84 | //! "id": "42", 85 | //! "attributes": { 86 | //! "name": "John" 87 | //! } 88 | //! } 89 | //! ] 90 | //! }"#; 91 | //! let data: Result = serde_json::from_str(&serialized); 92 | //! assert_eq!(data.is_ok(), true); 93 | //! ``` 94 | //! 95 | //! Or parse the `String` directly using the 96 | //! [Resource::from_str](api/struct.Resource.html) trait implementation 97 | //! 98 | //! ```rust 99 | //! let data = Resource::from_str(&serialized); 100 | //! assert_eq!(data.is_ok(), true); 101 | //! ``` 102 | //! 103 | //! [`JsonApiDocument`][JsonApiDocument] implements `PartialEq` which allows two 104 | //! documents to be compared for equality. If two documents possess the **same 105 | //! contents** the ordering of the attributes and fields within the JSON:API 106 | //! document are irrelevant and their equality will be `true`. 107 | //! 108 | //! ## Testing 109 | //! 110 | //! Run the tests: 111 | //! 112 | //! ```text 113 | //! cargo test 114 | //! ``` 115 | //! 116 | //! Run tests with more verbose output: 117 | //! 118 | //! ```text 119 | //! RUST_BACKTRACE=1 cargo test -- --nocapture 120 | //! ``` 121 | //! 122 | //! Run tests whenever files are modified using `cargo watch`: 123 | //! 124 | //! ```text 125 | //! RUST_BACKTRACE=1 cargo watch "test -- --nocapture" 126 | //! ``` 127 | //! 128 | 129 | extern crate serde; 130 | extern crate serde_json; 131 | #[macro_use] 132 | extern crate serde_derive; 133 | 134 | extern crate queryst; 135 | 136 | #[macro_use] 137 | extern crate log; 138 | 139 | #[macro_use] 140 | extern crate error_chain; 141 | 142 | pub mod api; 143 | pub mod array; 144 | pub mod query; 145 | pub mod model; 146 | pub mod errors; 147 | -------------------------------------------------------------------------------- /tests/model_test.rs: -------------------------------------------------------------------------------- 1 | #[macro_use] 2 | extern crate jsonapi; 3 | #[macro_use] 4 | extern crate serde_derive; 5 | extern crate serde_json; 6 | use jsonapi::array::JsonApiArray; 7 | use jsonapi::model::*; 8 | 9 | mod helper; 10 | use helper::read_json_file; 11 | 12 | #[derive(Debug, PartialEq, Serialize, Deserialize)] 13 | struct Author { 14 | id: String, 15 | name: String, 16 | books: Vec, 17 | } 18 | jsonapi_model!(Author; "authors"; has many books); 19 | 20 | #[derive(Debug, PartialEq, Serialize, Deserialize)] 21 | struct Book { 22 | id: String, 23 | title: String, 24 | first_chapter: Chapter, 25 | chapters: Vec 26 | } 27 | jsonapi_model!(Book; "books"; has one first_chapter; has many chapters); 28 | 29 | #[derive(Debug, PartialEq, Serialize, Deserialize)] 30 | struct Chapter { 31 | id: String, 32 | title: String, 33 | ordering: i32, 34 | } 35 | jsonapi_model!(Chapter; "chapters"); 36 | 37 | #[test] 38 | fn to_jsonapi_document_and_back() { 39 | let book = Book { 40 | id: "1".into(), 41 | title: "The Fellowship of the Ring".into(), 42 | first_chapter: Chapter { id: "1".into(), title: "A Long-expected Party".into(), ordering: 1 }, 43 | chapters: vec![ 44 | Chapter { id: "1".into(), title: "A Long-expected Party".into(), ordering: 1 }, 45 | Chapter { id: "2".into(), title: "The Shadow of the Past".into(), ordering: 2 }, 46 | Chapter { id: "3".into(), title: "Three is Company".into(), ordering: 3 } 47 | ], 48 | }; 49 | 50 | let doc = book.to_jsonapi_document(); 51 | let json = serde_json::to_string(&doc).unwrap(); 52 | let book_doc: DocumentData = serde_json::from_str(&json) 53 | .expect("Book DocumentData should be created from the book json"); 54 | let book_again = Book::from_jsonapi_document(&book_doc) 55 | .expect("Book should be generated from the book_doc"); 56 | 57 | assert_eq!(book, book_again); 58 | } 59 | 60 | #[test] 61 | fn numeric_id() { 62 | #[derive(Debug, PartialEq, Serialize, Deserialize)] 63 | struct NumericChapter { 64 | id: i32, 65 | title: String, 66 | } 67 | jsonapi_model!(NumericChapter; "numeric_chapter"); 68 | 69 | let chapter = NumericChapter { 70 | id: 24, 71 | title: "The Riders of Rohan".into(), 72 | }; 73 | 74 | let (res, _) = chapter.to_jsonapi_resource(); 75 | assert_eq!(res.id, "24".to_string()); 76 | 77 | let doc = chapter.to_jsonapi_document(); 78 | assert!(doc.is_valid()); 79 | match &doc { 80 | JsonApiDocument::Error(_) => assert!(false), 81 | JsonApiDocument::Data(x) => { 82 | assert_eq!(x.data, Some(PrimaryData::Single(Box::new(res)))); 83 | } 84 | } 85 | 86 | let json = serde_json::to_string(&doc).unwrap(); 87 | let _num_doc: JsonApiDocument = serde_json::from_str(&json) 88 | .expect("NumericChapter JsonApiDocument should be created from the chapter json"); 89 | } 90 | 91 | #[test] 92 | fn test_vec_to_jsonapi_document() { 93 | let chapters = vec![ 94 | Chapter { 95 | id: "45".into(), 96 | title: "The Passing of the Grey Company".into(), 97 | ordering: 2, 98 | }, 99 | Chapter { 100 | id: "46".into(), 101 | title: "The Muster of Rohan".into(), 102 | ordering: 3, 103 | }, 104 | ]; 105 | 106 | let doc = vec_to_jsonapi_document(chapters); 107 | assert!(doc.is_valid()); 108 | } 109 | 110 | #[test] 111 | fn from_jsonapi_document() { 112 | let json = ::read_json_file("data/author_tolkien.json"); 113 | 114 | // TODO - is this the right thing that we want to test? Shold this cast into a JsonApiDocument 115 | // and detect if this was a data or an error? 116 | // Not sure that we want to immediately cast this into a "data" when we don't know if this isi 117 | // valid test file - it could be an error document for all we know... (that is equally valid) 118 | let author_doc: JsonApiDocument = serde_json::from_str(&json) 119 | .expect("Author DocumentData should be created from the author json"); 120 | 121 | // This assumes that the fixture we're using is a "valid" document with data 122 | match author_doc { 123 | JsonApiDocument::Error(_) => assert!(false), 124 | JsonApiDocument::Data(doc) => { 125 | let author = Author::from_jsonapi_document(&doc) 126 | .expect("Author should be generated from the author_doc"); 127 | 128 | let doc_again = author.to_jsonapi_document(); 129 | assert!(doc_again.is_valid()); 130 | } 131 | } 132 | } 133 | -------------------------------------------------------------------------------- /data/author_tolkien.json: -------------------------------------------------------------------------------- 1 | { 2 | "data": { 3 | "id": "1", 4 | "type": "authors", 5 | "attributes": { 6 | "name": "J. R. R. Tolkien" 7 | }, 8 | "relationships": { 9 | "books": { 10 | "data": [ 11 | { 12 | "id": "1", 13 | "type": "books" 14 | }, 15 | { 16 | "id": "2", 17 | "type": "books" 18 | }, 19 | { 20 | "id": "3", 21 | "type": "books" 22 | } 23 | ] 24 | } 25 | } 26 | }, 27 | "included": [ 28 | { 29 | "id": "1", 30 | "type": "books", 31 | "attributes": { 32 | "title": "The Fellowship of the Ring" 33 | }, 34 | "relationships": { 35 | "chapters": { 36 | "data": [ 37 | { 38 | "id": "1", 39 | "type": "chapters" 40 | }, 41 | { 42 | "id": "2", 43 | "type": "chapters" 44 | }, 45 | { 46 | "id": "3", 47 | "type": "chapters" 48 | } 49 | ] 50 | }, 51 | "first_chapter": { 52 | "data": { 53 | "id": "1", 54 | "type": "chapters" 55 | } 56 | }, 57 | "authors": { 58 | "data": { 59 | "id": "1", 60 | "type": "authors" 61 | } 62 | } 63 | } 64 | }, 65 | { 66 | "id": "2", 67 | "type": "books", 68 | "attributes": { 69 | "title": "The Two Towers" 70 | }, 71 | "relationships": { 72 | "chapters": { 73 | "data": [ 74 | { 75 | "id": "23", 76 | "type": "chapters" 77 | }, 78 | { 79 | "id": "24", 80 | "type": "chapters" 81 | }, 82 | { 83 | "id": "25", 84 | "type": "chapters" 85 | } 86 | ] 87 | }, 88 | "first_chapter": { 89 | "data": { 90 | "id": "23", 91 | "type": "chapters" 92 | } 93 | }, 94 | "authors": { 95 | "data": { 96 | "id": "1", 97 | "type": "authors" 98 | } 99 | } 100 | } 101 | }, 102 | { 103 | "id": "3", 104 | "type": "books", 105 | "attributes": { 106 | "title": "Return of the King" 107 | }, 108 | "relationships": { 109 | "chapters": { 110 | "data": [ 111 | { 112 | "id": "44", 113 | "type": "chapters" 114 | }, 115 | { 116 | "id": "45", 117 | "type": "chapters" 118 | }, 119 | { 120 | "id": "46", 121 | "type": "chapters" 122 | } 123 | ] 124 | }, 125 | "first_chapter": { 126 | "data": { 127 | "id": "44", 128 | "type": "chapters" 129 | } 130 | }, 131 | "authors": { 132 | "data": { 133 | "id": "1", 134 | "type": "authors" 135 | } 136 | } 137 | } 138 | }, 139 | { 140 | "id": "1", 141 | "type": "chapters", 142 | "attributes": { 143 | "title": "A Long-expected Party", 144 | "ordering": 1 145 | }, 146 | "relationships": { 147 | "books": { 148 | "data": { 149 | "id": "1", 150 | "type": "books" 151 | } 152 | } 153 | } 154 | }, 155 | { 156 | "id": "2", 157 | "type": "chapters", 158 | "attributes": { 159 | "title": "The Shadow of the Past", 160 | "ordering": 2 161 | }, 162 | "relationships": { 163 | "books": { 164 | "data": { 165 | "id": "1", 166 | "type": "books" 167 | } 168 | } 169 | } 170 | }, 171 | { 172 | "id": "3", 173 | "type": "chapters", 174 | "attributes": { 175 | "title": "Three is Company", 176 | "ordering": 3 177 | }, 178 | "relationships": { 179 | "books": { 180 | "data": { 181 | "id": "1", 182 | "type": "books" 183 | } 184 | } 185 | } 186 | }, 187 | { 188 | "id": "23", 189 | "type": "chapters", 190 | "attributes": { 191 | "title": "The Departure of Boromir", 192 | "ordering": 1 193 | }, 194 | "relationships": { 195 | "books": { 196 | "data": { 197 | "id": "2", 198 | "type": "books" 199 | } 200 | } 201 | } 202 | }, 203 | { 204 | "id": "24", 205 | "type": "chapters", 206 | "attributes": { 207 | "title": "The Riders of Rohan", 208 | "ordering": 2 209 | }, 210 | "relationships": { 211 | "books": { 212 | "data": { 213 | "id": "2", 214 | "type": "books" 215 | } 216 | } 217 | } 218 | }, 219 | { 220 | "id": "25", 221 | "type": "chapters", 222 | "attributes": { 223 | "title": "The Uruk-hai", 224 | "ordering": 3 225 | }, 226 | "relationships": { 227 | "books": { 228 | "data": { 229 | "id": "2", 230 | "type": "books" 231 | } 232 | } 233 | } 234 | }, 235 | { 236 | "id": "44", 237 | "type": "chapters", 238 | "attributes": { 239 | "title": "Minas Tirith", 240 | "ordering": 1 241 | }, 242 | "relationships": { 243 | "books": { 244 | "data": { 245 | "id": "3", 246 | "type": "books" 247 | } 248 | } 249 | } 250 | }, 251 | { 252 | "id": "45", 253 | "type": "chapters", 254 | "attributes": { 255 | "title": "The Passing of the Grey Company", 256 | "ordering": 2 257 | }, 258 | "relationships": { 259 | "books": { 260 | "data": { 261 | "id": "3", 262 | "type": "books" 263 | } 264 | } 265 | } 266 | }, 267 | { 268 | "id": "46", 269 | "type": "chapters", 270 | "attributes": { 271 | "title": "The Muster of Rohan", 272 | "ordering": 3 273 | }, 274 | "relationships": { 275 | "books": { 276 | "data": { 277 | "id": "3", 278 | "type": "books" 279 | } 280 | } 281 | } 282 | } 283 | ] 284 | } 285 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | 2 | ## v0.7.0 (2020-09-10) 3 | 4 | #### Bugfixes 5 | 6 | * Fix issue with infinite recursion when instantiating document ([9997a8c0](https://github.com/michiel/jsonapi-rust/commit/9997a8c07e694e93b6abf68673b67a57916d61e9)) 7 | * replace Dog/Flea with Author/Book/Chapter to expose bug with JsonApiModel::resource_to_attrs and stack overflow error ([9403689e](https://github.com/michiel/jsonapi-rust/commit/9403689e9bd94a34ad93f8a6d027c833adfd8b38)) 8 | 9 | #### Breaking Changes 10 | 11 | * Document validation using Rust types ([6f782603](https://github.com/michiel/jsonapi-rust/commit/6f7826034376c03e61c179cb2390222f71d8525b)) 12 | * Update tests to align with new enum changes ([a002d96d](https://github.com/michiel/jsonapi-rust/commit/a002d96d2f21533eacfb645d8b0c1866e27c6f29), breaks [#](https://github.com/michiel/jsonapi-rust/issues/)) 13 | 14 | #### Non-Functional 15 | 16 | * Fix broken inline documentation tests ([89d07baf](https://github.com/michiel/jsonapi-rust/commit/89d07baf7e4a65d5e23f180bb519c116e52553bc)) 17 | * Fixes tests/model_test with recent changes ([e8f0d0d8](https://github.com/michiel/jsonapi-rust/commit/e8f0d0d866eb665c85b8b6925cf9e82e91c419fe)) 18 | * Update tests to align with new enum changes ([a002d96d](https://github.com/michiel/jsonapi-rust/commit/a002d96d2f21533eacfb645d8b0c1866e27c6f29), breaks [#](https://github.com/michiel/jsonapi-rust/issues/)) 19 | 20 | #### Documentation 21 | 22 | * improve documentation in a few places ([70be409f](https://github.com/michiel/jsonapi-rust/commit/70be409fec545353967a8f6b419efa3ff6795d2c)) 23 | 24 | 25 | ## (2020-01-17) 26 | 27 | #### Tests 28 | 29 | * Add test to demonstrate `PartialEq` ([d24db4f2](https://github.com/michiel/jsonapi-rust/commit/d24db4f2704f738527b0485b6844fb51543e5e6d)) 30 | 31 | #### Documentation 32 | 33 | * Update documentation throughout repository with examples ([c8c51059](https://github.com/michiel/jsonapi-rust/commit/c8c51059b533dd413c2deaa89725b22b435e6cf2)) 34 | 35 | 36 | ## 0.6.5 (2019-11-23) 37 | 38 | #### Bugfixes 39 | 40 | * make Relationship data an Option ([b1a91099](https://github.com/michiel/jsonapi-rust/commit/b1a91099380d818c16d6c8806996a6557fbadf59)) 41 | 42 | 43 | ## 0.6.4 (2019-02-19) 44 | 45 | #### Features 46 | 47 | * Don't suppress errors of serde_json ([3b70f04](https://github.com/michiel/jsonapi-rust/commit/3b70f04e82e3ffab72859157819d06147b07ab09)) 48 | 49 | 50 | ## 0.6.3 (2019-01-02) 51 | 52 | #### Features 53 | 54 | * Derive Clone for Query ([99ff203](https://github.com/michiel/jsonapi-rust/commit/99ff203e97497a09dfc60f40c0daa895714c147f)) 55 | * Enable support for "maybe has many" ([6dc6b41](https://github.com/michiel/jsonapi-rust/commit/6dc6b4152cff84e1f1a1d4e065520dc659415694)) 56 | * Derive JsonApiModel for Box where M: JsonApiModel ([a261447](https://github.com/michiel/jsonapi-rust/commit/a261447cc6eb54f2c20a43be2ac7d71e70950ea6)) 57 | 58 | 59 | ## 0.6.2 (2019-01-01) 60 | 61 | #### Features 62 | 63 | * Add support for filter query param ([664fb91b](https://github.com/michiel/jsonapi-rust/commit/664fb91bf285c9770d180bf40e5ac84a525d4684)) 64 | 65 | 66 | ## 0.6.1 (2018-12-30) 67 | 68 | #### Features 69 | 70 | * Support sort query parameters ([b6b1def5](https://github.com/michiel/jsonapi-rust/commit/b6b1def55a769ae9fbbf60915e3ae44111d6b348)) 71 | 72 | 73 | ## 0.6.0 (2018-02-14) 74 | 75 | #### Features 76 | 77 | * Add conversion of object Vec to jsonapi_document ([1bf60a0](https://github.com/michiel/jsonapi-rust/commit/1bf60a0bd98f1027bb8cc42ddb8fc4ee36a61f4c)) 78 | * Support numeric id in JsonApiModel::to_jsonapi_* ([1f98c88](https://github.com/michiel/jsonapi-rust/commit/1f98c884b80f6d02f28df6d58686908c9068a585)) 79 | 80 | 81 | 82 | ## 0.5.3 (2017-12-20) 83 | 84 | #### Features 85 | 86 | * Box the PrimaryData::Single variant ([bf7a767](https://github.com/michiel/jsonapi-rust/commit/bf7a767bdd70c2829acf18e255393661a0d5b7ed)) 87 | * Use and serialize sparse structs ([75b6bac](https://github.com/michiel/jsonapi-rust/commit/75b6bacf8cff34d03dcfa19e1fc5d743578be2dc)) 88 | * model serialization and deserialization working ([d38093e](https://github.com/michiel/jsonapi-rust/commit/d38093e429afbf0f6f7c49e67db0aa89d7c69915)) 89 | * Implement FromStr for JsonApiDocument and Resource ([fb66803](https://github.com/michiel/jsonapi-rust/commit/fb66803252dd7866713ce93741548a45ba2596ab)) 90 | * Update 'serde*' minimal versions and relax version restrictions ([3723938](https://github.com/michiel/jsonapi-rust/commit/3723938dfa9755cebdbaad6ec8a862a6ad7a529c)) 91 | * Use an empty HashMap if attributes is not supplied ([e0d3712](https://github.com/michiel/jsonapi-rust/commit/e0d3712c9b63e8c04d6e2e8c4df6dfc7eddbef11)) 92 | 93 | #### Bugfixes 94 | 95 | * fix issues with static slice reference on stable ([647f93a](https://github.com/michiel/jsonapi-rust/commit/647f93a0425eff446c10e644ecfc19f957375ecc)) 96 | 97 | 98 | 99 | ## 0.5.1 (2017-04-13) 100 | 101 | #### Bugfixes 102 | 103 | * Not to include data and errors in a same document ([71e65a8](https://github.com/michiel/jsonapi-rust/commit/71e65a8822235e359029c32af51a23bc911fb37d)) 104 | 105 | 106 | 107 | ## 0.5.0 (2017-04-08) 108 | 109 | 110 | #### Features 111 | 112 | * Remove superfluous Pagination impl ([9310e369](https://github.com/michiel/jsonapi-rust/commit/9310e3696518b9cdd00f40d91a9e9bac326f4ff2)) 113 | * Add warn logs for setting query defaults ([a2c6c11a](https://github.com/michiel/jsonapi-rust/commit/a2c6c11a770d308f67b8c7bf2c61d4eca9f18301)) 114 | * Add log crate and error logging ([2283cb97](https://github.com/michiel/jsonapi-rust/commit/2283cb97a57c7b124b94c1f58d1fd49e693aaf55)) 115 | * Add denial of unwanted features ([178bb102](https://github.com/michiel/jsonapi-rust/commit/178bb1029eccb24c36a196d7e0f2eb19721e8e48)) 116 | * Add log crate and error logging ([06ea19b1](https://github.com/michiel/jsonapi-rust/commit/06ea19b1244569c3f4d0406fbc136e7a6e0390ac)) 117 | * Remove obsolete attribute_as_x ([76d8fff0](https://github.com/michiel/jsonapi-rust/commit/76d8fff02f0b7281b40f0136fe65517dc3202d44)) 118 | * Add Optional Meta field to Resource ([9f8d2f0b](https://github.com/michiel/jsonapi-rust/commit/9f8d2f0bd9a8985d5fd82fea88a13055bbf7f067)) 119 | * Initial diff/patch functionality ([0ae612d2](https://github.com/michiel/jsonapi-rust/commit/0ae612d2d002fee26f14e4e286bfef3af4a6caaa)) 120 | * Partial Resource diff implementation ([0686a55f](https://github.com/michiel/jsonapi-rust/commit/0686a55fbfbc4086b406339cd4e18604fad64664)) 121 | * Stub Resource patch/diff functions ([158aa7ba](https://github.com/michiel/jsonapi-rust/commit/158aa7ba156249a2967b07a9903a0fced5b50c35)) 122 | * Stub Resource patch/diff functions ([779e30d9](https://github.com/michiel/jsonapi-rust/commit/779e30d98cacc3b309a4219ff320ea02d89f827c)) 123 | * Add Resource from_str and get_attribute ([436df1ac](https://github.com/michiel/jsonapi-rust/commit/436df1ac2b7e907329ba7471856b064abe156001)) 124 | 125 | 126 | 127 | 128 | ## 0.4.0 (2017-03-05) 129 | 130 | 131 | #### Features 132 | 133 | * Add initial JsonApiModel trait ([7a3a4a23](https://github.com/michiel/jsonapi-rust/commit/7a3a4a2303d649de89b73e348fc8d4c40feaccf5)) 134 | * Resource function get_attribute_as_number ([67e1e661](https://github.com/michiel/jsonapi-rust/commit/67e1e66152ca7d4e8d2a54d5f9aac7f7f9c1b7bf)) 135 | * Add Relationship functions ([b8de4340](https://github.com/michiel/jsonapi-rust/commit/b8de4340485b854d972bd66e92cc100f860d1dd9)) 136 | * Add Resource functions get_relationship and get_attribute_x ([b1342cbf](https://github.com/michiel/jsonapi-rust/commit/b1342cbf3e02b7f834a037f53b180173ca586d7d)) 137 | 138 | 139 | 140 | 141 | ## 0.3.0 (2017-02-28) 142 | 143 | 144 | #### Features 145 | 146 | * Add queryparams generation with test cases ([4048fe83](https://github.com/michiel/jsonapi-rust/commit/4048fe8355e3cb6d1df11162384ca7cb34a402db)) 147 | * Make all JsonApiError fields optional ([0aab0ede](https://github.com/michiel/jsonapi-rust/commit/0aab0ede8e96845fc3b99899d25cc528cbbed64e)) 148 | * Add doc tests (#6) ([66388c05](https://github.com/michiel/jsonapi-rust/commit/66388c05dabfc08ad1c53ccec1d2a9c202a906a6)) 149 | 150 | 151 | 152 | 153 | ## 0.2.0 (2017-02-23) 154 | 155 | #### Features 156 | * Optional primary data (#5) ([65c54989](https://github.com/michiel/jsonapi-rust/commit/65c54989a93fe7dae46d1747d81d686a5e39f162)) 157 | * Extend document validation (#3) ([7ce19ed5](https://github.com/michiel/jsonapi-rust/commit/7ce19ed5fa404fbdb7690e430ad9b520301021e8)) 158 | * Merge Document and JsonApiResponse (#2) ([6fe0be44](https://github.com/michiel/jsonapi-rust/commit/6fe0be44e81c46db8dbd658f0f4cbb38cc9283d7)) 159 | 160 | 161 | 162 | -------------------------------------------------------------------------------- /src/query.rs: -------------------------------------------------------------------------------- 1 | use queryst::parse; 2 | use std::collections::HashMap; 3 | use serde_json::value::Value; 4 | 5 | #[derive(Debug, PartialEq, Clone, Copy)] 6 | pub struct PageParams { 7 | pub size: i64, 8 | pub number: i64, 9 | } 10 | 11 | /// JSON-API Query parameters 12 | #[derive(Clone, Debug, PartialEq, Default)] 13 | pub struct Query { 14 | pub _type: String, 15 | pub include: Option>, 16 | pub fields: Option>>, 17 | pub page: Option, 18 | pub sort: Option>, 19 | pub filter: Option>> 20 | } 21 | 22 | // 23 | // Helper functions to break down the cyclomatic complexity of parameter parsing 24 | // 25 | 26 | fn ok_params_include(o:&Value) -> Option> { 27 | match o.pointer("/include") { 28 | None => None, 29 | Some(inc) => { 30 | match inc.as_str() { 31 | None => None, 32 | Some(include_str) => { 33 | let arr: Vec = 34 | include_str.split(',').map(|s| s.to_string()).collect(); 35 | Some(arr) 36 | } 37 | } 38 | } 39 | } 40 | } 41 | 42 | fn ok_params_fields(o:&Value) -> HashMap> { 43 | let mut fields = HashMap::>::new(); 44 | 45 | if let Some(x) = o.pointer("/fields") { 46 | if x.is_object() { 47 | if let Some(obj) = x.as_object() { 48 | for (key, value) in obj.iter() { 49 | let arr: Vec = match value.as_str() { 50 | Some(string) => { 51 | string.split(',').map(|s| s.to_string()).collect() 52 | } 53 | None => Vec::::new(), 54 | }; 55 | fields.insert(key.to_string(), arr); 56 | 57 | } 58 | } 59 | } else { 60 | warn!("Query::from_params : No fields found in {:?}", x); 61 | } 62 | } 63 | 64 | fields 65 | } 66 | 67 | fn ok_params_sort(o:&Value) -> Option> { 68 | match o.pointer("/sort") { 69 | None => None, 70 | Some(sort) => { 71 | match sort.as_str() { 72 | None => None, 73 | Some(sort_str) => { 74 | let arr: Vec = 75 | sort_str.split(',').map(|s| s.to_string()).collect(); 76 | Some(arr) 77 | } 78 | } 79 | } 80 | } 81 | } 82 | 83 | fn ok_params_filter(o:&Value) -> Option>> { 84 | match o.pointer("/filter") { 85 | None => None, 86 | Some(x) => { 87 | if x.is_object() { 88 | let mut tmp_filter = HashMap::>::new(); 89 | if let Some(obj) = x.as_object() { 90 | for (key, value) in obj.iter() { 91 | let arr: Vec = match value.as_str() { 92 | Some(string) => { 93 | string.split(',').map(|s| s.to_string()).collect() 94 | } 95 | None => Vec::::new(), 96 | }; 97 | tmp_filter.insert(key.to_string(), arr); 98 | } 99 | } 100 | Some(tmp_filter) 101 | } else { 102 | warn!("Query::from_params : No filter found in {:?}", x); 103 | None 104 | } 105 | } 106 | } 107 | } 108 | 109 | fn ok_params_page(o:&Value) -> PageParams { 110 | PageParams { 111 | number: match o.pointer("/page/number") { 112 | None => { 113 | warn!( 114 | "Query::from_params : No page/number found in {:?}, setting \ 115 | default 0", 116 | o 117 | ); 118 | 0 119 | } 120 | Some(num) => { 121 | if num.is_string() { 122 | match num.as_str().map(str::parse::) { 123 | Some(y) => y.unwrap_or(0), 124 | None => { 125 | warn!( 126 | "Query::from_params : page/number found in {:?}, \ 127 | not able not able to parse it - setting default 0", 128 | o 129 | ); 130 | 0 131 | } 132 | } 133 | } else { 134 | warn!( 135 | "Query::from_params : page/number found in {:?}, but it is \ 136 | not an expected type - setting default 0", 137 | o 138 | ); 139 | 0 140 | } 141 | } 142 | }, 143 | size: match o.pointer("/page/size") { 144 | None => { 145 | warn!( 146 | "Query::from_params : No page/size found in {:?}, setting \ 147 | default 0", 148 | o 149 | ); 150 | 0 151 | } 152 | Some(num) => { 153 | if num.is_string() { 154 | match num.as_str().map(str::parse::) { 155 | Some(y) => y.unwrap_or(0), 156 | None => { 157 | warn!( 158 | "Query::from_params : page/size found in {:?}, \ 159 | not able not able to parse it - setting default 0", 160 | o 161 | ); 162 | 0 163 | } 164 | } 165 | } else { 166 | warn!( 167 | "Query::from_params : page/size found in {:?}, but it is \ 168 | not an expected type - setting default 0", 169 | o 170 | ); 171 | 0 172 | } 173 | } 174 | }, 175 | } 176 | } 177 | 178 | fn ok_params(o:Value) -> Query { 179 | Query { 180 | _type: "none".into(), 181 | include : ok_params_include(&o), 182 | fields: Some(ok_params_fields(&o)), 183 | page: Some(ok_params_page(&o)), 184 | sort: ok_params_sort(&o), 185 | filter: ok_params_filter(&o), 186 | } 187 | } 188 | 189 | /// JSON-API Query parameters 190 | impl Query { 191 | /// 192 | /// Takes a query parameter string and returns a Query 193 | /// 194 | /// ``` 195 | /// use jsonapi::query::Query; 196 | /// let query = Query::from_params("include=author&fields[articles]=title,\ 197 | /// body&fields[people]=name&page[number]=3&page[size]=1"); 198 | /// match query.include { 199 | /// None => assert!(false), 200 | /// Some(include) => { 201 | /// assert_eq!(include.len(), 1); 202 | /// assert_eq!(include[0], "author"); 203 | /// } 204 | /// } 205 | /// 206 | /// ``` 207 | pub fn from_params(params: &str) -> Self { 208 | 209 | match parse(params) { 210 | Ok(o) => { 211 | ok_params(o) 212 | } 213 | Err(err) => { 214 | warn!("Query::from_params : Can't parse : {:?}", err); 215 | Query { 216 | _type: "none".into(), 217 | ..Default::default() 218 | } 219 | } 220 | } 221 | } 222 | 223 | /// 224 | /// Builds a query parameter string from a Query 225 | /// 226 | /// ``` 227 | /// use jsonapi::query::{Query, PageParams}; 228 | /// let query = Query { 229 | /// _type: "post".into(), 230 | /// include: Some(vec!["author".into()]), 231 | /// fields: None, 232 | /// page: Some(PageParams { 233 | /// size: 5, 234 | /// number: 10, 235 | /// }), 236 | /// sort: None, 237 | /// filter: None, 238 | /// }; 239 | /// 240 | /// let query_string = query.to_params(); 241 | /// assert_eq!(query_string, "include=author&page[size]=5&page[number]=10"); 242 | /// 243 | /// ``` 244 | pub fn to_params(&self) -> String { 245 | let mut params = Vec::::new(); 246 | 247 | if let Some(ref include) = self.include { 248 | params.push(format!("include={}", include.join(","))); 249 | } 250 | 251 | // Examples from json-api.org, 252 | // fields[articles]=title,body,author&fields[people]=name 253 | // fields[articles]=title,body&fields[people]=name 254 | 255 | if let Some(ref fields) = self.fields { 256 | for (name, val) in fields.iter() { 257 | params.push(format!("fields[{}]={}", name, val.join(","))); 258 | } 259 | } 260 | 261 | if let Some(ref sort) = self.sort { 262 | params.push(format!("sort={}", sort.join(","))) 263 | } 264 | 265 | if let Some(ref filter) = self.filter { 266 | for (name, val) in filter.iter() { 267 | params.push(format!("filter[{}]={}", name, val.join(","))); 268 | } 269 | } 270 | 271 | if let Some(ref page) = self.page { 272 | params.push(page.to_params()); 273 | } 274 | 275 | params.join("&") 276 | } 277 | } 278 | 279 | impl PageParams { 280 | pub fn to_params(&self) -> String { 281 | format!("page[size]={}&page[number]={}", self.size, self.number) 282 | } 283 | } 284 | -------------------------------------------------------------------------------- /tests/query_test.rs: -------------------------------------------------------------------------------- 1 | extern crate jsonapi; 2 | extern crate env_logger; 3 | 4 | use jsonapi::query::*; 5 | 6 | #[test] 7 | fn can_print() { 8 | let _ = env_logger::try_init(); 9 | let query = Query::from_params( 10 | "include=author&fields[articles]=title,\ 11 | body&fields[people]=name&page[number]=3&page[size]=1", 12 | ); 13 | println!("Query is {:?}", query); 14 | 15 | let pageparams = PageParams { size: 1, number: 1 }; 16 | 17 | println!("PageParams is {:?}", pageparams); 18 | } 19 | 20 | #[test] 21 | fn can_parse() { 22 | let _ = env_logger::try_init(); 23 | let query = Query::from_params( 24 | "include=author&fields[articles]=title,\ 25 | body&fields[people]=name&filter[post]=1,2&sort=name&page[number]=3&page[size]=1", 26 | ); 27 | 28 | match query.include { 29 | None => assert!(false), 30 | Some(include) => { 31 | assert_eq!(include.len(), 1); 32 | assert_eq!(include[0], "author"); 33 | } 34 | } 35 | 36 | match query.page { 37 | None => assert!(false), 38 | Some(page) => { 39 | assert_eq!(page.size, 1); 40 | assert_eq!(page.number, 3); 41 | } 42 | } 43 | 44 | match query.fields { 45 | None => assert!(false), 46 | Some(fields) => { 47 | assert_eq!(fields.contains_key("people"), true); 48 | assert_eq!(fields.contains_key("articles"), true); 49 | 50 | match fields.get("people") { 51 | None => assert!(false), 52 | Some(arr) => { 53 | assert_eq!(arr.len(), 1); 54 | assert_eq!(arr[0], "name"); 55 | } 56 | } 57 | match fields.get("articles") { 58 | None => assert!(false), 59 | Some(arr) => { 60 | assert_eq!(arr.len(), 2); 61 | assert_eq!(arr[0], "title"); 62 | assert_eq!(arr[1], "body"); 63 | } 64 | } 65 | } 66 | } 67 | 68 | match query.sort { 69 | None => assert!(false), 70 | Some(sort) => { 71 | assert_eq!(sort.len(), 1); 72 | assert_eq!(sort[0], "name"); 73 | } 74 | } 75 | 76 | match query.filter { 77 | None => assert!(false), 78 | Some(filter) => { 79 | assert_eq!(filter.contains_key("post"), true); 80 | 81 | match filter.get("post") { 82 | None => assert!(false), 83 | Some(arr) => { 84 | assert_eq!(arr.len(), 2); 85 | assert_eq!(arr[0], "1"); 86 | assert_eq!(arr[1], "2"); 87 | } 88 | } 89 | } 90 | } 91 | } 92 | 93 | #[test] 94 | fn can_parse_and_provide_defaults_for_partial_fields() { 95 | let _ = env_logger::try_init(); 96 | let query = Query::from_params(""); 97 | 98 | match query.include { 99 | None => assert!(true), 100 | Some(_) => assert!(false), 101 | } 102 | 103 | match query.fields { 104 | None => assert!(false), 105 | Some(_) => assert!(true), 106 | } 107 | 108 | match query.page { 109 | None => assert!(false), 110 | Some(page) => { 111 | assert_eq!(page.size, 0); 112 | assert_eq!(page.number, 0); 113 | } 114 | } 115 | 116 | match query.sort { 117 | None => assert!(true), 118 | Some(_) => assert!(false), 119 | } 120 | 121 | match query.filter { 122 | None => assert!(true), 123 | Some(_) => assert!(false), 124 | } 125 | } 126 | 127 | #[test] 128 | fn can_parse_and_handle_missing_fields_values() { 129 | let _ = env_logger::try_init(); 130 | 131 | let query = Query::from_params("fields="); 132 | match query.fields { 133 | None => assert!(false), 134 | Some(_) => assert!(true), 135 | } 136 | 137 | let query = Query::from_params("fields=key"); 138 | match query.fields { 139 | None => assert!(false), 140 | Some(_) => assert!(true), 141 | } 142 | 143 | let query = Query::from_params("fields=[key]"); 144 | match query.fields { 145 | None => assert!(false), 146 | Some(_) => assert!(true), 147 | } 148 | 149 | let query = Query::from_params("fields[key]"); 150 | match query.fields { 151 | None => assert!(false), 152 | Some(_) => assert!(true), 153 | } 154 | 155 | let query = Query::from_params("fields[key]="); 156 | match query.fields { 157 | None => assert!(false), 158 | Some(_) => assert!(true), 159 | } 160 | } 161 | 162 | #[test] 163 | fn can_parse_and_handle_missing_filter_values() { 164 | let _ = env_logger::try_init(); 165 | 166 | let query = Query::from_params("filter="); 167 | match query.filter { 168 | None => assert!(true), 169 | Some(_) => assert!(false), 170 | } 171 | 172 | let query = Query::from_params("filter=key"); 173 | match query.filter { 174 | None => assert!(true), 175 | Some(_) => assert!(false), 176 | } 177 | 178 | let query = Query::from_params("filter=[key]"); 179 | match query.filter { 180 | None => assert!(true), 181 | Some(_) => assert!(false), 182 | } 183 | 184 | let query = Query::from_params("filter[key]"); 185 | match query.filter { 186 | None => assert!(false), 187 | Some(_) => assert!(true), 188 | } 189 | 190 | let query = Query::from_params("filter[key]="); 191 | match query.filter { 192 | None => assert!(false), 193 | Some(_) => assert!(true), 194 | } 195 | } 196 | 197 | #[test] 198 | fn can_parse_and_handle_missing_page_values() { 199 | let _ = env_logger::try_init(); 200 | 201 | let query = Query::from_params("page=&"); 202 | 203 | match query.page { 204 | None => assert!(false), 205 | Some(pageparams) => { 206 | assert_eq!(pageparams.number, 0); 207 | assert_eq!(pageparams.size, 0); 208 | } 209 | } 210 | 211 | let query = Query::from_params("page[number]=&page[size]="); 212 | 213 | match query.page { 214 | None => assert!(false), 215 | Some(pageparams) => { 216 | assert_eq!(pageparams.number, 0); 217 | assert_eq!(pageparams.size, 0); 218 | } 219 | } 220 | 221 | let query = Query::from_params("page[number]=/&page[size]=/"); 222 | 223 | match query.page { 224 | None => assert!(false), 225 | Some(pageparams) => { 226 | assert_eq!(pageparams.number, 0); 227 | assert_eq!(pageparams.size, 0); 228 | } 229 | } 230 | } 231 | 232 | #[test] 233 | fn can_parse_and_use_defaults_for_invalid_values() { 234 | let _ = env_logger::try_init(); 235 | let query = Query::from_params("page[number]=x&page[size]=y"); 236 | 237 | match query.include { 238 | None => assert!(true), 239 | Some(_) => assert!(false), 240 | } 241 | 242 | match query.fields { 243 | None => assert!(false), 244 | Some(_) => assert!(true), 245 | } 246 | 247 | match query.page { 248 | None => assert!(false), 249 | Some(page) => { 250 | assert_eq!(page.size, 0); 251 | assert_eq!(page.number, 0); 252 | } 253 | } 254 | 255 | match query.sort { 256 | None => assert!(true), 257 | Some(_) => assert!(false) 258 | } 259 | 260 | match query.filter { 261 | None => assert!(true), 262 | Some(_) => assert!(false), 263 | } 264 | } 265 | 266 | #[test] 267 | fn can_provide_and_empty_struct() { 268 | let _ = env_logger::try_init(); 269 | let query = Query::from_params("!"); 270 | 271 | match query.include { 272 | None => assert!(true), 273 | Some(_) => assert!(false), 274 | } 275 | 276 | match query.fields { 277 | None => assert!(false), 278 | Some(_) => assert!(true), 279 | } 280 | 281 | match query.page { 282 | None => assert!(false), 283 | Some(_) => assert!(true), 284 | } 285 | 286 | match query.sort { 287 | None => assert!(true), 288 | Some(_) => assert!(false), 289 | } 290 | 291 | match query.filter { 292 | None => assert!(true), 293 | Some(_) => assert!(false), 294 | } 295 | } 296 | 297 | #[test] 298 | fn can_generate_string_empty() { 299 | let _ = env_logger::try_init(); 300 | let query = Query { 301 | _type: "none".into(), 302 | include: None, 303 | fields: None, 304 | page: None, 305 | sort: None, 306 | filter: None, 307 | }; 308 | 309 | let query_string = query.to_params(); 310 | 311 | assert_eq!(query_string, ""); 312 | } 313 | 314 | #[test] 315 | fn can_generate_string_include() { 316 | let _ = env_logger::try_init(); 317 | let query = Query { 318 | _type: "none".into(), 319 | include: Some(vec!["author".into()]), 320 | fields: None, 321 | page: None, 322 | sort: None, 323 | filter: None, 324 | }; 325 | 326 | let query_string = query.to_params(); 327 | 328 | assert_eq!(query_string, "include=author"); 329 | } 330 | 331 | #[test] 332 | fn can_generate_string_include_multiple() { 333 | let _ = env_logger::try_init(); 334 | let query = Query { 335 | _type: "none".into(), 336 | include: Some(vec!["author".into(), "publisher".into()]), 337 | fields: None, 338 | page: None, 339 | sort: None, 340 | filter: None, 341 | }; 342 | 343 | let query_string = query.to_params(); 344 | 345 | assert_eq!(query_string, "include=author,publisher"); 346 | } 347 | 348 | #[test] 349 | fn can_generate_string_sort() { 350 | let _ = env_logger::try_init(); 351 | let query = Query { 352 | _type: "none".into(), 353 | include: None, 354 | fields: None, 355 | page: None, 356 | sort: Some(vec!["name".into()]), 357 | filter: None, 358 | }; 359 | 360 | let query_string = query.to_params(); 361 | 362 | assert_eq!(query_string, "sort=name"); 363 | } 364 | 365 | #[test] 366 | fn can_generate_string_sort_multiple() { 367 | let _ = env_logger::try_init(); 368 | let query = Query { 369 | _type: "none".into(), 370 | include: None, 371 | fields: None, 372 | page: None, 373 | sort: Some(vec!["-name".into(),"created".into()]), 374 | filter: None, 375 | }; 376 | 377 | let query_string = query.to_params(); 378 | 379 | assert_eq!(query_string, "sort=-name,created"); 380 | } 381 | 382 | #[test] 383 | fn can_generate_string_fields() { 384 | let _ = env_logger::try_init(); 385 | type VecOfStrings = Vec; 386 | let mut fields = std::collections::HashMap::::new(); 387 | 388 | fields.insert("user".into(), vec!["name".into()]); 389 | 390 | let query = Query { 391 | _type: "none".into(), 392 | include: None, 393 | fields: Some(fields), 394 | page: None, 395 | sort: None, 396 | filter: None, 397 | }; 398 | 399 | let query_string = query.to_params(); 400 | 401 | assert_eq!(query_string, "fields[user]=name"); 402 | } 403 | 404 | #[test] 405 | fn can_generate_string_fields_multiple_values() { 406 | let _ = env_logger::try_init(); 407 | type VecOfStrings = Vec; 408 | let mut fields = std::collections::HashMap::::new(); 409 | 410 | fields.insert("user".into(), vec!["name".into(), "dateofbirth".into()]); 411 | 412 | let query = Query { 413 | _type: "none".into(), 414 | include: None, 415 | fields: Some(fields), 416 | page: None, 417 | sort: None, 418 | filter: None, 419 | }; 420 | 421 | let query_string = query.to_params(); 422 | 423 | assert_eq!(query_string, "fields[user]=name,dateofbirth"); 424 | } 425 | 426 | #[test] 427 | fn can_generate_string_fields_multiple_key_and_values() { 428 | let _ = env_logger::try_init(); 429 | type VecOfStrings = Vec; 430 | let mut fields = std::collections::HashMap::::new(); 431 | 432 | fields.insert("item".into(), vec!["title".into(), "description".into()]); 433 | fields.insert("user".into(), vec!["name".into(), "dateofbirth".into()]); 434 | 435 | let query = Query { 436 | _type: "none".into(), 437 | include: None, 438 | fields: Some(fields), 439 | page: None, 440 | sort: None, 441 | filter: None, 442 | }; 443 | 444 | let query_string = query.to_params(); 445 | 446 | // We don't have any guarantees on the order in which fields are output 447 | // 448 | 449 | assert!( 450 | query_string.eq( 451 | "fields[item]=title,description&fields[user]=name,dateofbirth", 452 | ) || 453 | query_string.eq( 454 | "fields[user]=name,dateofbirth&fields[item]=title,description", 455 | ) 456 | ); 457 | } 458 | 459 | #[test] 460 | fn can_generate_string_filter() { 461 | let _ = env_logger::try_init(); 462 | type VecOfStrings = Vec; 463 | let mut filter = std::collections::HashMap::::new(); 464 | 465 | filter.insert("posts".into(), vec!["1".into()]); 466 | 467 | let query = Query { 468 | _type: "none".into(), 469 | include: None, 470 | fields: None, 471 | page: None, 472 | sort: None, 473 | filter: Some(filter), 474 | }; 475 | 476 | let query_string = query.to_params(); 477 | 478 | assert_eq!(query_string, "filter[posts]=1"); 479 | } 480 | 481 | #[test] 482 | fn can_generate_string_filter_multiple_values() { 483 | let _ = env_logger::try_init(); 484 | type VecOfStrings = Vec; 485 | let mut filter = std::collections::HashMap::::new(); 486 | 487 | filter.insert("posts".into(), vec!["1".into(), "2".into()]); 488 | 489 | let query = Query { 490 | _type: "none".into(), 491 | include: None, 492 | fields: None, 493 | page: None, 494 | sort: None, 495 | filter: Some(filter), 496 | }; 497 | 498 | let query_string = query.to_params(); 499 | 500 | assert_eq!(query_string, "filter[posts]=1,2"); 501 | } 502 | 503 | #[test] 504 | fn can_generate_string_filter_multiple_key_and_values() { 505 | let _ = env_logger::try_init(); 506 | type VecOfStrings = Vec; 507 | let mut filter = std::collections::HashMap::::new(); 508 | 509 | filter.insert("posts".into(), vec!["1".into(), "2".into()]); 510 | filter.insert("authors".into(), vec!["3".into(), "4".into()]); 511 | 512 | let query = Query { 513 | _type: "none".into(), 514 | include: None, 515 | fields: None, 516 | page: None, 517 | sort: None, 518 | filter: Some(filter), 519 | }; 520 | 521 | let query_string = query.to_params(); 522 | 523 | // We don't have any guarantees on the order in which fields are output 524 | // 525 | 526 | assert!( 527 | query_string.eq( 528 | "filter[posts]=1,2&filter[authors]=3,4", 529 | ) || 530 | query_string.eq( 531 | "filter[authors]=3,4&filter[posts]=1,2", 532 | ) 533 | ); 534 | } 535 | 536 | #[test] 537 | fn can_generate_page_fields() { 538 | let _ = env_logger::try_init(); 539 | 540 | let query = Query { 541 | _type: "none".into(), 542 | include: None, 543 | fields: None, 544 | page: Some(PageParams { 545 | size: 5, 546 | number: 10, 547 | }), 548 | sort: None, 549 | filter: None, 550 | }; 551 | 552 | let query_string = query.to_params(); 553 | 554 | assert_eq!(query_string, "page[size]=5&page[number]=10"); 555 | } 556 | -------------------------------------------------------------------------------- /src/model.rs: -------------------------------------------------------------------------------- 1 | //! Defines the `JsonApiModel` trait. This is primarily used in conjunction with 2 | //! the [`jsonapi_model!`](../macro.jsonapi_model.html) macro to allow arbitrary 3 | //! structs which implement `Deserialize` to be converted to/from a 4 | //! [`JsonApiDocument`](../api/struct.JsonApiDocument.html) or 5 | //! [`Resource`](../api/struct.Resource.html) 6 | pub use std::collections::HashMap; 7 | pub use crate::api::*; 8 | use crate::errors::*; 9 | use serde::{Deserialize, Serialize}; 10 | use serde_json::{from_value, to_value, Value, Map}; 11 | 12 | /// A trait for any struct that can be converted from/into a 13 | /// [`Resource`](api/struct.Resource.tml). The only requirement is that your 14 | /// struct has an `id: String` field. 15 | /// You shouldn't be implementing JsonApiModel manually, look at the 16 | /// `jsonapi_model!` macro instead. 17 | pub trait JsonApiModel: Serialize 18 | where 19 | for<'de> Self: Deserialize<'de>, 20 | { 21 | #[doc(hidden)] 22 | fn jsonapi_type(&self) -> String; 23 | #[doc(hidden)] 24 | fn jsonapi_id(&self) -> String; 25 | #[doc(hidden)] 26 | fn relationship_fields() -> Option<&'static [&'static str]>; 27 | #[doc(hidden)] 28 | fn build_relationships(&self) -> Option; 29 | #[doc(hidden)] 30 | fn build_included(&self) -> Option; 31 | 32 | fn from_jsonapi_resource(resource: &Resource, included: &Option) 33 | -> Result 34 | { 35 | 36 | let visited_relationships: Vec<&str> = Vec::new(); 37 | Self::from_serializable(Self::resource_to_attrs(resource, included, &visited_relationships)) 38 | } 39 | 40 | /// Create a single resource object or collection of resource 41 | /// objects directly from 42 | /// [`DocumentData`](../api/struct.DocumentData.html). This method 43 | /// will parse the document (the `data` and `included` resources) in an 44 | /// attempt to instantiate the calling struct. 45 | fn from_jsonapi_document(doc: &DocumentData) -> Result { 46 | match doc.data.as_ref() { 47 | Some(primary_data) => { 48 | match *primary_data { 49 | PrimaryData::None => bail!("Document had no data"), 50 | PrimaryData::Single(ref resource) => { 51 | Self::from_jsonapi_resource(resource, &doc.included) 52 | } 53 | PrimaryData::Multiple(ref resources) => { 54 | let visited_relationships: Vec<&str> = Vec::new(); 55 | let all: Vec = resources 56 | .iter() 57 | .map(|r| Self::resource_to_attrs(r, &doc.included, &visited_relationships)) 58 | .collect(); 59 | Self::from_serializable(all) 60 | } 61 | } 62 | } 63 | None => bail!("Document had no data"), 64 | } 65 | } 66 | 67 | /// Converts the instance of the struct into a 68 | /// [`Resource`](../api/struct.Resource.html) 69 | fn to_jsonapi_resource(&self) -> (Resource, Option) { 70 | if let Value::Object(mut attrs) = to_value(self).unwrap() { 71 | let _ = attrs.remove("id"); 72 | let resource = Resource { 73 | _type: self.jsonapi_type(), 74 | id: self.jsonapi_id(), 75 | relationships: self.build_relationships(), 76 | attributes: Self::extract_attributes(&attrs), 77 | ..Default::default() 78 | }; 79 | 80 | (resource, self.build_included()) 81 | } else { 82 | panic!(format!("{} is not a Value::Object", self.jsonapi_type())) 83 | } 84 | } 85 | 86 | 87 | /// Converts the struct into a complete 88 | /// [`JsonApiDocument`](../api/struct.JsonApiDocument.html) 89 | fn to_jsonapi_document(&self) -> JsonApiDocument { 90 | let (resource, included) = self.to_jsonapi_resource(); 91 | JsonApiDocument::Data ( 92 | DocumentData { 93 | data: Some(PrimaryData::Single(Box::new(resource))), 94 | included, 95 | ..Default::default() 96 | } 97 | ) 98 | } 99 | 100 | 101 | #[doc(hidden)] 102 | fn build_has_one(model: &M) -> Relationship { 103 | Relationship { 104 | data: Some(IdentifierData::Single(model.as_resource_identifier())), 105 | links: None 106 | } 107 | } 108 | 109 | #[doc(hidden)] 110 | fn build_has_many(models: &[M]) -> Relationship { 111 | Relationship { 112 | data: Some(IdentifierData::Multiple( 113 | models.iter().map(|m| m.as_resource_identifier()).collect() 114 | )), 115 | links: None 116 | } 117 | } 118 | 119 | #[doc(hidden)] 120 | fn as_resource_identifier(&self) -> ResourceIdentifier { 121 | ResourceIdentifier { 122 | _type: self.jsonapi_type(), 123 | id: self.jsonapi_id(), 124 | } 125 | } 126 | 127 | /* Attribute corresponding to the model is removed from the Map 128 | * before calling this, so there's no need to ignore it like we do 129 | * with the attributes that correspond with relationships. 130 | * */ 131 | #[doc(hidden)] 132 | fn extract_attributes(attrs: &Map) -> ResourceAttributes { 133 | attrs 134 | .iter() 135 | .filter(|&(key, _)| { 136 | if let Some(fields) = Self::relationship_fields() { 137 | if fields.contains(&key.as_str()) { 138 | return false; 139 | } 140 | } 141 | true 142 | }) 143 | .map(|(k, v)| (k.clone(), v.clone())) 144 | .collect() 145 | } 146 | 147 | #[doc(hidden)] 148 | fn to_resources(&self) -> Resources { 149 | let (me, maybe_others) = self.to_jsonapi_resource(); 150 | let mut flattened = vec![me]; 151 | if let Some(mut others) = maybe_others { 152 | flattened.append(&mut others); 153 | } 154 | flattened 155 | } 156 | 157 | /// When passed a `ResourceIdentifier` (which contains a `type` and `id`) 158 | /// this will iterate through the collection provided `haystack` in an 159 | /// attempt to find and return the `Resource` whose `type` and `id` 160 | /// attributes match 161 | #[doc(hidden)] 162 | fn lookup<'a>(needle: &ResourceIdentifier, haystack: &'a [Resource]) 163 | -> Option<&'a Resource> 164 | { 165 | for resource in haystack { 166 | if resource._type == needle._type && resource.id == needle.id { 167 | return Some(resource); 168 | } 169 | } 170 | None 171 | } 172 | 173 | /// Return a [`ResourceAttributes`](../api/struct.ResourceAttributes.html) 174 | /// object that contains the attributes in this `resource`. This will be 175 | /// called recursively for each `relationship` on the resource in an attempt 176 | /// to satisfy the properties for the calling struct. 177 | /// 178 | /// The last parameter in this function call is `visited_relationships` which is used as this 179 | /// function is called recursively. This `Vec` contains the JSON:API `relationships` that were 180 | /// visited when this function was called last. When operating on the root node of the document 181 | /// this is simply started with an empty `Vec`. 182 | /// 183 | /// Tracking these "visited" relationships is necessary to prevent infinite recursion and stack 184 | /// overflows. This situation can arise when the "included" resource object includes the parent 185 | /// resource object - it will simply ping pong back and forth unable to acheive a finite 186 | /// resolution. 187 | /// 188 | /// The JSON:API specification doesn't communicate the direction of a relationship. 189 | /// Furthermore the current implementation of this crate does not establish an object graph 190 | /// that could be used to traverse these relationships effectively. 191 | #[doc(hidden)] 192 | fn resource_to_attrs(resource: &Resource, included: &Option, visited_relationships: &Vec<&str>) 193 | -> ResourceAttributes 194 | { 195 | let mut new_attrs = HashMap::new(); 196 | new_attrs.clone_from(&resource.attributes); 197 | new_attrs.insert("id".into(), resource.id.clone().into()); 198 | 199 | // Copy the contents of `visited_relationships` so that we can mutate within the lexical 200 | // scope of this function call. This is also important so each edge that we follow (the 201 | // relationship) is not polluted by data from traversing sibling relationships 202 | let mut this_visited: Vec<&str> = Vec::new(); 203 | for rel in visited_relationships.iter() { 204 | this_visited.push(rel); 205 | } 206 | 207 | if let Some(relations) = resource.relationships.as_ref() { 208 | if let Some(inc) = included.as_ref() { 209 | for (name, relation) in relations { 210 | // If we have already visited this resource object, exit early and do not 211 | // recurse through the relations 212 | if this_visited.contains(&name.as_str()) { 213 | return new_attrs; 214 | } 215 | // Track that we have visited this relationship to avoid infinite recursion 216 | this_visited.push(name); 217 | 218 | let value = match relation.data { 219 | Some(IdentifierData::None) => Value::Null, 220 | Some(IdentifierData::Single(ref identifier)) => { 221 | let found = Self::lookup(identifier, inc) 222 | .map(|r| Self::resource_to_attrs(r, included, &this_visited) ); 223 | to_value(found) 224 | .expect("Casting Single relation to value") 225 | }, 226 | Some(IdentifierData::Multiple(ref identifiers)) => { 227 | let found: Vec> = 228 | identifiers.iter().map(|identifier|{ 229 | Self::lookup(identifier, inc).map(|r|{ 230 | Self::resource_to_attrs(r, included, &this_visited) 231 | }) 232 | }).collect(); 233 | to_value(found) 234 | .expect("Casting Multiple relation to value") 235 | }, 236 | None => Value::Null, 237 | }; 238 | new_attrs.insert(name.to_string(), value); 239 | } 240 | } 241 | } 242 | new_attrs 243 | } 244 | 245 | #[doc(hidden)] 246 | fn from_serializable(s: S) -> Result { 247 | from_value(to_value(s)?).map_err(Error::from) 248 | } 249 | } 250 | 251 | /// Converts a `vec!` of structs into 252 | /// [`Resources`](../api/type.Resources.html) 253 | /// 254 | pub fn vec_to_jsonapi_resources( 255 | objects: Vec, 256 | ) -> (Resources, Option) { 257 | let mut included = vec![]; 258 | let resources = objects 259 | .iter() 260 | .map(|obj| { 261 | let (res, mut opt_incl) = obj.to_jsonapi_resource(); 262 | if let Some(ref mut incl) = opt_incl { 263 | included.append(incl); 264 | } 265 | res 266 | }) 267 | .collect::>(); 268 | let opt_included = if included.is_empty() { 269 | None 270 | } else { 271 | Some(included) 272 | }; 273 | (resources, opt_included) 274 | } 275 | 276 | /// Converts a `vec!` of structs into a 277 | /// [`JsonApiDocument`](../api/struct.JsonApiDocument.html) 278 | /// 279 | /// ```rust 280 | /// #[macro_use] extern crate serde_derive; 281 | /// #[macro_use] extern crate jsonapi; 282 | /// use jsonapi::api::*; 283 | /// use jsonapi::model::*; 284 | /// 285 | /// #[derive(Debug, PartialEq, Serialize, Deserialize)] 286 | /// struct Flea { 287 | /// id: String, 288 | /// name: String, 289 | /// } 290 | /// 291 | /// jsonapi_model!(Flea; "flea"); 292 | /// 293 | /// let fleas = vec![ 294 | /// Flea { 295 | /// id: "2".into(), 296 | /// name: "rick".into(), 297 | /// }, 298 | /// Flea { 299 | /// id: "3".into(), 300 | /// name: "morty".into(), 301 | /// }, 302 | /// ]; 303 | /// let doc = vec_to_jsonapi_document(fleas); 304 | /// assert!(doc.is_valid()); 305 | /// ``` 306 | pub fn vec_to_jsonapi_document(objects: Vec) -> JsonApiDocument { 307 | let (resources, included) = vec_to_jsonapi_resources(objects); 308 | JsonApiDocument::Data ( 309 | DocumentData { 310 | data: Some(PrimaryData::Multiple(resources)), 311 | included, 312 | ..Default::default() 313 | } 314 | ) 315 | } 316 | 317 | impl JsonApiModel for Box { 318 | fn jsonapi_type(&self) -> String { 319 | self.as_ref().jsonapi_type() 320 | } 321 | 322 | fn jsonapi_id(&self) -> String { 323 | self.as_ref().jsonapi_id() 324 | } 325 | 326 | fn relationship_fields() -> Option<&'static [&'static str]> { 327 | M::relationship_fields() 328 | } 329 | 330 | fn build_relationships(&self) -> Option { 331 | self.as_ref().build_relationships() 332 | } 333 | 334 | fn build_included(&self) -> Option { 335 | self.as_ref().build_included() 336 | } 337 | } 338 | 339 | /// When applied this macro implements the 340 | /// [`JsonApiModel`](model/trait.JsonApiModel.html) trait for the provided type 341 | /// 342 | #[macro_export] 343 | macro_rules! jsonapi_model { 344 | ($model:ty; $type:expr) => ( 345 | impl JsonApiModel for $model { 346 | fn jsonapi_type(&self) -> String { $type.to_string() } 347 | fn jsonapi_id(&self) -> String { self.id.to_string() } 348 | fn relationship_fields() -> Option<&'static [&'static str]> { None } 349 | fn build_relationships(&self) -> Option { None } 350 | fn build_included(&self) -> Option { None } 351 | } 352 | ); 353 | ($model:ty; $type:expr; 354 | has one $( $has_one:ident ),* 355 | ) => ( 356 | jsonapi_model!($model; $type; has one $( $has_one ),*; has many); 357 | ); 358 | ($model:ty; $type:expr; 359 | has many $( $has_many:ident ),* 360 | ) => ( 361 | jsonapi_model!($model; $type; has one; has many $( $has_many ),*); 362 | ); 363 | ($model:ty; $type:expr; 364 | has one $( $has_one:ident ),*; 365 | has many $( $has_many:ident ),* 366 | ) => ( 367 | impl JsonApiModel for $model { 368 | fn jsonapi_type(&self) -> String { $type.to_string() } 369 | fn jsonapi_id(&self) -> String { self.id.to_string() } 370 | 371 | fn relationship_fields() -> Option<&'static [&'static str]> { 372 | static FIELDS: &'static [&'static str] = &[ 373 | $( stringify!($has_one),)* 374 | $( stringify!($has_many),)* 375 | ]; 376 | 377 | Some(FIELDS) 378 | } 379 | 380 | fn build_relationships(&self) -> Option { 381 | let mut relationships = HashMap::new(); 382 | $( 383 | relationships.insert(stringify!($has_one).into(), 384 | Self::build_has_one(&self.$has_one) 385 | ); 386 | )* 387 | $( 388 | relationships.insert( 389 | stringify!($has_many).into(), 390 | { 391 | let values = &self.$has_many.get_models(); 392 | Self::build_has_many(values) 393 | } 394 | ); 395 | )* 396 | Some(relationships) 397 | } 398 | 399 | fn build_included(&self) -> Option { 400 | let mut included:Resources = vec![]; 401 | $( included.append(&mut self.$has_one.to_resources()); )* 402 | $( 403 | for model in self.$has_many.get_models() { 404 | included.append(&mut model.to_resources()); 405 | } 406 | )* 407 | Some(included) 408 | } 409 | } 410 | ); 411 | } 412 | -------------------------------------------------------------------------------- /src/api.rs: -------------------------------------------------------------------------------- 1 | //! Defines custom types and structs primarily that composite the JSON:API 2 | //! document 3 | use serde_json; 4 | use std::collections::HashMap; 5 | use crate::errors::*; 6 | use std::str::FromStr; 7 | use std; 8 | 9 | /// Permitted JSON-API values (all JSON Values) 10 | pub type JsonApiValue = serde_json::Value; 11 | 12 | /// Vector of `Resource` 13 | pub type Resources = Vec; 14 | /// Vector of `ResourceIdentifiers` 15 | pub type ResourceIdentifiers = Vec; 16 | pub type Links = HashMap; 17 | /// Meta-data object, can contain any data 18 | pub type Meta = HashMap; 19 | /// Resource Attributes, can be any JSON value 20 | pub type ResourceAttributes = HashMap; 21 | /// Map of relationships with other objects 22 | pub type Relationships = HashMap; 23 | /// Side-loaded Resources 24 | pub type Included = Vec; 25 | /// Data-related errors 26 | pub type JsonApiErrors = Vec; 27 | 28 | pub type JsonApiId = String; 29 | pub type JsonApiIds<'a> = Vec<&'a JsonApiId>; 30 | 31 | /// Resource Identifier 32 | #[derive(Serialize, Deserialize, Debug, Clone, PartialEq)] 33 | pub struct ResourceIdentifier { 34 | #[serde(rename = "type")] 35 | pub _type: String, 36 | pub id: JsonApiId, 37 | } 38 | 39 | /// Representation of a JSON:API resource. This is a struct that contains 40 | /// attributes that map to the JSON:API specification of `id`, `type`, 41 | /// `attributes`, `relationships`, `links`, and `meta` 42 | #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Default)] 43 | pub struct Resource { 44 | #[serde(rename = "type")] 45 | pub _type: String, 46 | pub id: JsonApiId, 47 | #[serde(default)] 48 | pub attributes: ResourceAttributes, 49 | #[serde(skip_serializing_if = "Option::is_none")] 50 | pub relationships: Option, 51 | #[serde(skip_serializing_if = "Option::is_none")] 52 | pub links: Option, 53 | #[serde(skip_serializing_if = "Option::is_none")] 54 | pub meta: Option, 55 | } 56 | 57 | /// Relationship with another object 58 | #[derive(Serialize, Deserialize, Debug, Clone, PartialEq)] 59 | pub struct Relationship { 60 | #[serde(skip_serializing_if = "Option::is_none")] 61 | pub data: Option, 62 | #[serde(skip_serializing_if = "Option::is_none")] 63 | pub links: Option, 64 | } 65 | 66 | /// Valid data Resource (can be None) 67 | #[derive(Serialize, Deserialize, Debug, Clone, PartialEq)] 68 | #[serde(untagged)] 69 | pub enum PrimaryData { 70 | None, 71 | Single(Box), 72 | Multiple(Resources), 73 | } 74 | 75 | /// Valid Resource Identifier (can be None) 76 | #[derive(Serialize, Deserialize, Debug, Clone, PartialEq)] 77 | #[serde(untagged)] 78 | pub enum IdentifierData { 79 | None, 80 | Single(ResourceIdentifier), 81 | Multiple(ResourceIdentifiers), 82 | } 83 | 84 | /// A struct that defines an error state for a JSON:API document 85 | #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Default)] 86 | pub struct DocumentError { 87 | pub errors: JsonApiErrors, 88 | #[serde(skip_serializing_if = "Option::is_none")] 89 | pub links: Option, 90 | #[serde(skip_serializing_if = "Option::is_none")] 91 | pub meta: Option, 92 | #[serde(skip_serializing_if = "Option::is_none")] 93 | pub jsonapi: Option, 94 | } 95 | 96 | /// A struct that defines properties for a JSON:API document that contains no errors 97 | #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Default)] 98 | pub struct DocumentData { 99 | #[serde(skip_serializing_if = "Option::is_none")] 100 | pub data: Option, 101 | #[serde(skip_serializing_if = "Option::is_none")] 102 | pub included: Option, 103 | #[serde(skip_serializing_if = "Option::is_none")] 104 | pub links: Option, 105 | #[serde(skip_serializing_if = "Option::is_none")] 106 | pub meta: Option, 107 | #[serde(skip_serializing_if = "Option::is_none")] 108 | pub jsonapi: Option, 109 | } 110 | 111 | /// An enum that defines the possible composition of a JSON:API document - one which contains `error` or 112 | /// `data` - but not both. Rely on Rust's type system to handle this basic validation instead of 113 | /// running validators on parsed documents 114 | #[derive(Serialize, Deserialize, Debug, Clone, PartialEq)] 115 | #[serde(untagged)] 116 | pub enum JsonApiDocument { 117 | Error(DocumentError), 118 | Data(DocumentData), 119 | } 120 | 121 | /// Error location 122 | #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Default)] 123 | pub struct ErrorSource { 124 | pub pointer: Option, 125 | pub parameter: Option, 126 | } 127 | 128 | /// Retpresentation of a JSON:API error (all fields are optional) 129 | #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Default)] 130 | pub struct JsonApiError { 131 | #[serde(skip_serializing_if = "Option::is_none")] 132 | pub id: Option, 133 | #[serde(skip_serializing_if = "Option::is_none")] 134 | pub links: Option, 135 | #[serde(skip_serializing_if = "Option::is_none")] 136 | pub status: Option, 137 | #[serde(skip_serializing_if = "Option::is_none")] 138 | pub code: Option, 139 | #[serde(skip_serializing_if = "Option::is_none")] 140 | pub title: Option, 141 | #[serde(skip_serializing_if = "Option::is_none")] 142 | pub detail: Option, 143 | #[serde(skip_serializing_if = "Option::is_none")] 144 | pub source: Option, 145 | #[serde(skip_serializing_if = "Option::is_none")] 146 | pub meta: Option, 147 | } 148 | 149 | /// Optional `JsonApiDocument` payload identifying the JSON-API version the 150 | /// server implements 151 | #[derive(Serialize, Deserialize, Debug, Clone, PartialEq)] 152 | pub struct JsonApiInfo { 153 | pub version: Option, 154 | pub meta: Option, 155 | } 156 | 157 | /// Pagination links 158 | #[derive(Serialize, Deserialize, Debug)] 159 | pub struct Pagination { 160 | pub first: Option, 161 | pub prev: Option, 162 | pub next: Option, 163 | pub last: Option, 164 | } 165 | 166 | 167 | #[derive(Debug)] 168 | pub struct Patch { 169 | pub patch_type: PatchType, 170 | pub subject: String, 171 | pub previous: JsonApiValue, 172 | pub next: JsonApiValue, 173 | } 174 | 175 | #[derive(Debug)] 176 | pub struct PatchSet { 177 | pub resource_type: String, 178 | pub resource_id: String, 179 | pub patches: Vec, 180 | } 181 | 182 | impl PatchSet { 183 | pub fn new_for(resource: &Resource) -> Self { 184 | PatchSet { 185 | resource_type: resource._type.clone(), 186 | resource_id: resource.id.clone(), 187 | patches: Vec::::new(), 188 | } 189 | } 190 | 191 | pub fn push(&mut self, patch: Patch) { 192 | self.patches.push(patch); 193 | } 194 | } 195 | 196 | impl DocumentData { 197 | fn has_meta(&self) -> bool { 198 | self.meta.is_some() 199 | } 200 | fn has_included(&self) -> bool { 201 | self.included.is_some() 202 | } 203 | fn has_data(&self) -> bool { 204 | self.data.is_some() 205 | } 206 | } 207 | 208 | /// Top-level JSON-API Document 209 | /// An "error" document can be valid, just as a "data" document can be valid 210 | impl JsonApiDocument { 211 | /// This function returns `false` if the `JsonApiDocument` contains any violations of the 212 | /// specification. See [`DocumentValidationError`](enum.DocumentValidationError.html) 213 | /// 214 | /// The spec dictates that the document must have least one of `data`, `errors` or `meta`. 215 | /// Of these, `data` and `errors` must not co-exist. 216 | /// The optional field `included` may only be present if the `data` field is present too. 217 | pub fn is_valid(&self) -> bool { 218 | self.validate().is_none() 219 | } 220 | 221 | /// This function returns a `Vec` with identified specification violations enumerated in 222 | /// `DocumentValidationError` 223 | /// 224 | /// ``` 225 | /// // Simulate an error where `included` has data but `data` does not 226 | /// use jsonapi::api::*; 227 | /// use std::str::FromStr; 228 | /// 229 | /// let serialized = r#"{ 230 | /// "id":"1", 231 | /// "type":"post", 232 | /// "attributes":{ 233 | /// "title": "Rails is Omakase", 234 | /// "likes": 250 235 | /// }, 236 | /// "relationships":{}, 237 | /// "links" :{} 238 | /// }"#; 239 | /// 240 | /// let resource = Resource::from_str(&serialized); 241 | /// 242 | /// let data = DocumentData { 243 | /// data: None, 244 | /// included: Some(vec![resource.unwrap()]), 245 | /// ..Default::default() 246 | /// }; 247 | /// 248 | /// let doc = JsonApiDocument::Data(data); 249 | /// 250 | /// match doc.validate() { 251 | /// Some(errors) => { 252 | /// assert!( 253 | /// errors.contains( 254 | /// &DocumentValidationError::IncludedWithoutData 255 | /// ) 256 | /// ) 257 | /// } 258 | /// None => assert!(false) 259 | /// } 260 | /// ``` 261 | pub fn validate(&self) -> Option> { 262 | let mut errors = Vec::::new(); 263 | 264 | match self { 265 | JsonApiDocument::Error(_x) => None, 266 | JsonApiDocument::Data(doc) => { 267 | if doc.has_included() && !doc.has_data() { 268 | errors.push(DocumentValidationError::IncludedWithoutData); 269 | } 270 | 271 | if !(doc.has_data() || doc.has_meta()) { 272 | errors.push(DocumentValidationError::MissingContent); 273 | } 274 | match errors.len() { 275 | 0 => None, 276 | _ => Some(errors), 277 | } 278 | } 279 | } 280 | } 281 | } 282 | 283 | impl FromStr for JsonApiDocument { 284 | type Err = Error; 285 | 286 | /// Instantiate from string 287 | /// 288 | /// ``` 289 | /// use jsonapi::api::JsonApiDocument; 290 | /// use std::str::FromStr; 291 | /// 292 | /// let serialized = r#"{ 293 | /// "data" : [ 294 | /// { "id":"1", "type":"post", "attributes":{}, "relationships":{}, "links" :{} }, 295 | /// { "id":"2", "type":"post", "attributes":{}, "relationships":{}, "links" :{} }, 296 | /// { "id":"3", "type":"post", "attributes":{}, "relationships":{}, "links" :{} } 297 | /// ] 298 | /// }"#; 299 | /// let doc = JsonApiDocument::from_str(&serialized); 300 | /// assert_eq!(doc.is_ok(), true); 301 | /// ``` 302 | fn from_str(s: &str) -> Result { 303 | serde_json::from_str(s).chain_err(|| "Error parsing Document") 304 | } 305 | } 306 | 307 | impl Resource { 308 | pub fn get_relationship(&self, name: &str) -> Option<&Relationship> { 309 | match self.relationships { 310 | None => None, 311 | Some(ref relationships) => { 312 | match relationships.get(name) { 313 | None => None, 314 | Some(rel) => Some(rel), 315 | } 316 | } 317 | } 318 | } 319 | 320 | /// Get an attribute `JsonApiValue` 321 | /// 322 | /// ``` 323 | /// use jsonapi::api::Resource; 324 | /// use std::str::FromStr; 325 | /// 326 | /// let serialized = r#"{ 327 | /// "id":"1", 328 | /// "type":"post", 329 | /// "attributes":{ 330 | /// "title": "Rails is Omakase", 331 | /// "likes": 250 332 | /// }, 333 | /// "relationships":{}, 334 | /// "links" :{} 335 | /// }"#; 336 | /// 337 | /// match Resource::from_str(&serialized) { 338 | /// Err(_)=> assert!(false), 339 | /// Ok(resource)=> { 340 | /// match resource.get_attribute("title") { 341 | /// None => assert!(false), 342 | /// Some(attr) => { 343 | /// match attr.as_str() { 344 | /// None => assert!(false), 345 | /// Some(s) => { 346 | /// assert_eq!(s, "Rails is Omakase"); 347 | /// } 348 | /// } 349 | /// } 350 | /// } 351 | /// } 352 | /// } 353 | pub fn get_attribute(&self, name: &str) -> Option<&JsonApiValue> { 354 | match self.attributes.get(name) { 355 | None => None, 356 | Some(val) => Some(val), 357 | } 358 | } 359 | 360 | pub fn diff(&self, other: Resource) -> std::result::Result { 361 | if self._type != other._type { 362 | Err(DiffPatchError::IncompatibleTypes( 363 | self._type.clone(), 364 | other._type.clone(), 365 | )) 366 | } else { 367 | 368 | let mut self_keys: Vec = 369 | self.attributes.iter().map(|(key, _)| key.clone()).collect(); 370 | 371 | self_keys.sort(); 372 | 373 | let mut other_keys: Vec = other 374 | .attributes 375 | .iter() 376 | .map(|(key, _)| key.clone()) 377 | .collect(); 378 | 379 | other_keys.sort(); 380 | 381 | let matching = self_keys 382 | .iter() 383 | .zip(other_keys.iter()) 384 | .filter(|&(a, b)| a == b) 385 | .count(); 386 | 387 | if matching != self_keys.len() { 388 | Err(DiffPatchError::DifferentAttributeKeys) 389 | } else { 390 | let mut patchset = PatchSet::new_for(self); 391 | 392 | for (attr, self_value) in &self.attributes { 393 | match other.attributes.get(attr) { 394 | None => { 395 | error!( 396 | "Resource::diff unable to find attribute {:?} in {:?}", 397 | attr, 398 | other 399 | ); 400 | } 401 | Some(other_value) => { 402 | if self_value != other_value { 403 | patchset.push(Patch { 404 | patch_type: PatchType::Attribute, 405 | subject: attr.clone(), 406 | previous: self_value.clone(), 407 | next: other_value.clone(), 408 | }); 409 | } 410 | } 411 | } 412 | 413 | } 414 | 415 | Ok(patchset) 416 | } 417 | } 418 | } 419 | 420 | pub fn patch(&mut self, patchset: PatchSet) -> Result { 421 | let mut res = self.clone(); 422 | for patch in &patchset.patches { 423 | res.attributes.insert( 424 | patch.subject.clone(), 425 | patch.next.clone(), 426 | ); 427 | } 428 | Ok(res) 429 | } 430 | } 431 | 432 | impl FromStr for Resource { 433 | type Err = Error; 434 | 435 | /// Instantiate from string 436 | /// 437 | /// ``` 438 | /// use jsonapi::api::Resource; 439 | /// use std::str::FromStr; 440 | /// 441 | /// let serialized = r#"{ 442 | /// "id":"1", 443 | /// "type":"post", 444 | /// "attributes":{ 445 | /// "title": "Rails is Omakase", 446 | /// "likes": 250 447 | /// }, 448 | /// "relationships":{}, 449 | /// "links" :{} 450 | /// }"#; 451 | /// 452 | /// let data = Resource::from_str(&serialized); 453 | /// assert_eq!(data.is_ok(), true); 454 | /// ``` 455 | fn from_str(s: &str) -> Result { 456 | serde_json::from_str(s).chain_err(|| "Error parsing resource") 457 | } 458 | } 459 | 460 | 461 | impl Relationship { 462 | pub fn as_id(&self) -> std::result::Result, RelationshipAssumptionError> { 463 | match self.data { 464 | Some(IdentifierData::None) => Ok(None), 465 | Some(IdentifierData::Multiple(_)) => Err(RelationshipAssumptionError::RelationshipIsAList), 466 | Some(IdentifierData::Single(ref data)) => Ok(Some(&data.id)), 467 | None => Ok(None), 468 | } 469 | } 470 | 471 | pub fn as_ids(&self) -> std::result::Result, RelationshipAssumptionError> { 472 | match self.data { 473 | Some(IdentifierData::None) => Ok(None), 474 | Some(IdentifierData::Single(_)) => Err(RelationshipAssumptionError::RelationshipIsNotAList), 475 | Some(IdentifierData::Multiple(ref data)) => Ok(Some(data.iter().map(|x| &x.id).collect())), 476 | None => Ok(None), 477 | } 478 | } 479 | } 480 | 481 | /// Enum to describe top-level JSON:API specification violations 482 | #[derive(Debug, Clone, PartialEq, Copy)] 483 | pub enum DocumentValidationError { 484 | IncludedWithoutData, 485 | MissingContent, 486 | } 487 | 488 | #[derive(Debug, Clone, PartialEq, Copy)] 489 | pub enum JsonApiDataError { 490 | AttributeNotFound, 491 | IncompatibleAttributeType, 492 | } 493 | 494 | #[derive(Debug, Clone, PartialEq, Copy)] 495 | pub enum RelationshipAssumptionError { 496 | RelationshipIsAList, 497 | RelationshipIsNotAList, 498 | } 499 | 500 | #[derive(Debug, Clone, PartialEq)] 501 | pub enum DiffPatchError { 502 | IncompatibleTypes(String, String), 503 | DifferentAttributeKeys, 504 | NonExistentProperty(String), 505 | IncorrectPropertyValue(String), 506 | } 507 | 508 | #[derive(Debug, Clone, PartialEq, Copy)] 509 | pub enum PatchType { 510 | Relationship, 511 | Attribute, 512 | } 513 | -------------------------------------------------------------------------------- /tests/api_test.rs: -------------------------------------------------------------------------------- 1 | //! The purpose of these tests is to validate compliance with the JSONAPI 2 | //! specification and to ensure that this crate reads documents properly 3 | extern crate jsonapi; 4 | extern crate serde_json; 5 | extern crate env_logger; 6 | 7 | use jsonapi::api::*; 8 | 9 | mod helper; 10 | use crate::helper::read_json_file; 11 | 12 | #[test] 13 | fn it_works() { 14 | let _ = env_logger::try_init(); 15 | let resource = Resource { 16 | _type: "test".into(), 17 | id: "123".into(), 18 | attributes: ResourceAttributes::new(), 19 | relationships: Some(Relationships::new()), 20 | links: None, 21 | meta: Some(Meta::new()), 22 | }; 23 | 24 | assert_eq!(resource.id, "123"); 25 | 26 | let serialized = serde_json::to_string(&resource).unwrap(); 27 | let deserialized: Resource = serde_json::from_str(&serialized).unwrap(); 28 | 29 | assert_eq!(deserialized.id, resource.id); 30 | 31 | let jsonapidocument = JsonApiDocument::Data ( 32 | DocumentData { 33 | data: Some(PrimaryData::None), 34 | ..Default::default() 35 | } 36 | ); 37 | 38 | assert_eq!(jsonapidocument.is_valid(), true); 39 | 40 | } 41 | 42 | #[test] 43 | fn jsonapi_document_can_be_valid() { 44 | let _ = env_logger::try_init(); 45 | let resource = Resource { 46 | _type: "test".into(), 47 | id: "123".into(), 48 | attributes: ResourceAttributes::new(), 49 | relationships: Some(Relationships::new()), 50 | links: None, 51 | meta: Some(Meta::new()), 52 | }; 53 | 54 | let jsonapi_document_with_data = JsonApiDocument::Data ( 55 | DocumentData { 56 | data: Some(PrimaryData::Single(Box::new(resource))), 57 | ..Default::default() 58 | } 59 | ); 60 | 61 | assert_eq!(jsonapi_document_with_data.is_valid(), true); 62 | } 63 | 64 | #[test] 65 | fn jsonapi_document_invalid_errors() { 66 | let _ = env_logger::try_init(); 67 | 68 | let included_resource = Resource { 69 | _type: "test".into(), 70 | id: "123".into(), 71 | attributes: ResourceAttributes::new(), 72 | relationships: Some(Relationships::new()), 73 | links: None, 74 | meta: Some(Meta::new()), 75 | }; 76 | 77 | let no_content_document = JsonApiDocument::Data ( 78 | DocumentData { 79 | data: None, 80 | ..Default::default() 81 | } 82 | ); 83 | 84 | match no_content_document.validate() { 85 | None => assert!(false), 86 | Some(errors) => { 87 | assert!(errors.contains(&DocumentValidationError::MissingContent)); 88 | } 89 | } 90 | 91 | let null_data_content_document = JsonApiDocument::Data ( 92 | DocumentData { 93 | data: Some(PrimaryData::None), 94 | ..Default::default() 95 | } 96 | ); 97 | 98 | match null_data_content_document.validate() { 99 | None => assert!(true), 100 | Some(_) => assert!(false), 101 | } 102 | 103 | let included_without_data_document = JsonApiDocument::Data ( 104 | DocumentData { 105 | included: Some(vec![included_resource]), 106 | ..Default::default() 107 | } 108 | ); 109 | 110 | match included_without_data_document.validate() { 111 | None => assert!(false), 112 | Some(errors) => { 113 | assert!(errors.contains( 114 | &DocumentValidationError::IncludedWithoutData, 115 | )); 116 | } 117 | } 118 | } 119 | 120 | #[test] 121 | fn error_from_json_string() { 122 | let _ = env_logger::try_init(); 123 | 124 | let serialized = r#" 125 | {"id":"1", "links" : {}, "status" : "unknown", "code" : "code1", "title" : "error-title", "detail": "error-detail"} 126 | "#; 127 | let error: Result = serde_json::from_str(serialized); 128 | assert_eq!(error.is_ok(), true); 129 | match error { 130 | Ok(jsonapierror) => { 131 | match jsonapierror.id { 132 | Some(id) => assert_eq!(id, "1"), 133 | None => assert!(false), 134 | } 135 | } 136 | Err(_) => assert!(false), 137 | } 138 | } 139 | 140 | #[test] 141 | fn single_resource_from_json_string() { 142 | let _ = env_logger::try_init(); 143 | let serialized = 144 | r#"{ "id" :"1", "type" : "post", "attributes" : {}, "relationships" : {}, "links" : {} }"#; 145 | let data: Result = serde_json::from_str(serialized); 146 | assert_eq!(data.is_ok(), true); 147 | } 148 | 149 | #[test] 150 | fn multiple_resource_from_json_string() { 151 | let _ = env_logger::try_init(); 152 | let serialized = r#"[ 153 | { "id" :"1", "type" : "post", "attributes" : {}, "relationships" : {}, "links" : {} }, 154 | { "id" :"2", "type" : "post", "attributes" : {}, "relationships" : {}, "links" : {} }, 155 | { "id" :"3", "type" : "post", "attributes" : {}, "relationships" : {}, "links" : {} } 156 | ]"#; 157 | let data: Result = serde_json::from_str(serialized); 158 | assert_eq!(data.is_ok(), true); 159 | } 160 | 161 | #[test] 162 | fn no_data_document_from_json_string() { 163 | let _ = env_logger::try_init(); 164 | let serialized = r#"{ 165 | "data" : null 166 | }"#; 167 | let data: Result = serde_json::from_str(serialized); 168 | assert_eq!(data.is_ok(), true); 169 | } 170 | 171 | #[test] 172 | fn single_data_document_from_json_string() { 173 | let _ = env_logger::try_init(); 174 | let serialized = r#"{ 175 | "data" : { 176 | "id" :"1", "type" : "post", "attributes" : {}, "relationships" : {}, "links" : {} 177 | } 178 | }"#; 179 | let data: Result = serde_json::from_str(serialized); 180 | assert_eq!(data.is_ok(), true); 181 | } 182 | 183 | #[test] 184 | fn multiple_data_document_from_json_string() { 185 | let _ = env_logger::try_init(); 186 | let serialized = r#"{ 187 | "data" : [ 188 | { "id" :"1", "type" : "post", "attributes" : {}, "relationships" : {}, "links" : {} }, 189 | { "id" :"2", "type" : "post", "attributes" : {}, "relationships" : {}, "links" : {} }, 190 | { "id" :"3", "type" : "post", "attributes" : {}, "relationships" : {}, "links" : {} } 191 | ] 192 | }"#; 193 | let data: Result = serde_json::from_str(serialized); 194 | assert_eq!(data.is_ok(), true); 195 | } 196 | 197 | #[test] 198 | fn api_document_from_json_file() { 199 | let _ = env_logger::try_init(); 200 | 201 | let s = crate::read_json_file("data/results.json"); 202 | let data: Result = serde_json::from_str(&s); 203 | 204 | match data { 205 | Ok(res) => { 206 | match res { 207 | JsonApiDocument::Error(_x) => assert!(false), 208 | JsonApiDocument::Data(x) => { 209 | match x.data { 210 | Some(PrimaryData::Multiple(arr)) => { 211 | assert_eq!(arr.len(), 1); 212 | } 213 | Some(PrimaryData::Single(_)) => { 214 | println!( 215 | "api_document_from_json_file : Expected one Resource in a vector, \ 216 | not a direct Resource" 217 | ); 218 | assert!(false); 219 | } 220 | Some(PrimaryData::None) => { 221 | println!("api_document_from_json_file : Expected one Resource in a vector"); 222 | assert!(false); 223 | } 224 | None => assert!(false), 225 | } 226 | } 227 | } 228 | } 229 | Err(err) => { 230 | println!("api_document_from_json_file : Error: {:?}", err); 231 | assert!(false); 232 | } 233 | } 234 | } 235 | 236 | #[test] 237 | fn api_document_collection_from_json_file() { 238 | let _ = env_logger::try_init(); 239 | 240 | let s = crate::read_json_file("data/collection.json"); 241 | let data: Result = serde_json::from_str(&s); 242 | 243 | match data { 244 | Ok(x) => { 245 | match x { 246 | JsonApiDocument::Error(_) => assert!(false), 247 | JsonApiDocument::Data(res) => { 248 | match res.data { 249 | Some(PrimaryData::Multiple(arr)) => { 250 | assert_eq!(arr.len(), 1); 251 | } 252 | Some(PrimaryData::Single(_)) => { 253 | println!( 254 | "api_document_collection_from_json_file : Expected one Resource in \ 255 | a vector, not a direct Resource" 256 | ); 257 | assert!(false); 258 | } 259 | Some(PrimaryData::None) => { 260 | println!( 261 | "api_document_collection_from_json_file : Expected one Resource in \ 262 | a vector" 263 | ); 264 | assert!(false); 265 | } 266 | None => assert!(false), 267 | } 268 | 269 | match res.included { 270 | Some(arr) => { 271 | assert_eq!(arr.len(), 3); 272 | assert_eq!(arr[0].id, "9"); 273 | assert_eq!(arr[1].id, "5"); 274 | assert_eq!(arr[2].id, "12"); 275 | } 276 | None => { 277 | println!( 278 | "api_document_collection_from_json_file : Expected three Resources \ 279 | in 'included' in a vector" 280 | ); 281 | assert!(false); 282 | } 283 | } 284 | 285 | match res.links { 286 | Some(links) => { 287 | assert_eq!(links.len(), 3); 288 | } 289 | None => { 290 | println!("api_document_collection_from_json_file : expected links"); 291 | assert!(false); 292 | } 293 | } 294 | } 295 | } 296 | } 297 | Err(err) => { 298 | println!("api_document_collection_from_json_file : Error: {:?}", err); 299 | assert!(false); 300 | } 301 | } 302 | } 303 | 304 | // TODO - naming of this test and the test file should be more clear 305 | #[test] 306 | fn can_deserialize_jsonapi_example_resource_001() { 307 | let _ = env_logger::try_init(); 308 | let s = crate::read_json_file("data/resource_001.json"); 309 | let data: Result = serde_json::from_str(&s); 310 | assert!(data.is_ok()); 311 | } 312 | 313 | // TODO - naming of this test and the test file should be more clear 314 | #[test] 315 | fn can_deserialize_jsonapi_example_resource_002() { 316 | let _ = env_logger::try_init(); 317 | let s = crate::read_json_file("data/resource_002.json"); 318 | let data: Result = serde_json::from_str(&s); 319 | assert!(data.is_ok()); 320 | } 321 | 322 | // TODO - naming of this test and the test file should be more clear 323 | #[test] 324 | fn can_deserialize_jsonapi_example_resource_003() { 325 | let _ = env_logger::try_init(); 326 | let s = crate::read_json_file("data/resource_003.json"); 327 | let data: Result = serde_json::from_str(&s); 328 | assert!(data.is_ok()); 329 | } 330 | 331 | // TODO - naming of this test and the test file should be more clear 332 | #[test] 333 | fn can_deserialize_jsonapi_example_resource_004() { 334 | let _ = env_logger::try_init(); 335 | let s = ::read_json_file("data/resource_004.json"); 336 | let data: Result = serde_json::from_str(&s); 337 | assert!(data.is_ok()); 338 | } 339 | 340 | // TODO - naming of this test and the test file should be more clear 341 | #[test] 342 | fn can_deserialize_jsonapi_example_compound_document() { 343 | let _ = env_logger::try_init(); 344 | let s = crate::read_json_file("data/compound_document.json"); 345 | let data: Result = serde_json::from_str(&s); 346 | assert!(data.is_ok()); 347 | } 348 | 349 | // TODO - naming of this test and the test file should be more clear 350 | #[test] 351 | fn can_deserialize_jsonapi_example_links_001() { 352 | let _ = env_logger::try_init(); 353 | let s = crate::read_json_file("data/links_001.json"); 354 | let data: Result = serde_json::from_str(&s); 355 | assert!(data.is_ok()); 356 | } 357 | 358 | // TODO - naming of this test and the test file should be more clear 359 | #[test] 360 | fn can_deserialize_jsonapi_example_links_002() { 361 | let _ = env_logger::try_init(); 362 | let s = crate::read_json_file("data/links_002.json"); 363 | let data: Result = serde_json::from_str(&s); 364 | assert!(data.is_ok()); 365 | } 366 | 367 | // TODO - naming of this test and the test file should be more clear 368 | #[test] 369 | fn can_deserialize_jsonapi_example_jsonapi_info() { 370 | let _ = env_logger::try_init(); 371 | let s = crate::read_json_file("data/jsonapi_info_001.json"); 372 | let data: Result = serde_json::from_str(&s); 373 | assert!(data.is_ok()); 374 | } 375 | 376 | #[test] 377 | fn can_get_attribute() { 378 | let _ = env_logger::try_init(); 379 | let s = crate::read_json_file("data/resource_all_attributes.json"); 380 | let data: Result = serde_json::from_str(&s); 381 | match data { 382 | Err(_) => assert!(false), 383 | Ok(res) => { 384 | match res.get_attribute("likes") { 385 | None => assert!(false), 386 | Some(val) => { 387 | match val.as_i64() { 388 | None => assert!(false), 389 | Some(num) => { 390 | let x: i64 = 250; 391 | assert_eq!(num, x); 392 | } 393 | } 394 | } 395 | } 396 | 397 | match res.get_attribute("title") { 398 | None => assert!(false), 399 | Some(val) => { 400 | match val.as_str() { 401 | None => assert!(false), 402 | Some(s) => { 403 | assert_eq!(s, "Rails is Omakase"); 404 | } 405 | } 406 | } 407 | } 408 | 409 | match res.get_attribute("published") { 410 | None => assert!(false), 411 | Some(val) => { 412 | match val.as_bool() { 413 | None => assert!(false), 414 | Some(b) => { 415 | assert_eq!(b, true); 416 | } 417 | } 418 | } 419 | } 420 | 421 | match res.get_attribute("tags") { 422 | None => assert!(false), 423 | Some(val) => { 424 | match val.as_array() { 425 | None => assert!(false), 426 | Some(arr) => { 427 | assert_eq!(arr[0], "rails"); 428 | assert_eq!(arr[1], "news"); 429 | } 430 | } 431 | } 432 | } 433 | 434 | } 435 | } 436 | } 437 | 438 | #[test] 439 | fn can_diff_resource() { 440 | let _ = env_logger::try_init(); 441 | let s1 = crate::read_json_file("data/resource_post_001.json"); 442 | let s2 = crate::read_json_file("data/resource_post_002.json"); 443 | 444 | let data1: Result = serde_json::from_str(&s1); 445 | let data2: Result = serde_json::from_str(&s2); 446 | 447 | match data1 { 448 | Err(_) => assert!(false), 449 | Ok(res1) => { 450 | // So far so good 451 | match data2 { 452 | Err(_) => assert!(false), 453 | Ok(res2) => { 454 | match res1.diff(res2) { 455 | Err(_) => { 456 | assert!(false); 457 | } 458 | Ok(patchset) => { 459 | println!("can_diff_resource: PatchSet is {:?}", patchset); 460 | assert_eq!(patchset.patches.len(), 5); 461 | } 462 | } 463 | } 464 | } 465 | } 466 | } 467 | } 468 | 469 | #[test] 470 | fn it_omits_empty_document_and_primary_data_keys() { 471 | let _ = env_logger::try_init(); 472 | let resource = Resource { 473 | _type: "test".into(), 474 | id: "123".into(), 475 | attributes: ResourceAttributes::new(), 476 | ..Default::default() 477 | }; 478 | let doc = JsonApiDocument::Data ( 479 | DocumentData { 480 | data: Some(PrimaryData::Single(Box::new(resource))), 481 | ..Default::default() 482 | } 483 | ); 484 | 485 | assert_eq!( 486 | serde_json::to_string(&doc).unwrap(), 487 | r#"{"data":{"type":"test","id":"123","attributes":{}}}"# 488 | ); 489 | } 490 | 491 | #[test] 492 | fn it_does_not_omit_an_empty_primary_data() { 493 | let doc = JsonApiDocument::Data ( 494 | DocumentData { 495 | data: Some(PrimaryData::None), 496 | ..Default::default() 497 | } 498 | ); 499 | 500 | assert_eq!(serde_json::to_string(&doc).unwrap(), r#"{"data":null}"#); 501 | } 502 | 503 | #[test] 504 | fn it_omits_empty_error_keys() { 505 | let error = JsonApiError { 506 | id: Some("error_id".to_string()), 507 | ..Default::default() 508 | }; 509 | let doc = JsonApiDocument::Error ( 510 | DocumentError { 511 | errors: vec![error], 512 | ..Default::default() 513 | } 514 | ); 515 | assert_eq!( 516 | serde_json::to_string(&doc).unwrap(), 517 | r#"{"errors":[{"id":"error_id"}]}"# 518 | ); 519 | } 520 | 521 | #[test] 522 | fn it_allows_for_optional_attributes() { 523 | let _ = env_logger::try_init(); 524 | let serialized = r#"{ 525 | "data" : { 526 | "id" :"1", "type" : "post", "relationships" : {}, "links" : {} 527 | } 528 | }"#; 529 | let data: Result = serde_json::from_str(serialized); 530 | assert_eq!(data.is_ok(), true); 531 | } 532 | 533 | #[test] 534 | fn it_validates_partialeq_when_compariing_documents() { 535 | let _ = env_logger::try_init(); 536 | let document1 = r#" 537 | { 538 | "data": { 539 | "type": "posts", 540 | "id": "1", 541 | "attributes": { 542 | "title": "Rails is Omakase" 543 | }, 544 | "relationships": { 545 | "author": { 546 | "links": { 547 | "self": "/posts/1/relationships/author", 548 | "related": "/posts/1/author" 549 | }, 550 | "data": { 551 | "type": "people", 552 | "id": "9" 553 | } 554 | }, 555 | "tags": { 556 | "links": { 557 | "self": "/posts/1/relationships/tags", 558 | "related": "/posts/1/tags" 559 | }, 560 | "data": { 561 | "type": "tags", 562 | "id": "99" 563 | } 564 | } 565 | }, 566 | "links": { 567 | "self": "http://example.com/posts/1" 568 | } 569 | } 570 | }"#; 571 | 572 | let document2 = r#" 573 | { 574 | "data": { 575 | "relationships": { 576 | "tags": { 577 | "data": { 578 | "type": "tags", 579 | "id": "99" 580 | }, 581 | "links": { 582 | "self": "/posts/1/relationships/tags", 583 | "related": "/posts/1/tags" 584 | } 585 | }, 586 | "author": { 587 | "links": { 588 | "self": "/posts/1/relationships/author", 589 | "related": "/posts/1/author" 590 | }, 591 | "data": { 592 | "type": "people", 593 | "id": "9" 594 | } 595 | } 596 | }, 597 | "links": { 598 | "self": "http://example.com/posts/1" 599 | }, 600 | "attributes": { 601 | "title": "Rails is Omakase" 602 | }, 603 | "type": "posts", 604 | "id": "1" 605 | } 606 | }"#; 607 | let doc1: Result = serde_json::from_str(document1); 608 | let doc2: Result = serde_json::from_str(document2); 609 | assert_eq!(doc1.is_ok(), true); 610 | assert_eq!(doc2.is_ok(), true); 611 | assert!(doc1.unwrap() == doc2.unwrap()); 612 | } 613 | --------------------------------------------------------------------------------