A highly-modular (typescript-friendly)-framework agnostic library for serializing data to the JSON:API specification
Features
- This is the only typescript-compatible library that fully types the JSON:API specification and performs proper serialization.
- Zero dependencies.
- This is the only library with resource recursion.
- The modular framework laid out here highly promotes the specifications intentions:
- Using links is no longer obfuscated.
- Meta can truly be placed anywhere with possible dependencies laid out visibly.
- This library is designed to adhere to the specifications "never remove, only add" policy, so we will remain backwards-compatible.
Documentation
The documentation has everything that is covered here and more.
Installation
You can install ts-japi in your project's directory as usual:
npm install ts-japi
Getting Started
There are fives classes that are used to serialize data (only one of which is necessarily required).
You can check the documentation for a deeper insight into the usage.
Examples
You can check the examples and the test folders to see some examples (such as the ones below). You can check this example to see almost every option of Serializer
exhausted.
Serialization
The Serializer
class is the only class required for basic serialization.
The following example constructs the most basic Serializer
: (Note the await
)
import { Serializer } from "../src";
import { User } from "../test/models";
import { getJSON } from "../test/utils/get-json";
const UserSerializer = new Serializer("users");
(async () => {
const user = new User("sample_user_id");
console.log("Output:", getJSON(await UserSerializer.serialize(user)));
})();
Links
The Linker
class is used to generate a normalized document link. Its methods are not meant to be called. See the FAQ for reasons.
The following example constructs a Linker
for User
s and Article
s:
import { Linker } from "../src";
import { User, Article } from "../test/models";
import { getJSON } from "../test/utils/get-json";
const UserArticleLinker = new Linker((user: User, articles: Article | Article[]) => {
return Array.isArray(articles)
? `https://www.example.com/users/${user.id}/articles/`
: `https://www.example.com/users/${user.id}/articles/${articles.id}`;
});
(async () => {
const user = new User("sample_user_id");
const article = new Article("same_article_id", user);
console.log("Output:", getJSON(UserArticleLinker.link(user, article)));
})();
The Paginator
class is used to generate pagination links. Its methods are not meant to be called.
The following example constructs a Paginator
:
import { Paginator } from "../src";
import { User, Article } from "../test/models";
import { getJSON } from "../test/utils/get-json";
const ArticlePaginator = new Paginator((articles: Article | Article[]) => {
if (Array.isArray(articles)) {
const nextPage = Number(articles[0].id) + 1;
const prevPage = Number(articles[articles.length - 1].id) - 1;
return {
first: `https://www.example.com/articles/0`,
last: `https://www.example.com/articles/10`,
next: nextPage <= 10 ? `https://www.example.com/articles/${nextPage}` : null,
prev: prevPage >= 0 ? `https://www.example.com/articles/${prevPage}` : null,
};
}
return;
});
(async () => {
const user = new User("sample_user_id");
const article = new Article("same_article_id", user);
console.log("Output:", getJSON(ArticlePaginator.paginate([article])));
})();
Relationships
The Relator
class is used to generate top-level included data as well as resource-level relationships. Its methods are not meant to be called.
Relator
s may also take optional Linker
s (using the linker
option) to define relationship links and related resource links.
The following example constructs a Relator
for User
s and Article
s:
import { Serializer, Relator } from "../src";
import { User, Article } from "../test/models";
import { getJSON } from "../test/utils/get-json";
const ArticleSerializer = new Serializer<Article>("articles");
const UserArticleRelator = new Relator<User, Article>(
async (user) => user.getArticles(),
ArticleSerializer
);
(async () => {
const user = new User("sample_user_id");
const article = new Article("same_article_id", user);
User.save(user);
Article.save(article);
console.log("Output:", getJSON(await UserArticleRelator.getRelationship(user)));
})();
Metadata
The Metaizer
class is used to construct generate metadata given some dependencies. There are several locations Metaizer
can be used:
Like Linker
, its methods are not meant to be called.
The following example constructs a Metaizer
:
import { User, Article } from "../test/models";
import { Metaizer } from "../src";
import { getJSON } from "../test/utils/get-json";
const UserArticleMetaizer = new Metaizer((user: User, articles: Article | Article[]) => {
return Array.isArray(articles)
? { user_created: user.createdAt, article_created: articles.map((a) => a.createdAt) }
: { user_created: user.createdAt, article_created: articles.createdAt };
});
(async () => {
const user = new User("sample_user_id");
const article = new Article("same_article_id", user);
console.log("Output:", getJSON(UserArticleMetaizer.metaize(user, article)));
})();
Serializing Errors
The ErrorSerializer
class is used to serialize any object considered an error (the attributes
option allows you to choose what attributes to use during serialization). Alternatively (recommended), you can construct custom errors by extending the JapiError
class and use those for all server-to-client errors.
The error serializer test includes an example of the alternative solution.
The following example constructs the most basic ErrorSerializer
: (Note the lack of await
)
import { ErrorSerializer } from "../src";
import { getJSON } from "../test/utils/get-json";
const PrimitiveErrorSerializer = new ErrorSerializer();
(async () => {
const error = new Error("badness");
console.log("Output:", getJSON(PrimitiveErrorSerializer.serialize(error)));
})();
Caching
The Cache
class can be placed in a Serializer
's cache
option. Alternatively, setting that option to true
will provide a default Cache
.
The default Cache
uses the basic Object.is
function to determine if input data are the same. If you want to adjust this, instantiate a new Cache
with a resolver
.
Deserialization
We stress the following: There are many clients readily built to consume JSON:API endpoints (see here). It is highly recommended to use them and only use this for serialization. It would be an anti-pattern not to do so since the problem of serialization and deserialization generally have distinct solutions (think P vs. NP).
For inquisitive developers: To be precise, serialization is optimized by increasing runtime data storage and decreasing computation time (with e.g., caching and stored functions). Deserialization is somewhat dual to serialization; it is increasingly computational with storage proportional to the desired formatting. Perhaps an abstract directed binary tree (ADBT) could be helpful? It turns out the design of JSON:API is not very tree-like (think about the locations the relationships and identifiers can go), so by the time data gets transfigured into an ADBT, we would have finished serializing the data directly.
tl;dr: Serialization and deserialization are different types of actions for different paradigms, therefore they must be in different packages.
There are several model classes used inside TS:JAPI such as Resource
and Relationships
. These models are used for normalization as well as traversing a JSON:API document. If you plan to fork this repo, you can extend these models and reimplement them to create your own custom (non-standard, extended) serializer.
FAQ
Why not just allow optional functions that return the internal Link
Class (or just a URI string
)?
The Link
class is defined to be as general as possible in case of changes in the specification. In particular, the implementation of metadata and the types in our library rely on the generality of the Link
class. Relying on user arguments will generate a lot of overhead for both us and users whenever the specs change.
Why does the Meta
class exist if it is essentially just a plain object?
In case the specification is updated to change the meta objects in some functional way.
What is "resource recursion"?
Due to compound documents, it is possible to recurse through related resources via their resource linkages and obtain included resources beyond what the primary data gives. This is not preferable and should be done with caution (see SerializerOptions.depth
and this example)
Is the "zero dependencies" a gimmick?
In general, some packages obtain "zero dependencies" by simply hardcoding packages into their libraries. This can sometimes lead to an undesirable bulk for final consumers of the package. For us, we just couldn't find a package that can do what we do faster. For example, even is-plain-object
(which is useful, e.g., for identifying classes over "plain" objects) has some unnecessary comparisons that we optimized upon.
Contributing
This project is maintained by the author, however contributions are welcome and appreciated.
You can find TS:JAPI on GitHub: https://github.com/mu-io/ts-japi
Feel free to submit an issue, but please do not submit pull requests unless it is to fix some issue.
For more information, read the contribution guide.
License
Copyright © 2020 mu-io.
Licensed under Apache 2.0.