Working on a decoupled architecture, you quickly find yourself in need of a solid request/response standard. json:api is a specification that exposes your data with strictly defined set of rules for object composition, and yet allows enough flexibility within those objects to accommodate any data needs. It has the capacity for expressing complex data structures and relationships between records in a predictable format that is suitable for everything from a brochure website to a complex application with user-generated content. json:api has become a popular standard in recent years, with Ember.js adopting it as the default format for its serializer and Drupal’s JSON API module becoming a de facto standard for Decoupled Drupal projects.
One thing json:api is not, however, is terse. Its data structure gets deep pretty quickly, navigating related records can be expensive, and manipulating data via server requests requires building verbose request bodies. Composing query string parameters to filter the data or include related records is also verbose. There are very good reasons for all of this, but there is no denying that these characteristics make json:api a bit challenging to work with directly.
Introducing: jsonmonger
jsonmonger is a JavaScript library, written by yours truly, that abstracts all of the complexity of interacting with json:api and exposes a declarative interface, allowing you to write expressive code that focuses on business logic, not API transactions. In this post, we’ll go over some of its features and how it makes your interactions with json:api-compliant servers safer, less verbose, and a pleasure to work with.
I took inspiration from popular ORM libraries, which provide a layer of abstraction between databases and application code to facilitate database transactions without the need for lengthy SQL queries right in your code. jsonmonger aims to provide a similar abstraction layer for the json:api server powering your application. Its high-level goals are to empower developers to:
- Focus on application logic, not server requests. jsonmonger abstracts away all of the complexity associated with making requests to a json:api server so that application code need not concern itself with server requests.
- Enforce data model contracts to make assigning and looking up data values safer and simpler.
- Truly decouple your application from the json:api server by mapping the data structure you get from the server into custom objects that you control, and doing so in a non-destructive way that preserves the ability to push updates back to the server.
What does jsonmonger do?
To illustrate the benefits of using jsonmonger, first let’s take a quick look at what it looks like to interact with json:api without an abstraction layer. Say we have a book cataloging application and we often interact with book and author records. If you need to fetch the record for China Miéville and all of his books, here’s how you might accomplish that:
// my_app/controllers/get_author.js
// Axios is a library for making HTTP requests.
const axios = require('axios');
const id = 'china_id';
// We tell json:api that we want related records via the `include`
// query string parameter.
const request_url = `https://yourdomain.com/jsonapi/authors/${id}?include=field_books`;
// This example uses async/await to make async functions easier
// to reason about.
const response = await axios.get(request_url);
// Axios puts the response body in a data property.
const response_body = response.data;
// json:api puts the main object’s data in a data property.
const author_data = response_body.data;
// json:api puts related objects in an included array.
const related_records = response_body.included;
// We construct a bespoke object from the raw data.
const author = {
attributes: author_data.attributes,
// Books are related records, so we construct a custom array of book objects.
books: author_data.relationships.field_books.map(reference => {
// Return the first item in the included array which matches the reference
// type and ID. Then filter out the ones that might not be defined.
return related_records.find(record => {
return (record.id === reference.id && record.type === reference.type);
});
// Filter out any book records that didn’t get included, if any.
}).filter(record => record),
}
return author;
});
// Now we can do something with `author`.
That’s a whole lot of imperative code just to fetch one author’s record and include his related books in an array. You’d be well-advised to write your own internal library for fetching records, but you’d find yourself either having to implement options for which related objects to include in any given context (which gets hairy real quick, especially with nested relationships) or just live with having a generic wrapper that fetches every record the same way, regardless of what you actually need in a given scenario.
Now let’s compare that with jsonmonger:
// my_app/controllers/get_author.js
const Author = require('../models/Author.js');
const id = 'china_id';
// Create a new Author object with the requested author’s id and fetch
// its json:api record.
const author = await new Author({ id }).fetch({ related: 'books' });
// Now we can do something with `author`.
Ah, that’s better. jsonmonger takes care of the request for us and processes the raw data (including related records) to produce an object we can use in our application. The result is declarative code that’s focused on business logic and therefore easier to reason about.
This also makes manipulating China’s record relatively painless. Say we needed to update this record with China’s birthdate. jsonmonger makes this straightforward:
author.dateOfBirth = new Date('September 6, 1972');
await author.save();
What about adding one of his latest books? You might do something like this:
const Book = require('../models/Book');
const new_book = new Book({
title: 'The Last Days of New Paris',
isbn: '978-0345543998',
publishingYear: '2016',
});
// Create the new book in json:api.
await new_book.save();
// Add the newly-created book to the related books array and save it.
author.books.push(new_book);
author.save();
Your code doesn’t need to concern itself with the complexities of producing a request to create a new book, add that book to the author’s relationships, then producing another request to update the author on the server. Never mind handling each request’s response. jsonmonger takes care of that for you, and it does so by employing Models.
Using Models to shape your application data
jsonmonger Models allow you to define custom objects to handle every type of data record you interact with. These models come pre-loaded with methods to remove friction between your application code and how your data is stored. Using the book catalog app example, let’s see what our Author
and Book
models might look like:
// my_app/models/Author.js
const Model = require('jsonmonger').Model;
module.exports = new Model({
type: 'author',
endpoint: '/authors',
firstName: 'attributes.first_name',
lastName: 'attributes.last_name',
dateOfBirth: 'attributes.date_of_birth',
books: 'relationships.books_authored',
});
// my_app/models/Book.js
const Model = require('jsonmonger').Model;
module.exports = new Model({
type: 'book',
endpoint: '/books',
title: 'attributes.title',
isbn: 'attributes.meta.isbn',
publishingYear: 'attributes.year_published',
authors: 'relationships.authors',
});
There’s quite a bit to unpack here, so let’s go over some of the basics.
Required fields: type and endpoint
The type
property tells jsonmonger that it should use the current model whenever loading a related record with that type. This is particularly useful when an Author is loaded with all of their related books, because it makes each book its own jsonmonger object, modeled after the Book
model you defined. This has the added benefit of standardizing your internal data structure: books are always mapped in the same way no matter the context.
The endpoint
property tells jsonmonger which path to use when fetching, creating, updating, and destroying records of this type
.
Mapping attributes and relationships
jsonmonger allows you to truly decouple from the json:api by mapping custom object properties to discrete pieces of data in the raw JSON. Without it, you’d have to do something like this to get a book’s ISBN from your json:api payload:
// This is unsafe, as `meta` could be `null`.
const isbn = response.data.attributes.meta.isbn
With the sample Book
model above, you only need to do this:
// If it’s in the model, it’s in the object. This is a safe lookup.
const isbn = book.isbn
By mapping the raw JSON data into a custom object of your choosing, you are truly decoupling your application from your json:api. What’s even better, changing values on these properties correctly updates the raw JSON data. jsonmonger then uses that to make update requests to the server when you call .save()
on a record.
Using functions to get and set custom values
Not everything maps so cleanly between your json:api data and your application’s needs; sometimes you need a calculated value. For that, jsonmonger allows your models to define custom getter/setter functions. Let’s say we’d like our author
objects to have a fullName
property that concatenates its firstName
and lastName
properties for us. We’d amend the Author
model above to include the following property:
// my_app/models/Author.js
const Model = require('jsonmonger').Model;
module.exports = new Model({
// other properties…
fullName: function (value) {
// If there is a value, we are SETTING.
if (value) {
const splitName = value.split(' ');
this.firstName = splitName[0];
this.lastName = split.slice(1).join(' ');
return value;
// Otherwise, we are GETTING.
} else {
return `${this.firstName} ${this.lastName}`;
}
},
});
That function will get called any time we attempt to get or set an author’s
fullName
property:
// Create a new author object and set its full name like any other property.
const author = new Author({
fullName: 'Charles Bukowski',
});
console.log(author.firstName); // 'Charles'
console.log(author.lastName); // 'Bukowski'
author.firstName = 'Arturo';
author.lastName = 'Pérez Reverte';
console.log(author.fullName); // 'Arturo Pérez Reverte'
More to come!
jsonmonger was inspired by some of the challenges we’ve faced consuming decoupled Drupal with the JSON API module. There are several improvements currently in the pipe, including:
- Optimization options for minimizing response sizes.
- Support for custom methods (aside from getter/setters) for even more powerful functionality built right into your models.
- Browser support to make jsonmonger as useful client side as it is on the server.
This library is in active development, so be sure to star or watch it on GitHub to stay up-to-date on its progress. Contributions are always welcome!