{json:api} SDK library for Javascript
This is a library that allows you to create SDKs for APIs that conform to the {json:api} specification. The design of this library, as well as the structure of this README file, follow the principles of an equivalent library that was written for Python as much as possible.
Setting up
npm install --save javascript-jsonapi-sdk-library
Using this library means creating your own API SDK for a remote service. In
order to do that, you need to first define an API connection type. This is
done by subclassing JsonApi
:
import { JsonApi } from 'javascript-jsonapi-sdk-library';
class FamilyApi extends JsonApi {
static HOST = 'https://api.families.com';
}
Next, you have to define some API resource types and register them to the
API connection type. This is done by subclassing Resource
and decorating it
with the connection type's register
method:
import { Resource } from 'javascript-jsonapi-sdk-library';
class Parent extends Resource {
static name = 'Parent';
static TYPE = 'parents';
}
FamilyApi.register(Parent);
class Child extends Resource {
static name = 'Child';
static TYPE = 'children';
}
FamilyApi.register(Child);
Note: The static attribute
name
is used by us in order to expose a version of the class that is bound to the API connection type as an attribute of the API connection instances. Typically, javascript classes automatically have a.name
attribute that is set as the name we used for the class. So:class Something {} console.log(Something.name); // <<< 'Something'However, when the code is minified, for example when you bundle it using webpack in production mode, the class names get converted to a single letter. This is why it is safer to add a static 'name' attribute manually.
Users of your SDK can then instantiate your API connection type, providing authentication credentials and/or overriding the host, in case you want to test against a sandbox API server and not the production one:
const familyApi = new FamilyApi({
host: 'https://sandbox.api.families.com',
auth: 'MYTOKEN',
});
Finally the API resource types you have registered can be accessed as attributes on this API connection instance. You can either use the class's name or the API resource's type:
const child = await familyApi.Child.get('1')
const child = await familyApi.children.get('1')
This is enough to get you started since the library will be able to provide you with a lot of functionality based on the structure of the responses you get from the server. Make sure you define and register Resource subclasses for every type you intend to encounter, because the library will use the API instance's registry to resolve the appropriate subclass for the items included in the API's responses.
Non-ES Javascript
If you are running a version of Javascript that doesn't support classes, for
example an old browser, you can use the extend
static methods of JsonApi
and Resource
to achieve the same results:
<html>
<meta charset="UTF-8">
<body>
<script src="jsonapi.js"></script>
<script>
var JsonApi = jsonapi.JsonApi;
var Resource = jsonapi.Resource;
var FamilyApi = JsonApi.extend({
HOST: 'https://api.families.com',
});
var Parent = Resource.extend({
name: 'Parent',
TYPE: 'parents',
});
FamilyApi.register(Parent);
var familyApi = new FamilyApi({
auth: 'MYTOKEN',
});
var parents = familyApi.Parent.list();
parents.fetch().then(function() {
console.log({ parents });
})
</script>
</body>
</html>
Global API connection instances
You can configure an already created API connection instance by calling the
setup
method, which accepts the same properties as the constructor. In
fact, JsonApi
's constructor
and setup
methods have been written in such a
way that the following two snippets should produce an identical outcome:
const props = ...;
const familyApi = new FamilyApi(props);
const props = ...;
const familyApi = new FamilyApi();
familyApi.setup(props);
This way, you can implement your SDK in a way that offers the option to users
to either use a global API connection instance or multiple instances. In
fact, this is exactly how @transifex/api
has been set up:
// transifexApi/src/index.js
import { JsonApi, Resource } from 'javascript-jsonapi-sdk-library';
export class TransifexApi extends JsonApi {
static HOST = 'https://rest.api.transifex.com';
}
class Organization extends Resource {
static name = "Organization";
static TYPE = "organizations";
}
TransifexApi.register(Organization);
export const transifexApi = TransifexApi();
// app.js (uses the global API connection instance)
import { transifexApi } from '@transifex/api';
transifexApi.setup({ auth: 'MYTOKEN' });
const organization = await transifexApi.Organization.get("1");
// app.js (uses multiple custom API connection instances)
import { TransifexApi } from '@transifex/api';
const api1 = new TransifexApi({ auth: 'APITOKEN1' });
const api2 = new TransifexApi({ auth: 'APITOKEN2' });
const organization1 = await api1.Organization.get('1');
const organization2 = await api2.Organization.get('2');
(The whole logic behind this initialization process is further explained here)
Authentication
The auth
property to JsonApi
or setup
can either be:
-
A string, in which case all requests to the API server will include the
Authorization: Bearer <API_TOKEN>
header -
A callable, in which case the return value is expected to be a dictionary which will be merged with the headers of all requests to the API server
import { FamilyApi } from './families'; import { encrypt } from './crypto'; function myAuth() { return { 'x-signature': encrypt(Date()) }; } const familyApi = new FamilyApi({ auth: myAuth });
Retrieval
URLs
By default, collection URLs have the form /<type>
(eg /children
) and item
URLs have the form /<type>/<id>
(eg /children/1
). This is also part of
{json:api}'s recommendations. If you want to customize them, you need to
override the getCollectionUrl
static method and the getItemUrl()
method
of the resource's subclass:
class Child extends Resource {
static name = 'Child';
static TYPE = 'children';
static getCollectionUrl() {
return '/children_collection';
}
getItemUrl() {
return `/child_item/${this.id}`;
}
}
FamilyApi.register(Child);
Getting a single resource object from the API
If you know the ID of the resource object, you can fetch its {json:api} representation with:
const child = await familyApi.Child.get('1');
The attributes of a resource object are id
, attributes
, relationships
,
links
and related
. id
, attributes
, relationships
and links
have
exactly the same value as in the API response.
const parent = await familyApi.Parent.get('1');
console.log(parent.id);
// <<< '1'
console.log(parent.attributes);
// <<< { name: 'Zeus' }
console.log(parent.relationships);
// <<< { children: { links: { self: '/parent/1/relationships/children',
// ... related: '/children?filter[parent]=1' } } }
const child = await familyApi.Child.get('1');
console.log(child.id);
// <<< '1'
console.log(child.attributes);
// <<< { name: 'Hercules' }
console.log(child.relationships);
// <<< { parent: { data: { type: 'parents', id: '1' },
// ... links: { self: '/children/1/relationships/parent',
// ... related: '/parents/1' } } }
You can reload an object from the server by calling .reload()
:
await child.reload();
// equivalent to
child = await familyApi.Child.get(child.id);
Relationships
Intro
We need to talk a bit about how {json:api} represents relationships and how the
transifex.api.jsonapi
library interprets them. Depending on the value of a
field of relationships
, we consider the following possibilities. A
relationship can either be:
-
A null relationship which will be represented by a null value:
{ type: 'children', id: '...', attributes: { ... }, relationships: { parent: null, // <--- ..., }, links: { ... } }
-
A singular relationship which will be represented by an object with both
data
andlinks
fields, with thedata
field being a dictionary:{ type: 'children', id: '...', attributes: { ... }, relationships: { parent: { data: { type: 'parents', id: '...' }, // <--- links: { self: '...', related: '...' } }, // <--- ... , }, links: { ... } }
-
A plural relationship which will be represented by an object with a
links
field and either a missingdata
field or adata
field which is a list:{ type: 'parents', id: '...', attributes: { ... }, relationships: { children: { links: { self: '...', related: '...' } }, // <--- ..., }, links: { ... } }
or
{ type: 'parents', id: '...', attributes: { ... }, relationships: { children: { links: { self: '...', related: '...' }, // <--- data: [{ type: 'children', id: '...' }, // <--- { type: 'children', id: '...' }, // <--- ... ] }, // <--- ... , }, links: { ... } }
This is important because the library will make assumptions about the nature of relationships based on the existence of these fields.
Fetching relationships
The related
field is meant to host the data of the relationships, after
these have been fetched from the API. Lets revisit the last example and inspect
the relationships
and related
fields:
const parent = await familyApi.Parent.get('1');
console.log(parent.relationships);
// <<< { children: { links: { self: '/parent/1/relationships/children',
// ... related: '/children?filter[parent]=1' } } }
console.log(parent.related);
// <<< {}
const child = await familyApi.Child.get('1');
console.log(child.relationships);
// <<< { parent: { data: { type: 'parents', id: '1' },
// ... links: { self: '/children/1/relationships/parent',
// ... related: '/parents/1' } } }
console.log(child.related.id);
// <<< '1'
console.log(child.related.attributes);
// <<< {}
console.log(child.related.relationships);
// <<< {}
As you can see, the parent→children related
field is empty while the
child→parent related
field is prefilled with an "unfetched" Parent
instance. This happens because the first one is a plural relationship while
the second is a singular relationship. Unfetched means that we only know its
id
so far. In both cases, we don't know any meaningful data about the
relationships yet.
In order to fetch the related data, you need to call .fetch()
with the names
of the relationship you want to fetch:
await child.fetch('parent'); // Now `related.parent` has all the information
console.log(child.related.parent.id);
// <<< '1'
console.log(child.related.parent.attributes);
// <<< { name: 'Zeus' }
console.log(child.related.parent.relationships);
// <<< { children: { links: { self: '/parent/1/relationships/children',
// ... related: '/children?filter[parent]=1' } } })
await parent.fetch('children');
await parent.related.children.fetch();
console.log(parent.related.children.data[0].id);
// <<< '1'
console.log(parent.related.children.data[0].attributes);
// <<< { name: 'Hercules' }
console.log(parent.related.children.data[0].relationships);
// <<< { parent: { data: { type: 'parents', id: '1' },
// ... links: { self: '/children/1/relationships/parent',
// ... related: '/parents/1' } } }
Trying to fetch an already-fetched relationship will not actually trigger
another request, unless you pass { force: true }
to .fetch()
.
.fetch()
will return the relation:
const children = await parent.fetch('children');
// is equivalent to
await parent.fetch('children');
const children = parent.related.children;
await children.fetch();
console.log(children.data[0].attributes.name);
// <<< 'Hercules'
Since .fetch()
behaves lazily by default, ie it won't interact with the
server again if the relationship is already fetched, it may be preferable to
access relationships with .fetch()
every time instead of .related
, since
then you won't have to worry about making sure that the relationship was
fetched beforehand.
Shortcuts
You can access attributes and fetched relationships directly , or you can use
the .get()
shortcut which will work for both attributes and relationships:
console.log(child.get('name'));
// equivalent to
console.log(child.attributes.name);
console.log(child.get('parent').get('name'));
// equivalent to
console.log(child.related.parent.attributes.name);
(although for relationships using .fetch()
may be preferable, as mentioned
before)
You can also set attributes and relationships using .set()
:
child.set('name', 'Achilles');
// equivalent to
child.attributes.name = 'Achilles';
For relationships, using .set()
is preferable to editing relationships
and
related
by hand. .set()
will make sure that the argument will be
interpreted as a {json:api} relationship, and it will take care to empty the
pre-fetched value in case the relationship has changed:
child.set('parent', { data: { type: 'parents', id: '1' } });
// equivalent to
child.set('parent', new familyApi.Parent({ id: '1' }));
await child.fetch('parent');
child.set('parent', new familyApi.Parent({ id: '2' }));
// Will re-fetch since the parent was changed and we don't have pre-fetched
// information on the new parent
await child.fetch('parent');
Getting Resource collections
You can access a collection of resource objects using one of the list
,
filter
, page
, include
,sort
, fields
and extra
static methods of the
Resource subclass.
const children = familyApi.Child.list();
await children.fetch();
console.log(children.data[0].get('name'));
Each method does the following:
-
list
returns the first page of the results -
filter
applies filters; nested filters are separated by double underscores (__
), Django-styleoperation GET request .filter({ a: 1 })
?filter[a]=1
.filter({ a__b: 1 })
?filter[a][b]=1
Note: because it's a common use-case, using a resource object as the value of a filter operation will result in using its
id
fieldconst parent = await familyApi.Parent.get('1'); familyApi.Child.filter({ parent: parent }); // is equivalent to familyApi.Child.filter({ parent: parent.id });
-
page
applies pagination; it accepts either one positional argument which will be passed to thepage
GET parameter or multiple properties which will be passed as nestedpage
GET parametersoperation GET request .page(1)
?page=1
.page({ a: 1, b: 2 })
?page[a]=1&page[b]=2
(Note: you will probably not have to use
.page
yourself since the returned lists support pagination on their own, see below) -
include
will set theinclude
GET parameter; it accepts multiple positional arguments which it will join with commas (,
)operation GET request .include('parent', 'pet')
?include=parent,pet
-
sort
will set thesort
GET parameter; it accepts multiple positional arguments which it will join with commas (,
)operation GET request .sort('age', 'name')
?sort=age,name
-
fields
will set thefields
GET parameter; it accepts multiple positional arguments which it will join with commas (,
)operation GET request .fields('age', 'name')
?fields=age,name
-
extra
accepts any keyword arguments which will be added to the GET parameters sent to the APIoperation GET request .extra({ group_by: 'age' })
?group_by=age
-
all
returns a generator that will yield all results of a paginated collection, using multiple requests if necessary; the pages are fetched on-demand, so if you abort the generator early, you will not be performing requests against every possible pageconst list = familyApi.Child.list(); for await (const child of list.all()) { console.log(child.get('name')); }
-
allPages
returns a generator of non-empty pages; similarly toall
, pages are fetched on-demand (in fact,all
usesallPages
internally)
All the above methods can be chained to each other. So:
familyApi.Child.list().filter({ a: 1 });
// is equivalent to
familyApi.Child.filter({ a: 1 });
familyApi.Child.filter({ a: 1 }).filter({ b: 2 });
// is equivalent to
familyApi.Child.filter({ a: 1, b: 2 });
Also, in order for the chaining to work, the collections are also lazy. You
will not actually make any requests to the server until you await a .fetch()
call on the collection. So this:
function getChildren(gender = null, hair_color = null) {
let result = familyApi.Child.list();
if (gender) {
result = result.filter({ gender });
}
if (hair_color) {
result = result.filter({ hair_color });
}
return result
}
const children = getChildren({ hair_color: 'red' });
await children.fetch();
will only make one request to the server during the resolution of the
.fetch()
call in the last line.
You can also access pagination via the getNext()
, and getPrevious()
methods
of a returned page, provided that that the page's .next
and .previous
attributes are not null
(in fact, all()
and allPages()
use getNext()
internally):
const all = [];
let page = familyApi.Child.list();
await page.fetch();
while (true) {
for (const item of page.data) {
all.push(item);
}
if (! page.next) {
break;
}
page = await page.getNext();
}
All the previous methods also work on plural relationships (assuming the API
supports the applied filters etc on the endpoint specified by the related
link of the relationship).
const children = await parent.fetch('children');
const filteredChildren = children.filter({ name: "Hercules" });
await filteredChildren.fetch();
console.log(filteredChildren.data[0].get('name'));
(Don't be confused by the double appearance of .fetch()
; the first time we
are getting an unfetched collection representing the parent->children
relationship, the second time we are converting the unfetched page to a fetched
one)
include
Prefetching relationships with If you use the include
method on a collection retrieval or if you use the
include
property on .get()
(and if the server supports it), the included
values of the response will be used to prefill the relevant fields of
related
:
const child = await familyApi.Child.get("1", { include: ['parent'] })
console.log(child.get('parent').get('name')); // No need to fetch the parent
// <<< 'Zeus'
const children = familyApi.Child.list().include('parent')
await children.fetch();
// No need to fetch the parents
console.log(children.data[0].get('parent').get('name'));
// <<< 'Zeus'
console.log(children.data[1].get('parent').get('name'));
// <<< 'Zeus'
// ...
In case of a plural relationships with a list data
field, if the response
supplies the related items in the included
section, these too will be
prefilled.
const parent = await familyApi.Parent.get("1", { include: ['children'] });
// Assuming the response looks like:
// {'data': {'type': "parents",
// 'id': "1",
// 'attributes': ...,
// 'relationships': {'children': {'data': [{'type': "children", 'id': "1"},
// {'type': "children", 'id': "2"}],
// 'links': ...}}},
// 'included': [{'type': "children",
// 'id': "1",
// 'attributes': {'name': "Hercules"}},
// {'type': "children",
// 'id': "2",
// 'attributes': {'name': "Achilles"}}]}
// No need to fetch
console.log(parent.get('children').data[0].get('name'));
// <<< 'Hercules'
console.log(parent.get('children').data[1].get('name'));
// <<< 'Achilles'
Getting single resource objects using filters
Appending .get()
to a collection will ensure that the collection is of size 1
and return the one resource instance in it. If the collection's size isn't 1,
it will raise an error.
const child = await familyApi.Child.filter({ name: 'Bill' }).get();
The Resource
's .get()
static method, which we covered before, also accepts
properties as an argument. Calling it this way, will apply the filters and use
the collection's .get()
method on the result.
const child = await familyApi.Child.get({ name: 'Bill' });
// is equivalent to
const child = await familyApi.Child.filter({ name: 'Bill' }).get();
Editing
Saving changes
After you change some attributes or relationships, you can call .save()
on an
object, which will trigger a PATCH request to the server. Because usually the
server includes immutable fields with the response (creation timestamps etc),
you don't want to include all attributes and relationships in the request. You
can specify which fields will be sent with save
's argument:
const child = await familyApi.Child.get('1');
child.set('name', child.get('name') + ' the Great!');
await child.save(['name']);
Because setting values right before saving is a common use-case, .save()
also
accepts properties. These will be set on the resource object, right before the
actual saving:
await child.save({ name: 'Hercules' });
// is equivalent to
child.set('name', 'Hercules');
await child.save(['name']);
Creating new resources
Calling .save()
on an object whose id
is not set will result in a POST
request which will (attempt to) create the resource on the server.
const parent = await familyApi.Parent.get('1');
const child = new familyApi.Child({
attributes: { name: 'Hercules' },
relationships: { parent },
});
await child.save();
After saving, the object will have the id
returned by the server, plus any
other server-generated attributes and relationships (for example, creation
timestamps).
There is a shortcut for the above, called .create()
const parent = await familyApi.Parent.get('1');
const child = await familyApi.Child.create({
attributes: { name: 'Hercules' },
relationships: { parent },
});
Note: for relationships, you can provide either a resource instance, a "Resource Identifier" (the 'data' value of a relationship object) or an entire relationship from another resource. So, the following are equivalent:
// Well, almost equivalent, the first example will trigger a request to fetch
// the parent's data from the server
const child = await familyApi.Child.create({
attributes: { name: 'Hercules' },
relationships: { parent: await familyApi.Parent.get('1') },
});
const child = await familyApi.Child.create({
attributes: { name: 'Hercules' },
relationships: { parent: new familyApi.Parent({ id: '1' }) },
});
const child = familyApi.Child.create({
attributes: { name: 'Hercules' },
relationships: { parent: { type: 'parents', id: '1' } },
});
const child = familyApi.Child.create({
attributes: { name: 'Hercules' },
relationships: { parent: { data: { type: 'parents', id: '1' } } },
});
This way, you can reuse a relationship from another object when creating, without having to fetch the relationship:
const newChild = await familyApi.Child.create({
attributes: { name: 'Achilles' },
relationships: { parent: old_child.get('parent') },
});
Magic properties
When making new (unsaved) instances, or when you create instances on the server
with .create()
, you can supply any property apart from id
, attributes
,
relationships
, etc and they will be interpreted as attributes or
relationships. Anything that looks like a relationship will be interpreted as
such while everything else will be interpreted as an attribute.
Things that are interpreted as relationships are:
- Resource instances
- Resource identifiers - dictionaries with 'type' and 'id' fields
- Relationship objects - dictionaries with a single 'data' field whose value is a resource identifier
So
new familyApi.Child({ name: 'Hercules' });
// is equivalent to
new familyApi.Child({ attributes: { name: 'Hercules' } });
new familyApi.Child({ parent: { type: 'parents', id: '1' } });
// is equivalent to
new familyApi.Child({ relationships: { parent: { type: 'parents', id: '1' } } });
new familyApi.Child({ parent: new familyApi.Parent({ id: '1' }) });
// is equivalent to
new familyApi.Child({ relationships: { parent: new familyApi.Parent({ id: '1' }) } });
If you are worried about naming conflicts, for example if you want to have an attribute called 'attributes' etc, you should fall back to using 'attributes' and 'relationships' directly.
// Don't do this
new familyApi.Child({ attributes: [1, 2, 3] });
// Do this instead
new familyApi.Child({ attributes: { attributes: [1, 2, 3] } });
Client-generated IDs
Since .save()
will issue a PATCH request when invoked on objects that have an
ID, if you want to supply your own client-generated ID during creation, you
have to use .create()
, which will always issue a POST request.
await (new familyApi.Child(attributes={ name: 'Hercules' })).save();
// POST: {data: {type: "children", attributes: {name: "Hercules"}}}
await (new familyApi.Child({ id: '1', attributes: { name: 'Hercules' } })).save();
// PATCH: {data: {type: "children", id: "1", attributes: {name: "Hercules"}}}
await familyApi.Child.create({ attributes: { name: 'Hercules' } });
// POST: {data: {type: "children", attributes: {name: "Hercules"}}}
await familyApi.Child.create({ id: '1', attributes: { name: 'Hercules' } });
// POST: {data: {type: "children", id: "1", attributes: {name: "Hercules"}}}
// ^^^^
Deleting
Deleting happens simply by calling .delete()
on an object. After deletion,
the object will have the same data as before, except its id
will be set to
None
. This happens in case you want to delete an object and instantly
re-create it, with a different ID.
const child = await familyApi.Child.get('1');
await child.delete();
// Will create a new child with the same name and parent as the previous one
await child.save(['name', 'parent']);
console.log(child.id === null || child.id == '1');
// <<< false
Editing relationships
Singular relationships
Changing a singular relationship can happen in two ways (this also depends on what the server supports).
const child = await familyApi.Child.get('1');
child.set('parent', newParent);
await child.save(['parent']);
// or
await child.change('parent', newParent);
The first one will send a PATCH request to /children/1
with a body of:
{"data": {"type": "children",
"id": "1",
"relationships": {"parent": {"data": {"type": "parents", "id": "2"}}}}}
The second one will send a PATCH request to the URL indicated by
child.relationships.parent.links.self
, which will most likely be
something like /children/1/relationships/parent
, with a body of:
{"data": {"type": "parents", "id": "2"}}
Plural relationships
For changing plural relationships, you can use one of the add
, remove
and
reset
methods:
const parent = await familyApi.Parent.get('1');
parent.add('children', [new_child, ...])
parent.remove('children', [existing_child, ...])
parent.reset('children', [child_a, child_b, ...])
These will send a POST, DELETE or PATCH request respectively to the URL
indicated by parent.relationships.children.links.self
, which will most likely
be something like /parents/1/relationships/children
, with a body of:
{"data": [{"type": "children", "id": "1"},
{"type": "children", "id": "2"},
{"...": "..."}]}
Similar to the case when we were instantiating objects with relationships, the values passed to the above methods can either be resource objects, "resource identifiers" or entire relationship objects:
await parent.add('children', [await familyApi.Child.get("1"),
new familyApi.Child({ id: '2' }),
{ type: 'children', id: '3' },
{ data: { type: 'children', id: '4' } }]);
Bulk operations
Resource subclasses provide the bulkDelete
, bulkCreate
and bulkUpdate
static methods for API endpoints that support such operations. These static
methods accepts lists of resource objects or {json:api} representations on
which the operation will be performed on. Furthermore, bulkUpdate
accepts a
fields
argument with the attributes
and relationships
of the objects it
will attempt to update.
// Bulk-create
const children = await familyApi.Child.bulkCreate([
new familyApi.Child({ name: 'One', parent: parent }),
{ type: 'children', attributes: { name: 'Two' }, relationships: { parent: parent } },
]);
// Bulk-update
const child = await familyApi.Child.get('a');
child.set('married', true);
const children = await familyApi.Child.bulkUpdate(
[child,
{ type: 'children', id: 'b', attributes: { married: true } }],
['married'],
);
// Bulk-delete
const child = await familyApi.Child.get('a');
const deletedCount = await familyApi.Child.bulkDelete([child, { id: 'b' }, 'c']);
const parent = await familyApi.Parent.get('1');
const childrenCollection = await parent.fetch('children');
const allChildren = [];
for await (const child of children_collection.all()) {
allChildren.push(child);
}
await familyApi.Child.bulkDelete(allChildren);
TODOS:
- [x] README
- [x] include param and included items in response
- [x] include with plural relationship
- [x] plural relationship editing
- [x] bulk actions
- [x] proper exceptions
- [x] create-with-form (aka more fine-grained control into axios API)
- [x] redirects
- [x] generators?
- [x] Clean up
_setRelationship
andsetRelated
methods - [x] Figure out how exported library works in node vs browser vs webpack vs minified bundle (CRA doesn't import source maps from dependencies yet)
- [x] Figure out how non-es code can use this library (
extend
function) - [ ] Add more comments