├── .codeclimate.yml ├── .coveralls.yml ├── .editorconfig ├── .eslintrc ├── .flowconfig ├── .gitignore ├── .jshintrc ├── .npmignore ├── .travis.yml ├── CHANGELOG.md ├── CONTRIBUTING.md ├── LICENSE ├── README.md ├── documentation ├── client.md ├── error.md ├── resource-interning.md └── resource.md ├── karma.local.conf.js ├── karma.saucelabs.conf.js ├── lib ├── Client.js ├── Resource.js ├── Transport.js └── resourceCache.js ├── package.json └── test ├── _testServer.js ├── authentication.js ├── promises.js ├── read.js └── write.js /.codeclimate.yml: -------------------------------------------------------------------------------- 1 | languages: 2 | JavaScript: true 3 | exclude_paths: 4 | - "dist/*.js" 5 | -------------------------------------------------------------------------------- /.coveralls.yml: -------------------------------------------------------------------------------- 1 | service_name: travis-ci 2 | repo_token: xBt1HoQbbXn6Buk0fEXR5zv6kQS1JOzim 3 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # editorconfig.org 2 | root = true 3 | 4 | [*] 5 | end_of_line = lf 6 | charset = utf-8 7 | indent_style = space 8 | indent_size = 2 9 | insert_final_newline = true 10 | trim_trailing_whitespace = true -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "eslint:recommended", 3 | "globals": { 4 | }, 5 | "env": { 6 | "node": true, 7 | "mocha": true 8 | }, 9 | "rules": { 10 | "no-underscore-dangle": [ 0 ], 11 | "curly": [2, "multi-line"], 12 | "no-console": [ 0 ] 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /.flowconfig: -------------------------------------------------------------------------------- 1 | [include] 2 | ./lib/ 3 | 4 | [ignore] 5 | .*node_modules/phantomjs/node_modules/npmconf/.* 6 | 7 | [options] 8 | module.ignore_non_literal_requires=true 9 | munge_underscores=true 10 | suppress_comment= \\(.\\|\n\\)*\\$FlowFixMe 11 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | complexity 2 | coverage.html 3 | dist.js 4 | dist/ 5 | node_modules 6 | npm-debug.log 7 | .DS_Store 8 | -------------------------------------------------------------------------------- /.jshintrc: -------------------------------------------------------------------------------- 1 | { 2 | "node": true 3 | } 4 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | .* 2 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | node_js: 3 | - 4 4 | - 6 5 | - 7 6 | addons: 7 | firefox: "latest" 8 | before_script: 9 | - export DISPLAY=:99.0 10 | - sh -e /etc/init.d/xvfb start 11 | script: npm run ci 12 | notifications: 13 | email: false 14 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | - 2017-07-14 - v1.0.0 2 | - 2017-07-14 - Publish to NPM as scoped package `@holidayextras/jsonapi-client`. 3 | - 2017-07-14 - v0.8.1 4 | - 2016-07-14 - Test with latest `jsonapi-server` and update dependencies. 5 | - 2016-07-19 - v0.8.0 6 | - 2016-07-19 - Enable options in fetch requests 7 | - 2016-07-13 - v0.7.0 8 | - 2016-07-12 - Send id and type on PATCH requests 9 | - 2016-07-12 - Add flow and jscpd to Travis 10 | - 2016-07-12 - Use qs instead of perry for url formatting 11 | - 2016-07-12 - Don't send null id when creating a resource 12 | - 2016-06-22 - v0.6.0 Updating all dependencies 13 | - 2016-06-08 - v0.5.1 Fixes retrieval of many-to-one relationships 14 | - 2016-04-22 - v0.5.0 Safer error extraction, relationship deduplication bug, adding relationships to new resources 15 | - 2016-03-10 - v0.4.3 Error handling when fetching related resources that don't exist 16 | - 2016-02-10 - v0.4.2 Fix adding/removing resources to/from relationships 17 | - 2016-02-08 - v0.4.1 Handling resources with no relationships 18 | - 2015-12-17 - v0.4.0 Better handling of non-json:api payloads 19 | - 2015-12-17 - v0.3.0 Fixes to enable usage in web browsers 20 | - 2015-12-17 - v0.2.0 Better validation when changing relations 21 | - 2015-12-17 - v0.1.0 Adding authentication 22 | - 2015-11-07 - v0.0.1 Initial release 23 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | ### Contributing 2 | 3 | We'd love for you to contribute to our source code and to make this project even better than it is today! 4 | 5 | #### Submitting an Issue 6 | 7 | If you've found an area whereby this project does not do what you think it should, then you can help us by submitting an issue to this repository. The more specific you can be, the better. 8 | 9 | #### Submitting a Pull Request 10 | 11 | If the bug/feature you are working on is in any way controversial, or makes assumptions about parts of the json:api spec that could be misconstrued, I'd encourage you to first open an issue in this repository to enable a discussion to take place before you start changing things. Nobody likes to waste their own time, or anybody elses! 12 | 13 | Every pull request that changes code in this project needs to have some form of regression test with it. That might take the shape of some additional asserts in key places within existing tests, it might involve new tests. We write tests to ensure features don't get lost - protect your feature by writing good tests! 14 | 15 | To verify all the code changes pass our style guidelines: 16 | ``` 17 | npm run lint 18 | ``` 19 | 20 | To verify everything still behaves as expected: 21 | ``` 22 | npm test 23 | ``` 24 | 25 | To check the code coverage is still great: 26 | ``` 27 | npm run coverage 28 | google-chrome ./coverage.html 29 | ``` 30 | 31 | To see code complexity statistics: 32 | ``` 33 | npm run complexity 34 | google-chrome ./complexity/index.html 35 | ``` 36 | 37 | If all of the above comes up good, go ahead and put in a pull request! 38 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright 2015 Oliver Rumbelow. All rights reserved. 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy 4 | of this software and associated documentation files (the "Software"), to 5 | deal in the Software without restriction, including without limitation the 6 | rights to use, copy, modify, merge, publish, distribute, sublicense, and/or 7 | sell copies of the Software, and to permit persons to whom the Software is 8 | furnished to do so, subject to the following conditions: 9 | 10 | The above copyright notice and this permission notice shall be included in 11 | all copies or substantial portions of the Software. 12 | 13 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING 18 | FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS 19 | IN THE SOFTWARE. 20 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![Coverage Status](https://coveralls.io/repos/holidayextras/jsonapi-client/badge.svg?branch=master&service=github)](https://coveralls.io/github/holidayextras/jsonapi-client?branch=master) 2 | [![Build Status](https://travis-ci.org/holidayextras/jsonapi-client.svg?branch=master)](https://travis-ci.org/holidayextras/jsonapi-client) 3 | [![Code Climate](https://codeclimate.com/github/holidayextras/jsonapi-client/badges/gpa.svg)](https://codeclimate.com/github/holidayextras/jsonapi-client) 4 | [![Codacy Badge](https://api.codacy.com/project/badge/grade/3998acb1f4c6433a93a688e9523e37e0)](https://www.codacy.com/app/oliver-rumbelow-github/jsonapi-client) 5 | [![Dependencies Status](https://david-dm.org/holidayextras/jsonapi-client.svg)](https://david-dm.org/holidayextras/jsonapi-client) 6 | 7 | # jsonapi-client 8 | 9 | A javascript module designed to make it really easy to consume a `json:api` service. 10 | 11 | ** ⚠ !! THIS PROJECT IS IN NPM AS `@holidayextras/jsonapi-client` !! ** 12 | 13 | ``` 14 | $ npm install --save @holidayextras/jsonapi-client 15 | ``` 16 | 17 | note: this project requires a Node.js version of at least `4.5.0`. 18 | 19 | ### Motivation / Justification / Rationale 20 | 21 | Consuming a json:api service from within Javascript is a non-trivial affair. Setting up a transport mechanism, authentication, making requests to standardised HTTP routes, error handling, pagination and expanding an inclusion tree... All of these things represent barriers to consuming an API. This module takes away all the hassle and lets developers focus on interacting with a rich API without wasting developer time focusing on anything other than shipping valuable features. 22 | 23 | This module is tested against the example json:api server provided by [jsonapi-server](https://github.com/holidayextras/jsonapi-server). 24 | 25 | ### Full documentation 26 | 27 | - [Using the Client](documentation/client.md) 28 | - [Interacting with Resources](documentation/resource.md) 29 | - [Resource Interning](documentation/resource-interning.md) 30 | 31 | ### The tl;dr 32 | 33 | #### In a browser 34 | ```html 35 | 36 | 43 | ``` 44 | 45 | #### Creating a new Client 46 | ```javascript 47 | var JsonapiClient = require("jsonapi-client"); 48 | var client = new JsonapiClient("http://localhost:16006/rest", { 49 | header: { 50 | authToken: "2ad1d6f7-e1d0-480d-86b2-dfad8af4a5b3" 51 | } 52 | }); 53 | ``` 54 | 55 | #### Creating a new Resource 56 | ```javascript 57 | var article = client.create("articles"); 58 | article.set("title", "foobar"); 59 | article.sync(function(err) { 60 | console.log("Resource created"); 61 | }); 62 | ``` 63 | 64 | #### Finding Resources 65 | ```javascript 66 | client.find("articles", function(err, resources) { 67 | resources.map(function(resource) { 68 | console.log(resource.toJSON()); 69 | }); 70 | }); 71 | ``` 72 | 73 | #### Getting a specific Resource 74 | ```javascript 75 | client.get("articles", 5, { include: [ "author" ] }, function(err, article) { 76 | console.log(article.toJSONTree()); 77 | }); 78 | ``` 79 | 80 | #### Fetching a Resource's related Resource 81 | ```javascript 82 | article.fetch("author", function(err) { 83 | console.log(article.author.toJSON()); 84 | }); 85 | ``` 86 | 87 | #### Updating a Resource's primary relationships 88 | ```javascript 89 | article.relationships("comments").add(comment); 90 | article.sync(function(err) { 91 | console.log("Resource's relation updated"); 92 | }); 93 | ``` 94 | 95 | #### Deleting a Resource 96 | ```javascript 97 | article.delete(function(err) { 98 | console.log("Resource deleted"); 99 | }); 100 | ``` 101 | 102 | #### A more complex example 103 | ```javascript 104 | }).then(function() { 105 | return client.create("articles") 106 | .set("title", "some fancy booklet") 107 | .set("content", "oh-la-la!") 108 | .relationships("tags").add(someTagResource) 109 | .sync(); 110 | }).then(function(newlyCreatedArticle) { 111 | ``` 112 | -------------------------------------------------------------------------------- /documentation/client.md: -------------------------------------------------------------------------------- 1 | 2 | ## Using the Client 3 | 4 | - [Purpose of the Client](#purpose-of-the-client) 5 | - [Getting Started](#getting-started) 6 | - [Authentication](#authentication) 7 | - [Creating a Resource](#creating-a-resource) 8 | - [Finding Resources](#finding-resources) 9 | - [Getting a Resource](#getting-a-resource) 10 | 11 | #### Purpose of the Client 12 | 13 | 1. To enable consumption of multiple JSON:API services. 14 | 2. To be a factory for Resource objects. 15 | 1. When creating new Resources. 16 | 2. When retrieving Resources from an API. 17 | 18 | #### Getting Started 19 | 20 | First up, if you're working in a browser you'll need to drop the library onto your page: 21 | 22 | ```html 23 | 24 | ``` 25 | 26 | From here on, the implementation (be it in NodeJS or the browser) is the same. 27 | Start by pulling in the library: 28 | 29 | ```javascript 30 | var JsonapiClient = require("jsonapi-client"); 31 | ``` 32 | 33 | Next, we create a new instance of jsonapi-client and tell it where our target API is located: 34 | 35 | ```javascript 36 | var client = new JsonapiClient("http://localhost:16006/rest"); 37 | ``` 38 | 39 | Now we're good to go! 40 | 41 | #### Authentication 42 | 43 | jsonapi-server currently supports two basic forms of authentication - cookies and headers. 44 | 45 | Custom headers: 46 | ```javascript 47 | var client = new JsonapiClient("http://localhost:16006/rest", { 48 | header: { 49 | myHeaderName: "my-header-value" 50 | } 51 | }); 52 | ``` 53 | 54 | Cookie: 55 | ```javascript 56 | var client = new JsonapiClient("http://localhost:16006/rest", { 57 | cookie: { 58 | myCookieName: "my-cookie-value" 59 | } 60 | }); 61 | ``` 62 | 63 | #### Creating a Resource 64 | 65 | Creating a resource begins with a synchronous call to `client.create` - it will immediately return a new (empty) instance of a [Resource](resource.md). Next up is an opportunity to set the attributes on your new resource, for example you'll need to assign values to all mandatory attributes before trying to push it up to the remote API. 66 | 67 | ```javascript 68 | var newResource = client.create("resource-type"); // synchronous 69 | ``` 70 | 71 | Once the newResource is populated and ready to be pushed, it needs to be `sync`d. You can read more about `sync` in the [Resource documentation](resource.md). 72 | 73 | ```javascript 74 | newResource.sync(function(err) { /* accepts a callback */ }); 75 | newResource.sync(); // without a callback, it returns a promise 76 | ``` 77 | 78 | #### Finding Resources 79 | 80 | To search for resources, use `client.find`. The first argument must be the resource-type you want to search for. The second argument is optional, it represents any URL parameters you may want to include - filter, include, sort, etc. The third argument is an optional callback. If no callback is provided, a promise will be returned. 81 | 82 | The function will produce either an [error](error.md) or an array of [Resources](resource.md). 83 | 84 | ```javascript 85 | var optionalParams = { /* url-params go here */ }; 86 | client.find("resource-type", optionalParams, function(err, resources) { }); 87 | client.find("resource-type", optionalParams); // without a callback, it returns a promise 88 | ``` 89 | 90 | #### Getting a Resource 91 | 92 | To find a specific resource, use `client.get`. The first argument must be the resource-type you want to search for. The second argument must be the resource-id of the resource you want to retrieve from the remote API. The third argument is optional, it represents any URL parameters you may want to include - filter, include, sort, etc. The fourth argument is an optional callback. If no callback is provided, a promise will be returned. 93 | 94 | The function will produce either an [error](error.md) or a single [Resource](resource.md). 95 | 96 | ```javascript 97 | var optionalParams = { /* url-params go here */ }; 98 | client.get("resource-type", "resource-id", optionalParams, function(err, resources) { }); 99 | var promise = client.get("resource-type", "resource-id", optionalParams); 100 | ``` 101 | -------------------------------------------------------------------------------- /documentation/error.md: -------------------------------------------------------------------------------- 1 | 2 | ## Errors 3 | 4 | - [Purpose of Errors](#purpose-of-errors) 5 | - [Error properties](#error-properties) 6 | 7 | #### Purpose of Errors 8 | 9 | 1. To represent remote Errors. 10 | 1. To enable graceful fallback. 11 | 1. To conform to Javascript standards. 12 | 13 | #### Error properties 14 | 15 | * `name` - A high level summary of what went wrong. 16 | * `message` - A more detailed message of specifically what went wrong. 17 | * `status` - The HTTP response code, useful for distinguishing between user errors or API bugs. 18 | * `code` - Useful for debugging the remote API, should assist development teams to track down bugs. 19 | 20 | ```javascript 21 | > console.log(someError); 22 | { [Requested resource does not exist: "There is no people with id 5e7b30d5-c8da-4936-9f81-4a6ea1153a5f"] 23 | name: 'Requested resource does not exist', 24 | status: '404', 25 | code: 'ENOTFOUND' } 26 | ``` 27 | -------------------------------------------------------------------------------- /documentation/resource-interning.md: -------------------------------------------------------------------------------- 1 | 2 | ### Resource Interning 3 | 4 | *Resource Interning ONLY applies when running within a web browser!* 5 | 6 | Suppose your application fetches a specific resource from the remote API, lets call that resource A. At some point further down the line, another part of your application does a broad search which results in a list of resources, whereby that list contains A. Resource interning ensures that all local resources representing the same unique remote resource are all the same object. 7 | 8 | Here's an example of it in action: 9 | ```javascript 10 | var person1, person2; 11 | 12 | // Get Mark's record 13 | client.get("people", "d850ea75-4427-4f81-8595-039990aeede5", { }, function(err1, personResource) { 14 | person1 = personResource; 15 | }); 16 | 17 | // Find Mark's record 18 | client.get("people", { filter: { firtsname: "Mark" } }, function(err1, personResource) { 19 | person2 = personResource; 20 | }); 21 | 22 | // Wait for both requests to complete 23 | setTimeout(function() { 24 | // both objects will contain the SAME reference 25 | assert.equal(person1, person2); 26 | }, 1000); 27 | ``` 28 | -------------------------------------------------------------------------------- /documentation/resource.md: -------------------------------------------------------------------------------- 1 | 2 | ### Interacting with Resources 3 | 4 | Foreword: All resource objects are [interned](resource-interning.md). 5 | 6 | - [Examining resource attributes](#examining-resource-attributes) 7 | - [Setting resource attributes](#setting-resource-attributes) 8 | - [Syncing a resource with the remote service](#syncing-a-resource-with-the-remote-service) 9 | - [Flattening a resource](#flattening-a-resource) 10 | - [Debugging nested resources](#debugging-nested-resources) 11 | - [Fetching related resources](#fetching-related-resources) 12 | - [Changing resource relations](#changing-resource-relations) 13 | - [Deleting a resource](#deleting-a-resource) 14 | 15 | #### Examining resource attributes 16 | 17 | Resource attributes should be accessed via `resource.get()`. 18 | 19 | ```javascript 20 | resource.get("attribute-name"); 21 | ``` 22 | 23 | ### Setting resource attributes 24 | 25 | Resource attributes should be modified via `resource.set()`. 26 | 27 | ```javascript 28 | resource.set("attribute-name", "new-value"); 29 | ``` 30 | 31 | ### Syncing a resource with the remote service 32 | 33 | To push either a new resource, or changes to an existing resource, up to the remote API, use `resource.sync()`. 34 | 35 | ```javascript 36 | newPerson.sync(function(err) { /* accepts a callback */ }); 37 | newPerson.sync(); // without a callback, it returns a promise 38 | ``` 39 | 40 | ### Flattening a resource 41 | 42 | Calling `resource.toJSON()` will flatten and merge a Resource's type+id, attributes and relations to give a clean view of the resource. Nested or known related resources will be excluded. 43 | 44 | ```javascript 45 | > photo.toJSON(); 46 | { 47 | id: "aab14844-97e7-401c-98c8-0bd5ec922d93", 48 | type: "photos", 49 | title: "Matrix Code", 50 | url: "http://www.example.com/foobar", 51 | height: 1080, 52 | width: 1920, 53 | photographer: { type: "people", id: "ad3aa89e-9c5b-4ac9-a652-6670f9f27587" } 54 | } 55 | ``` 56 | 57 | ### Debugging nested resources 58 | 59 | Calling `resource.toJSONTree()` will produce an extended version of `resource.toJSON()` whereby known nested resources will be included. 60 | 61 | ```javascript 62 | > client.find("people", { include: "articles", filter: { lastname: "Rumbelow"}}, function(err, people) { 63 | console.log(people[0].toJSONTree()); 64 | }); 65 | { 66 | "id": "cc5cca2e-0dd8-4b95-8cfc-a11230e73116", 67 | "type": "people", 68 | "firstname": "Oli", 69 | "lastname": "Rumbelow", 70 | "email": "oliver.rumbelow@example.com", 71 | "articles": [ 72 | { 73 | "id": "de305d54-75b4-431b-adb2-eb6b9e546014", 74 | "type": "articles", 75 | "title": "NodeJS Best Practices", 76 | "content": "na", 77 | "author": "[Circular]", 78 | "tags": [ 79 | { 80 | "type": "tags", 81 | "id": "7541a4de-4986-4597-81b9-cf31b6762486" 82 | } 83 | ], 84 | "photos": [], 85 | "comments": [ 86 | { 87 | "type": "comments", 88 | "id": "3f1a89c2-eb85-4799-a048-6735db24b7eb" 89 | } 90 | ] 91 | } 92 | ], 93 | "photos": null 94 | } 95 | ``` 96 | 97 | ### Fetching related resources 98 | 99 | To easily retrieve related resources, call `resource.fetch("relation-name")`. This does a couple of things: 100 | 101 | 1. Fetches related resources via the `related` link, as per the JSON:API spec. 102 | 2. Creates instances of Resource for each new resource. 103 | 3. Assigns forward links. 104 | 4. Assigns backward links. 105 | 106 | ```javascript 107 | people.fetch("relation-name", function(err, newResources) { /* accepts a callback */ }); 108 | people.fetch("relation-name"); // without a callback, it returns a promise 109 | 110 | photo.fetch("photographer", function(err, person) { 111 | person instanceof Resource; 112 | photo.photographer == person; 113 | person.photos.indexOf(photo) !== -1; 114 | }); 115 | ``` 116 | 117 | ### Changing resource relations 118 | 119 | A resource's relations can be modified by calling `resource.relationships("relation-name")` and then calling `.add()`, `.remove()` or `.set()` to mutate the relation. 120 | 121 | ```javascript 122 | person.relationships("photos").add(someResource) // synchronous 123 | person.relationships("photos").remove(someResource) // synchronous 124 | person.relationships("photos").set(someResource) // synchronous 125 | ``` 126 | 127 | After mutating the relation, the resource needs to be `.sync()`d to push the changes to the remote API. 128 | 129 | ### Deleting a resource 130 | 131 | To delete a resource from the remote API use `resource.delete()`. Calling delete will remove the id from the local resource, retaining all other resource state. A deleted resource can therefore be re-`sync()`d under a new resource id. 132 | 133 | ```javascript 134 | resource.delete(function(err) { /* accepts a callback */ }); 135 | resource.delete(); // without a callback, it returns a promise 136 | ``` 137 | -------------------------------------------------------------------------------- /karma.local.conf.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | module.exports = function(config) { 4 | config.set({ 5 | frameworks: [ "mocha" ], 6 | files: [ "dist/jsonapi-client-test.js" ], 7 | reporters: [ "spec" ], 8 | plugins: [ "karma-mocha", "karma-firefox-launcher", "karma-spec-reporter" ], 9 | port: 9876, 10 | colors: true, 11 | logLevel: config.LOG_INFO, 12 | autoWatch: false, 13 | browsers: [ "Firefox"/*, "PhantomJS"*/ ], 14 | singleRun: true, 15 | concurrency: 1, 16 | client: { 17 | captureConsole: true, 18 | timeout: 10000, 19 | mocha: { 20 | timeout: 10000 21 | } 22 | } 23 | }); 24 | }; 25 | -------------------------------------------------------------------------------- /karma.saucelabs.conf.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | var customLaunchers = { 4 | win10chrome: { 5 | base: "SauceLabs", 6 | browserName: "chrome", 7 | platform: "Windows 10" 8 | }, 9 | androidChrome: { 10 | base: "SauceLabs", 11 | browserName: "android", 12 | platform: "Linux" 13 | }, 14 | win10firefox: { 15 | base: "SauceLabs", 16 | browserName: "firefox", 17 | platform: "Windows 10" 18 | }, 19 | osxSafari: { 20 | base: "SauceLabs", 21 | browserName: "safari", 22 | platform: "OS X 10.11" 23 | }, 24 | iosSafari: { 25 | base: "SauceLabs", 26 | browserName: "iphone", 27 | platform: "OS X 10.10" 28 | }, 29 | iosSafari92: { 30 | base: "SauceLabs", 31 | browserName: "iphone", 32 | platform: "OS X 10.10", 33 | version: "9.2" 34 | }, 35 | win10ie11: { 36 | base: "SauceLabs", 37 | browserName: "internet explorer", 38 | platform: "Windows 10" 39 | }, 40 | win7ie9: { 41 | base: "SauceLabs", 42 | browserName: "internet explorer", 43 | platform: "Windows 7", 44 | version: "9.0" 45 | } 46 | }; 47 | 48 | module.exports = function(config) { 49 | config.set({ 50 | sauceLabs: { 51 | testName: "jsonapi-client full stack tests" 52 | }, 53 | customLaunchers: customLaunchers, 54 | browsers: Object.keys(customLaunchers), 55 | frameworks: [ "mocha" ], 56 | reporters: [ "spec", "saucelabs" ], 57 | plugins: [ "karma-mocha", "karma-sauce-launcher", "karma-spec-reporter" ], 58 | singleRun: true, 59 | autoWatch: false, 60 | files: [ 61 | "https://cdn.polyfill.io/v2/polyfill.js?features=Promise", 62 | "dist/jsonapi-client-test.js" 63 | ], 64 | port: 9876, 65 | colors: true, 66 | logLevel: config.LOG_INFO, 67 | concurrency: 5, 68 | client: { 69 | captureConsole: true, 70 | timeout: 10000, 71 | mocha: { 72 | timeout: 10000 73 | } 74 | }, 75 | captureTimeout: 300000, 76 | customHeaders: [{ 77 | name: "Access-Control-Allow-Origin", 78 | value: "*" 79 | }, 80 | { 81 | name: "Access-Control-Allow-Methods", 82 | value: "GET, POST, PATCH, DELETE, OPTIONS" 83 | }], 84 | startConnect: true, 85 | connectOptions: { 86 | verbose: false, 87 | verboseDebugging: false 88 | }, 89 | browserNoActivityTimeout: 30000 90 | }); 91 | }; 92 | -------------------------------------------------------------------------------- /lib/Client.js: -------------------------------------------------------------------------------- 1 | /* @flow weak */ 2 | "use strict"; 3 | var Client = module.exports = function(path, auth, options) { 4 | if (!path) { 5 | throw new Error("API path is needed to construct an instanceof jsonapi-client"); 6 | } 7 | auth = auth || { }; 8 | options = options || {}; 9 | 10 | this._construct(path, auth, options); 11 | }; 12 | 13 | if (typeof window !== "undefined") { 14 | window.JsonapiClient = Client; // eslint-disable-line 15 | } 16 | 17 | var Promise = require("promise"); 18 | // Promise.denodeify = function(a) { return a; }; 19 | var Resource = require("./Resource"); 20 | Client.Resource = Resource; 21 | var Transport = require("./Transport"); 22 | var createResourceCache = require("./resourceCache"); 23 | 24 | Client.prototype._construct = function(path, auth, options) { 25 | this._resourceCache = createResourceCache(options); 26 | this._transport = new Transport(path, auth); 27 | }; 28 | 29 | Client.prototype.find = Promise.denodeify(function(type, options, callback) { 30 | var self = this; 31 | 32 | if (typeof options === "function") { 33 | callback = options; 34 | options = { }; 35 | } 36 | 37 | this._transport.search(type, options, function(err, rawResponse, rawResources, rawIncludes) { 38 | if (err) return callback(err); 39 | 40 | var resources = rawResources.map(function(someRawResource) { 41 | return new Resource(someRawResource, self); 42 | }); 43 | if (rawIncludes) { 44 | rawIncludes.forEach(function(someRawResource) { 45 | return new Resource(someRawResource, self); 46 | }); 47 | } 48 | 49 | return callback(null, resources); 50 | }); 51 | }); 52 | 53 | Client.prototype.get = Promise.denodeify(function(type, id, options, callback) { 54 | var self = this; 55 | 56 | if (typeof options === "function") { 57 | callback = options; 58 | options = { }; 59 | } 60 | 61 | this._transport.find(type, id, options, function(err, rawResponse, rawResource, rawIncludes) { 62 | if (err) return callback(err); 63 | 64 | var resource = new Resource(rawResource, self); 65 | if (rawIncludes) { 66 | rawIncludes.forEach(function(someRawResource) { 67 | return new Resource(someRawResource, self); 68 | }); 69 | } 70 | 71 | return callback(null, resource); 72 | }); 73 | }); 74 | 75 | Client.prototype._getRelated = function(resource, relation, options, callback) { 76 | var self = this; 77 | this._transport.fetch(resource, relation, options, function(err, rawResponse, rawResources, rawIncludes) { 78 | if (err) return callback(err); 79 | 80 | if (rawIncludes) { 81 | rawIncludes.forEach(function(someRawResource) { 82 | return new Resource(someRawResource, self); 83 | }); 84 | } 85 | 86 | if (!(rawResources instanceof Array)) { 87 | var rawResource = new Resource(rawResources, self); 88 | return callback(null, rawResource); 89 | } 90 | 91 | var resources = rawResources.map(function(someRawResource) { 92 | return new Resource(someRawResource, self); 93 | }); 94 | 95 | return callback(null, resources); 96 | }); 97 | }; 98 | 99 | Client.prototype.create = function(type, properties) { 100 | var newResource = new Resource({ 101 | id: null, 102 | type: type, 103 | attributes: properties || { }, 104 | relationships: { } 105 | }, this); 106 | return newResource; 107 | }; 108 | 109 | Client.prototype._remoteCreate = function(resource, callback) { 110 | this._transport.create(resource, callback); 111 | }; 112 | 113 | Client.prototype._update = function(resource, callback) { 114 | this._transport.update(resource, callback); 115 | }; 116 | 117 | Client.prototype._delete = function(resource, callback) { 118 | this._transport.delete(resource, callback); 119 | }; 120 | -------------------------------------------------------------------------------- /lib/Resource.js: -------------------------------------------------------------------------------- 1 | /* @flow weak */ 2 | "use strict"; 3 | var Resource = module.exports = function(rawResource, client) { 4 | rawResource = rawResource || { }; 5 | return this._construct(rawResource, client); 6 | }; 7 | 8 | var Promise = require("promise"); 9 | // Promise.denodeify = function(a) { return a; }; 10 | var assign = require("object-assign"); 11 | 12 | function getMeta(obj) { 13 | var metaDefaults = { relation: "primary" }; 14 | return obj.meta || metaDefaults; 15 | } 16 | 17 | Resource.prototype._construct = function(rawResource, client) { 18 | this._raw = rawResource; 19 | this._base = { 20 | id: rawResource.id, 21 | type: rawResource.type 22 | }; 23 | this._client = client; 24 | this._changed = [ ]; 25 | 26 | if (!this._base.id) return this; 27 | 28 | var fromCache = client._resourceCache.get(this); 29 | if (fromCache) return fromCache; 30 | 31 | client._resourceCache.set(this); 32 | return this; 33 | }; 34 | 35 | Resource.prototype._checkIsResource = function(resource) { 36 | if (resource instanceof Resource) { 37 | if (!resource._base.id) throw new Error("Resource has not been created remotely, it can't be assigned to a relationship!"); 38 | return; 39 | } 40 | 41 | 42 | var type = typeof resource; 43 | if (resource.constructor) type = resource.constructor.name; 44 | 45 | throw new Error("Expected Resource, got " + type); 46 | }; 47 | 48 | Resource.prototype._getBase = function() { 49 | return this._base; 50 | }; 51 | 52 | Resource.prototype._getRaw = function() { 53 | return this._raw; 54 | }; 55 | 56 | Resource.prototype._getDelta = function() { 57 | var primaryRelations = { }; 58 | var relationships = this._getRaw().relationships; 59 | Object.keys(relationships).forEach(function(i) { 60 | if (getMeta(relationships[i]).relation === "foreign") return; 61 | primaryRelations[i] = relationships[i]; 62 | }); 63 | 64 | return { 65 | id: this._base.id, 66 | type: this._base.type, 67 | attributes: this._getRaw().attributes, 68 | relationships: primaryRelations 69 | }; 70 | }; 71 | 72 | Resource.prototype._getUid = function() { 73 | return this._base.id; 74 | }; 75 | 76 | Resource.prototype._getUidString = function() { 77 | return this._base.id + "/" + this._base.type; 78 | }; 79 | 80 | Resource.prototype._getPathFor = function(relation) { 81 | return this._getRaw().relationships[relation].links.related; 82 | }; 83 | 84 | Resource.prototype._matches = function(other) { 85 | return (this._base.id === other.id) && (this._base.type === other.type); 86 | }; 87 | 88 | Resource.prototype._associateWithAll = function(resources) { 89 | var self = this; 90 | resources.forEach(function(resource) { 91 | self._associateWith(resource); 92 | resource._associateWith(self); 93 | }); 94 | }; 95 | 96 | Resource.prototype._relationHasResource = function(relationName, resource) { 97 | var relation = this[relationName]; 98 | 99 | if (relation instanceof Array) { 100 | relation = relation.filter(function(i) { 101 | return (i._base === resource._base); 102 | }).pop(); 103 | } 104 | 105 | return !!relation; 106 | }; 107 | 108 | Resource._rawRelationHasMany = function(rawRelation) { 109 | var meta = getMeta(rawRelation); 110 | if (meta.relation === "primary") return (rawRelation.data instanceof Array); 111 | return meta.many; 112 | }; 113 | 114 | Resource.prototype._associateWith = function(resource) { 115 | var self = this; 116 | var relationships = self._getRaw().relationships; 117 | if (!relationships) return; 118 | Object.keys(relationships).forEach(function(relationName) { 119 | var rawRelation = relationships[relationName]; 120 | var otherRelation = resource; 121 | var rawRelationData = rawRelation.data; 122 | var rawRelationMeta = getMeta(rawRelation); 123 | if (rawRelationMeta.relation === "foreign") { 124 | if (rawRelationMeta.belongsTo !== resource._getBase().type) { 125 | return; 126 | } 127 | otherRelation = self; 128 | rawRelationData = resource._getRaw().relationships[rawRelationMeta.as].data; 129 | } 130 | 131 | var match = !![].concat(rawRelationData).filter(function(dataItem) { 132 | // should null ever get here? 133 | return dataItem && otherRelation._matches(dataItem); 134 | }).pop(); 135 | if (!match) return; 136 | 137 | if (Resource._rawRelationHasMany(rawRelation)) { 138 | if (self._relationHasResource(relationName, resource)) return; 139 | self[relationName] = self[relationName] || [ ]; 140 | self[relationName].push(resource); 141 | } else { 142 | self[relationName] = resource; 143 | } 144 | }); 145 | }; 146 | 147 | Resource.prototype.relationships = function(relationName) { 148 | var self = this; 149 | var rawRelation = this._raw.relationships[relationName]; 150 | 151 | if (!self._base.id) { 152 | rawRelation = this._raw.relationships[relationName] = { 153 | meta: { _trust: true }, 154 | links: { }, 155 | data: null 156 | }; 157 | } 158 | 159 | if (!rawRelation) { 160 | throw new Error("Relationship " + relationName + " on " + this._raw.type + " does not exist!"); 161 | } 162 | 163 | if (getMeta(rawRelation).relation === "foreign") { 164 | throw new Error("Relationship " + relationName + " on " + this._raw.type + " is a foreign relation and cannot be updated from here!"); 165 | } 166 | 167 | return { 168 | add: function(resource) { 169 | return self._addRelation(relationName, rawRelation, resource); 170 | }, 171 | set: function(resource) { 172 | return self._setRelation(relationName, rawRelation, resource); 173 | }, 174 | remove: function(resource) { 175 | return self._removeRelation(relationName, rawRelation, resource); 176 | } 177 | }; 178 | }; 179 | Resource.prototype._addRelation = function(relationName, rawRelation, resource) { 180 | this._checkIsResource(resource); 181 | if (rawRelation.meta && rawRelation.meta._trust) { 182 | delete rawRelation.meta; 183 | rawRelation.data = [ ]; 184 | } 185 | if (!Resource._rawRelationHasMany(rawRelation)) { 186 | throw new Error("Relationship " + relationName + " on " + this._raw.type + " is a not a MANY relationship and cannot be added to!"); 187 | } 188 | if (this._relationHasResource(relationName, resource)) return this; 189 | rawRelation.data.push(resource._getBase()); 190 | this[relationName] = this[relationName] || []; 191 | this[relationName].push(resource); 192 | resource._tweakLinksTo("add", this); 193 | return this; 194 | }; 195 | Resource.prototype._removeRelation = function(relationName, rawRelation, resource) { 196 | this._checkIsResource(resource); 197 | if (rawRelation.meta && rawRelation.meta._trust) { 198 | delete rawRelation.meta; 199 | rawRelation.data = [ ]; 200 | } 201 | if (Resource._rawRelationHasMany(rawRelation)) { 202 | rawRelation.data = rawRelation.data.filter(function(id) { 203 | return !resource._matches(id); 204 | }); 205 | this[relationName] = this[relationName].filter(function(id) { 206 | return !resource._matches(id); 207 | }); 208 | resource._tweakLinksTo("remove", this); 209 | } else { 210 | rawRelation.data = undefined; 211 | this[relationName] = undefined; 212 | } 213 | resource._tweakLinksTo("remove", this); 214 | return this; 215 | }; 216 | Resource.prototype._setRelation = function(relationName, rawRelation, resource) { 217 | this._checkIsResource(resource); 218 | rawRelation.data = resource._getBase(); 219 | this[relationName] = resource; 220 | resource._tweakLinksTo("add", this); 221 | return this; 222 | }; 223 | 224 | Resource.prototype.toJSON = function() { 225 | var theirs = this._raw; 226 | var theirResource = assign({ 227 | id: theirs.id, 228 | type: theirs.type 229 | }, theirs.attributes); 230 | for (var i in theirs.relationships) { 231 | theirResource[i] = theirs.relationships[i].data; 232 | } 233 | return theirResource; 234 | }; 235 | 236 | Resource.prototype.toJSONTree = function(seen) { 237 | seen = seen || [ ]; 238 | if (seen.indexOf(this) !== -1) return "[Circular]"; 239 | seen.push(this); 240 | var theirs = this._raw; 241 | var theirResource = assign({ 242 | id: theirs.id, 243 | type: theirs.type 244 | }, theirs.attributes); 245 | for (var i in theirs.relationships) { 246 | if (this[i] instanceof Array) { 247 | theirResource[i] = this[i].map(function(j) { // eslint-disable-line 248 | return j.toJSONTree(seen); 249 | }); 250 | } else if (this[i] instanceof Resource) { 251 | theirResource[i] = this[i].toJSONTree(seen); 252 | } else { 253 | theirResource[i] = theirs.relationships[i].data || null; 254 | } 255 | } 256 | return theirResource; 257 | }; 258 | 259 | Resource.prototype.get = function(attibuteName) { 260 | return this._raw.attributes[attibuteName]; 261 | }; 262 | 263 | Resource.prototype.set = function(attributeName, value) { 264 | this._raw.attributes[attributeName] = value; 265 | this._changed.push(attributeName); 266 | return this; 267 | }; 268 | 269 | Resource.prototype.fetch = Promise.denodeify(function(relationName, options, callback) { 270 | var self = this; 271 | 272 | if (typeof options === "function") { 273 | callback = options; 274 | options = { }; 275 | } 276 | 277 | self._client._getRelated(this, relationName, options, function(err, newResources) { 278 | if (!newResources) return callback("No related resources found"); 279 | 280 | if (!(newResources instanceof Array)) { 281 | self._raw.relationships[relationName].data = newResources._getBase(); 282 | return callback(err, newResources); 283 | } 284 | 285 | self._raw.relationships[relationName].data = newResources.map(function(someResource) { 286 | return someResource._getBase(); 287 | }); 288 | 289 | return callback(err, newResources); 290 | }); 291 | }); 292 | 293 | Resource.prototype.sync = Promise.denodeify(function(callback) { 294 | var self = this; 295 | var target = self._client._update; 296 | if (!self._getBase().id) { 297 | target = self._client._remoteCreate; 298 | } 299 | 300 | target.call(self._client, self, function(err, rawResponse, rawResource) { 301 | if (err) return callback(err); 302 | self._construct(rawResource, self._client); 303 | return callback(null, self); 304 | }); 305 | }); 306 | 307 | Resource.prototype.delete = Promise.denodeify(function(callback) { 308 | var self = this; 309 | self._client._delete(self, function(err) { 310 | if (err) return callback(err); 311 | 312 | self._client._resourceCache.removeFromCache(self); 313 | self._getBase().id = null; 314 | self._getRaw().id = null; 315 | 316 | return callback(); 317 | }); 318 | }); 319 | 320 | Resource.prototype._tweakLinksTo = function(method, resource) { 321 | var self = this; 322 | if (!self._raw.relationships) return; 323 | Object.keys(self._raw.relationships).forEach(function(i) { 324 | var someRawRelationship = self._raw.relationships[i]; 325 | var someRawRelationshipMeta = getMeta(someRawRelationship); 326 | if (someRawRelationshipMeta.relation !== "foreign") return; 327 | if (someRawRelationshipMeta.belongsTo !== resource._raw.type) return; 328 | 329 | self["_" + method + "LinksTo"](i, resource, someRawRelationshipMeta.many); 330 | }); 331 | }; 332 | 333 | Resource.prototype._removeLinksTo = function(i, resource) { 334 | if (this[i] instanceof Array) { 335 | this[i] = this[i].filter(function(j) { 336 | return j !== resource; 337 | }); 338 | } else { 339 | if (this[i] === resource) { 340 | this[i] = undefined; 341 | } 342 | } 343 | }; 344 | 345 | Resource.prototype._addLinksTo = function(i, resource, many) { 346 | if (this[i] instanceof Array) { 347 | this[i].push(resource); 348 | } else { 349 | if (many) { 350 | this[i] = [resource]; 351 | } else { 352 | this[i] = resource; 353 | } 354 | } 355 | }; 356 | -------------------------------------------------------------------------------- /lib/Transport.js: -------------------------------------------------------------------------------- 1 | /* @flow weak */ 2 | "use strict"; 3 | var Transport = module.exports = function(path, auth) { 4 | auth = auth || { }; 5 | this._construct(path, auth); 6 | }; 7 | 8 | var request = require("superagent"); 9 | var qs = require("qs"); 10 | 11 | Transport.prototype._construct = function(path, auth) { 12 | this._path = path; 13 | this._auth = { 14 | cookie: auth.cookie, 15 | header: auth.header 16 | }; 17 | }; 18 | 19 | Transport.prototype._attachAuthToRequest = function(someRequest) { 20 | if (this._auth.cookie) { 21 | var cookieName = Object.keys(this._auth.cookie)[0]; 22 | someRequest.set("Cookie", cookieName + "=" + this._auth.cookie[cookieName]); 23 | } 24 | 25 | if (this._auth.header) { 26 | var headerName = Object.keys(this._auth.header)[0]; 27 | someRequest.set(headerName, this._auth.header[headerName]); 28 | } 29 | }; 30 | 31 | Transport._defaultError = function(response) { 32 | return { 33 | status: "500", 34 | code: "EUNKNOWN", 35 | title: "An unknown error has occured", 36 | detail: response 37 | }; 38 | }; 39 | 40 | Transport.prototype._action = function(method, url, data, callback) { 41 | // console.log(method, url, JSON.stringify(data, null, 2)); 42 | var someRequest = request[method](url); 43 | someRequest.set("Content-Type", "application/vnd.api+json"); 44 | this._attachAuthToRequest(someRequest); 45 | if (method === "get") { 46 | someRequest = someRequest.query(qs.stringify(data)); 47 | } else { 48 | someRequest = someRequest.send(data || {}); 49 | } 50 | someRequest.end(function(err, payload) { 51 | // console.log("=>", err, payload); 52 | if (err) { 53 | // console.log(err.response.text); 54 | if (err.status === 401) { 55 | return callback(new Error("401 Unauthorized")); 56 | } 57 | 58 | var response; 59 | try { 60 | response = JSON.parse(err.response.text); 61 | } catch(e) { 62 | console.error("Transport Error: " + JSON.stringify(err)); 63 | return callback(Transport._defaultError(response)); 64 | } 65 | 66 | if (!Array.isArray(response.errors)) { 67 | console.error("Invalid Error payload!", response); 68 | return callback(Transport._defaultError(response)); 69 | } 70 | 71 | var realErrors = response.errors.map(function(apiError) { 72 | var someError = new Error(JSON.stringify(apiError.detail)); 73 | someError.name = apiError.title; 74 | // $FlowFixMe: Technically "status" isn't a field on Error 75 | someError.status = apiError.status; 76 | // $FlowFixMe: Technically "code" isn't a field on Error 77 | someError.code = apiError.code; 78 | 79 | return someError; 80 | }); 81 | 82 | return callback(realErrors[0]); 83 | } 84 | 85 | if (!payload.body) { 86 | try { 87 | payload.body = JSON.parse(payload.text); 88 | } catch(e) { 89 | return callback(Transport._defaultError(payload)); 90 | } 91 | } 92 | 93 | if (!payload.body) { 94 | return callback(Transport._defaultError(payload)); 95 | } 96 | 97 | return callback(null, payload.body, payload.body.data, payload.body.included); 98 | }); 99 | }; 100 | 101 | Transport.prototype.search = function(type, options, callback) { 102 | var url = this._path + "/" + type; 103 | this._action("get", url, options, callback); 104 | }; 105 | 106 | Transport.prototype.find = function(type, id, options, callback) { 107 | var url = this._path + "/" + type + "/" + id; 108 | this._action("get", url, options, callback); 109 | }; 110 | 111 | Transport.prototype.fetch = function(resource, relation, options, callback) { 112 | var url = resource._getPathFor(relation); 113 | this._action("get", url, options, callback); 114 | }; 115 | 116 | Transport.prototype.create = function(resource, callback) { 117 | var url = this._path + "/" + resource._getBase().type; 118 | var postData = resource._getRaw(); 119 | delete postData.id; 120 | this._action("post", url, { 121 | data: resource._getRaw() 122 | }, callback); 123 | }; 124 | 125 | Transport.prototype.update = function(resource, callback) { 126 | var url = this._path + "/" + resource._getBase().type + "/" + resource._getBase().id; 127 | this._action("patch", url, { 128 | data: resource._getDelta() 129 | }, callback); 130 | }; 131 | 132 | Transport.prototype.delete = function(resource, callback) { 133 | var url = this._path + "/" + resource._getBase().type + "/" + resource._getBase().id; 134 | this._action("del", url, null, callback); 135 | }; 136 | -------------------------------------------------------------------------------- /lib/resourceCache.js: -------------------------------------------------------------------------------- 1 | /* @flow weak */ 2 | "use strict"; 3 | module.exports = function createResourceCache(options) { 4 | options = options || { }; 5 | var resourceCache = {}; 6 | 7 | resourceCache._cacheDuration = options.cacheDuration || 5000; 8 | 9 | if (typeof module === undefined) { 10 | resourceCache._cacheDuration = null; 11 | } 12 | resourceCache._cache = { }; 13 | resourceCache._cacheList = [ ]; 14 | 15 | resourceCache.get = function(someResource) { 16 | var key = someResource._getUidString(); 17 | return resourceCache._cache[key]; 18 | }; 19 | 20 | resourceCache.set = function(someResource) { 21 | someResource._associateWithAll(resourceCache._cacheList); 22 | 23 | var key = someResource._getUidString(); 24 | resourceCache._cache[key] = someResource; 25 | resourceCache._cacheList.push(someResource); 26 | 27 | if (!resourceCache._cacheDuration) return; 28 | 29 | setTimeout(function() { 30 | resourceCache.removeFromCache(someResource); 31 | }, resourceCache._cacheDuration); 32 | }; 33 | 34 | resourceCache.removeFromCache = function(someResource) { 35 | var key = someResource._getUidString(); 36 | resourceCache._cache[key] = undefined; 37 | var i = resourceCache._cacheList.indexOf(someResource); 38 | resourceCache._cacheList.splice(i, 1); 39 | }; 40 | 41 | return resourceCache; 42 | }; 43 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@holidayextras/jsonapi-client", 3 | "version": "1.0.0", 4 | "description": "A clientside module designed to make it really easy to consume a json:api service.", 5 | "keywords": [ 6 | "jsonapi", 7 | "json:api", 8 | "api" 9 | ], 10 | "main": "lib/Client.js", 11 | "author": "Oliver Rumbelow", 12 | "license": "MIT", 13 | "repository": { 14 | "type": "git", 15 | "url": "https://github.com/holidayextras/jsonapi-client.git" 16 | }, 17 | "dependencies": { 18 | "async": "2.5.0", 19 | "object-assign": "4.1.1", 20 | "promise": "8.0.1", 21 | "qs": "^6.2.0", 22 | "superagent": "3.5.2" 23 | }, 24 | "devDependencies": { 25 | "blanket": "1.2.3", 26 | "browserify": "14.4.0", 27 | "coveralls": "2.13.1", 28 | "eslint": "4.2.0", 29 | "eslint-config-eslint": "4.0.0", 30 | "flow-bin": "0.50.0", 31 | "jscpd": "^0.6.1", 32 | "jsonapi-server": "3.0.2", 33 | "karma": "1.7.0", 34 | "karma-chrome-launcher": "2.2.0", 35 | "karma-firefox-launcher": "1.0.1", 36 | "karma-mocha": "1.3.0", 37 | "karma-phantomjs-launcher": "1.0.4", 38 | "karma-sauce-launcher": "1.1.0", 39 | "karma-spec-reporter": "0.0.31", 40 | "mocha": "3.4.2", 41 | "mocha-lcov-reporter": "1.3.0", 42 | "phantomjs-prebuilt": "2.1.14", 43 | "plato": "1.7.0", 44 | "uglify-js": "3.0.24" 45 | }, 46 | "engines": { 47 | "node": ">=4.5" 48 | }, 49 | "scripts": { 50 | "build": "mkdir -p dist && ./node_modules/.bin/browserify lib/Client.js | ./node_modules/.bin/uglifyjs -cm -o dist/jsonapi-client.min.js 2> /dev/null && echo '✔ Assets built!'", 51 | "build:test": "npm run build && ./node_modules/.bin/browserify -i jsonapi-server/example/server.js test/*.js -o dist/jsonapi-client-test.js && echo '✔ Test assets built!'", 52 | "mocha": "./node_modules/mocha/bin/mocha -R spec ./test/*.js", 53 | "prekarmalocal": "DEBUG=* node ./node_modules/jsonapi-server/example/server.js &", 54 | "karmalocal": "sleep 1 && npm run build:test && ./node_modules/karma/bin/karma start ./karma.local.conf.js", 55 | "postkarmalocal": "killall node", 56 | "prekarmaremote": "DEBUG=* node ./node_modules/jsonapi-server/example/server.js &", 57 | "karmaremote": "sleep 1 && npm run build:test && ./node_modules/karma/bin/karma start ./karma.saucelabs.conf.js || true", 58 | "postkarmaremote": "killall node", 59 | "karma": "npm run karmalocal && npm run karmaremote", 60 | "test": "npm run mocha && npm run karma", 61 | "coveralls": "./node_modules/mocha/bin/mocha --require blanket --reporter mocha-lcov-reporter ./test/*.js | ./node_modules/coveralls/bin/coveralls.js", 62 | "coverage": "./node_modules/mocha/bin/mocha --require blanket --reporter html-cov ./test/*.js > coverage.html", 63 | "complexity": "./node_modules/plato/bin/plato -r -d complexity lib", 64 | "lint": "./node_modules/.bin/eslint ./lib/* ./test/*.js --quiet && echo '✔ All good!'", 65 | "flow": "node ./node_modules/flow-bin/cli.js && echo '✔ All good!'", 66 | "jscpd": "jscpd --blame -p ./lib/ || echo 'Finished!'", 67 | "ci": "npm run lint && npm run flow && npm run jscpd && npm run test" 68 | }, 69 | "config": { 70 | "blanket": { 71 | "pattern": "/jsonapi-client/lib/", 72 | "data-cover-never": [ 73 | "node_modules", 74 | "test", 75 | "example" 76 | ] 77 | } 78 | } 79 | } 80 | -------------------------------------------------------------------------------- /test/_testServer.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | var jsonApiTestServer = require("jsonapi-server/example/server.js"); 4 | if (!jsonApiTestServer.start) return; 5 | 6 | jsonApiTestServer.getExpressServer().use(function (req, res, next) { 7 | res.header("Access-Control-Allow-Origin", "*"); 8 | res.header("Access-Control-Allow-Methods", "POST, GET, PUT, DELETE, OPTIONS"); 9 | res.header("Access-Control-Allow-Headers", "Content-Type"); 10 | next(); 11 | }); 12 | 13 | before(function() { 14 | jsonApiTestServer.start(); 15 | }); 16 | after(function() { 17 | jsonApiTestServer.close(); 18 | }); 19 | -------------------------------------------------------------------------------- /test/authentication.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | var assert = require("assert"); 3 | var Client = require("../."); 4 | require("./_testServer.js"); 5 | 6 | describe("Testing jsonapi-client", function() { 7 | describe("authentication", function() { 8 | 9 | it("is denied access with the blockme header", function(done) { 10 | var client = new Client("http://localhost:16006/rest", { 11 | header: { 12 | blockme: true 13 | } 14 | }); 15 | 16 | client.get("people", "ad3aa89e-9c5b-4ac9-a652-6670f9f27587", function(err) { 17 | assert.equal(err.message, "401 Unauthorized"); 18 | done(); 19 | }); 20 | }); 21 | 22 | xit("is denied access with the blockMe cookie", function(done) { 23 | var client = new Client("http://localhost:16006/rest", { 24 | cookie: { 25 | blockMe: true 26 | } 27 | }); 28 | 29 | client.get("people", "ad3aa89e-9c5b-4ac9-a652-6670f9f27587", function(err) { 30 | assert.equal(err.message, "401 Unauthorized"); 31 | done(); 32 | }); 33 | }); 34 | 35 | }); 36 | }); 37 | -------------------------------------------------------------------------------- /test/promises.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | var assert = require("assert"); 3 | var Client = require("../."); 4 | require("./_testServer.js"); 5 | 6 | var client = new Client("http://localhost:16006/rest"); 7 | 8 | describe("Testing jsonapi-client", function() { 9 | 10 | context("supports promises", function() { 11 | 12 | it("client.find", function(done) { 13 | client.find("people", { }).then(function(people) { 14 | assert.ok(people[0] instanceof Client.Resource); 15 | }).then(done, done); 16 | }); 17 | 18 | it("client.get", function(done) { 19 | client.get("people", "d850ea75-4427-4f81-8595-039990aeede5", { }).then(function(person) { 20 | assert.ok(person instanceof Client.Resource); 21 | }).then(done, done); 22 | }); 23 | 24 | it("client.fetch", function(done) { 25 | client.get("people", "d850ea75-4427-4f81-8595-039990aeede5", { }).then(function(person) { 26 | return person.fetch("articles", { include: "photos" }); 27 | }).then(function(articles) { 28 | assert.ok(articles[0] instanceof Client.Resource); 29 | assert.ok(articles[0].photos[0] instanceof Client.Resource); 30 | }).then(done, done); 31 | }); 32 | 33 | it("create-sync-delete", function(done) { 34 | var newPerson = client.create("people"); 35 | var uuid; 36 | newPerson.sync().then(function() { 37 | uuid = newPerson._getUid(); 38 | return newPerson.delete(); 39 | }).then(function() { 40 | return client.get("people", uuid, { }); 41 | }).then(function() { 42 | throw new Error("Should have errored!"); 43 | }, function(err) { 44 | assert.ok(err.message.match(/There is no people with id /)); 45 | done(); 46 | }); 47 | }); 48 | 49 | }); 50 | 51 | }); 52 | -------------------------------------------------------------------------------- /test/read.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | var assert = require("assert"); 3 | var Client = require("../."); 4 | require("./_testServer.js"); 5 | 6 | var client = new Client("http://localhost:16006/rest"); 7 | 8 | describe("Testing jsonapi-client", function() { 9 | 10 | context("searches for resources", function() { 11 | it("resource.toJSONTree() should provide developer-friendly debug info", function(done) { 12 | client.find("people", { include: "articles", filter: { lastname: "Rumbelow"}}, function(err, people) { 13 | assert.equal(err, null); 14 | 15 | var treeView = people.map(function(person) { 16 | return person.toJSONTree(); 17 | }); 18 | 19 | assert.deepEqual(treeView, [ 20 | { 21 | "id": "cc5cca2e-0dd8-4b95-8cfc-a11230e73116", 22 | "type": "people", 23 | "firstname": "Oli", 24 | "lastname": "Rumbelow", 25 | "email": "oliver.rumbelow@example.com", 26 | "articles": [ 27 | { 28 | "id": "de305d54-75b4-431b-adb2-eb6b9e546014", 29 | "type": "articles", 30 | "title": "NodeJS Best Practices", 31 | "status": "published", 32 | "content": "na", 33 | "author": "[Circular]", 34 | "created": "2016-01-05", 35 | "views": "10", 36 | "tags": [ 37 | { 38 | "type": "tags", 39 | "id": "7541a4de-4986-4597-81b9-cf31b6762486" 40 | } 41 | ], 42 | "photos": [], 43 | "comments": [ 44 | { 45 | "type": "comments", 46 | "id": "3f1a89c2-eb85-4799-a048-6735db24b7eb" 47 | } 48 | ] 49 | } 50 | ], 51 | "photos": null 52 | } 53 | ]); 54 | 55 | done(); 56 | }); 57 | }); 58 | 59 | it("finds resources", function(done) { 60 | client.find("articles", { }, function(err, people) { 61 | assert.equal(err, null); 62 | assert.equal(people.length, 4); 63 | 64 | done(); 65 | }); 66 | }); 67 | 68 | it("filters resources", function(done) { 69 | client.find("articles", { filter: { title: "