Skip to content

Latest commit

 

History

History
393 lines (283 loc) · 16.3 KB

README.markdown

File metadata and controls

393 lines (283 loc) · 16.3 KB

jsonapi-transformers

This is a library for transforming between JSON:API responses and Typescript classes.

Specifically, it allows you to use natural Typescript classes in your application, and provides a light-touch way to transform to and from JSON:API representations.

Installing

You need to be using Node 14-18.

npm install jsonapi-transformers

What does the library do?

You can define Typescript classes to represent your REST entities, and add Typescript decorators that describe how to convert between these classes and their JSON:API representation.

This provides a way to have pleasant Typescript types for your application, but with the ability to convert to and from JSON:API when you need to interact with your APIs.

It's probably also worth knowing what this library doesn't do:

  • There's no HTTP layer - this is purely about serialisation - so you can use it with the HTTP layer of your choice

  • As a result, there's no intention to create a seamless client-server syncing experience.

A worked example

Imagine a simple blog application with a couple of Typescript classes declaring the major types:

export class Author {
  lastLoginDateTime: string;
  name: string;
}

export class Tag {
  label: string;
}

export class BlogPost {
  createdDateTime: string;
  title: string;
  content: string;
  author: Author;
  tags: Tag[];
}

(Yes: you might normally use interfaces for this. We'll address that later.)

A concrete example using these types might be:

const david = new Author({
  id: "david",
  name: "David Brooks",
  lastLoginDateTime: "2021-07-24T11:00:00.000Z",
});

const tag1 = new Tag({
  id: "tag1",
  label: "one",
});

const tag2 = new Tag({
  id: "tag2",
  label: "two",
});

const post1 = new BlogPost({
  id: "post1",
  title: "Introducing jsonapi-transformers",
  content: "<strong>It lives!</strong>",
  author: david,
  tags: [tag1, tag2],
  createdDateTime: "2021-06-24T10:00:00.000Z",
});

These might have the following counterpart representations in JSON:API.

For our author:

{
  "id": "david",
  "type": "authors",
  "attributes": {
    "name": "David Brooks"
  },
  "meta": {
    "lastLoginDateTime": "2021-07-24T11:00:00.000Z"
  },
  "links": {
    "self": "https://example.com/my-jsonapi/authors/david"
  }
}

a tag:

{
  "id": "tag1",
  "type": "tags",
  "attributes": {
    "label": "one"
  }
}

and our post:

{
  "id": "post1",
  "type": "blog_posts",
  "attributes": {
    "title": "Introducing jsonapi-transformers",
    "content": "<strong>It lives!</strong>"
  },
  "relationships": {
    "author": {
      "data": {
        "id": "david",
        "type": "authors"
      }
    },
    "tags": {
      "data": [
        {
          "id": "tag1",
          "type": "tags"
        },
        {
          "id": "tag2",
          "type": "tags"
        }
      ]
    }
  },
  "meta": {
    "createdDateTime": "2021-06-24T10:00:00.000Z"
  },
  "links": {
    "self": "https://example.com/my-jsonapi/blog_posts/post1"
  }
}

So the question is, how can we map between each Typescript type and its JSON:API representation?

Adding JSON:API with decorators

This library allows us to augment such classes with Typescript decorators to declare how our class instances will be translated into JSON:API representation. Here is how we extend our example classes:

@entity({ type: "authors" })
export class Author extends JsonapiEntity {
  @meta() lastLoginDateTime: string;
  @attribute() name: string;
}

@entity({ type: "tags" })
export class Tag extends JsonapiEntity {
  @attribute() label: string;
}

@entity({ type: "blog_posts" })
export class BlogPost extends JsonapiEntity {
  @meta() createdDateTime: string;
  @attribute() title: string;
  @attribute() content: string;
  @link() self: string;
  @relationship() author: Author;
  @relationship() tags: Tag[];
}

We can use these instances exactly as we did before - they look like natural classes to the application. However, there are a few important differences to note:

First, we used a Typescript class to define our type, and that class must extend JsonapiEntity, which ensures that our class provides the mandatory id and type properties that will be needed to send these to a JSON:API API endpoint.

Second, we used the @entity decorator to bind our class to a JSON:API entity type. Note that entity is a function accepting a JSON object as its sole argument, and the type (as it appears in the JSON:API entity definition) must be explicitly provided. This is how jsonapi-transformers connects the class with its serialised form.

Third, each class property that should be serialized to JSON:API should be marked with a decorator appropriate to how it is serialized: @attribute, @relationship, @meta, or @link. We will explore these a little more below.

Decorators

@entity

The @entity decorator must be applied to a class, and that class must be a subtype of JsonapiEntity. The decorator has the following options:

export interface EntityOptions {
  type: string;
}

The type is mandatory, and must match the type used by the backing API.

Attributes

Attributes are literal JSON properties that exist on an entity, which may be updated via the API. Constrast this with relationships - which reference other entities and can be updated - and meta properties, which cannot be updated.

The @attribute(options?: AttributeOptions) decorator must be applied to a property within a JsonapiEntity subtype, and permits the following options:

export interface AttributeOptions {
  name?: string;
}
  • name is optional. If you omit it, the property name within the class must exactly match the property name within the JSON:API representation. However, you can specify name to decouple these, and use a more natural name inside your application. This might be useful if your API uses a convention for naming that doesn't fit the application, e.g. snake-case property names: myComplexAttribute versus my_complex_attribute.

Relationships

Relationships exist between JsonapiEntitys, and come in two broad flavours: to-one relationships can have zero or one values; to-many relationships can have zero-to-many. These are used in slightly different ways, but share a decorator - @relationship(options?: RelationshipOptions), which must be applied to a property within a JsonapiEntity subtype, and permits the following configuration options:

export interface RelationshipOptions {
  allowUnresolvedIdentifiers?: boolean;
  name?: string;
}
  • allowUnresolvedIdentifiers is used when we want to fetch entities without also including their relationships, but we want to retain the ability to fetch them later at our discretion. More on that below.

  • name is optional. If you omit it, the property name within the class must exactly match the property name within the JSON:API representation. However, you can specify name to decouple these, and use a more natural name inside your application. This might be useful if your API uses a convention for naming that doesn't fit the application, e.g. snake-case property names: myComplexRelationship versus my_complex_relationship.

To-one relationships

We already have an example of a to-one relationship: BlogPost.author.

The crucial feature that marks it as "to-one" is that there is a single JsonapiEntity being referenced, rather than an array of JsonapiEntitys.

To-many relationships

To-many relationships instead use an array of JsonapiEntitys. We already have an example: BlogPost.tags.

Unresolved identifiers

Both of our previous examples assume that we always want to fetch a BlogPost from the API with its Authors and Tags populated. This is a common use-case and is therefore the default for this library. However, there are scenarios where we may wish to fetch an entity, and only populate its relationships at a later point.

To retain relationship identifiers, you need to work a little harder. Let's alter our existing example to capture this extra work:

@entity({ type: "blog_posts" })
export class BlogPost extends JsonapiEntity {
  @meta() createdDateTime: string;
  @attribute() title: string;
  @attribute() content: string;

  @relationship({ allowUnresolvedIdentifiers: true })
  author: OneUnresolvedIdentifierOr<Author>;

  @relationship({ allowUnresolvedIdentifiers: true })
  tags: ManyUnresolvedIdentifiersOr<Tag>;
}

Note that we must explicitly configure our relationships({ allowUnresolvedIdentifiers: true }) to enable this functionality.

In these cases, when the API response contains the related-entities, they will be resolved exactly as before:

  • author will be an Author instance
  • tags will be a Tag[], containing Tag instances

However, when the API response does not contain the related-entities, they will be resolved to a different type, called UnresolvedIdentifier

  • author will be an UnresolvedIdentifier instance
  • tags will be a UnresolvedIdentifier[], containing UnresolvedIdentifier instances

We can now query the API to find our BlogPost entities without these relationships being "included", but still retain the capability to fetch them later if we should need to do so.

There is a function called isUnresolvedIdentifier for testing whether we have an UnresolvedIdentifier or a true JsonapiEntity.

This is a differentiator against some other libraries (e.g. Yayson), which drop the relationship information if the related-resource is unavailable.

Meta properties

meta properties are not specific to an entity - they can appear at many levels within JSON:API representations - however they are available directly inside "resource objects", and are described thus:

meta: a meta object containing non-standard meta-information about a resource that can not be represented as an attribute or relationship.

This essentially means they are not part of the standard REST lifecycle - you cannot update meta properties when you send updates to the API. A common use-case is for derived or lifecycle properties - e.g. tracking when entities were created or last updated.

The @meta(options?: MetaOptions) decorator must be applied to a property within a JsonapiEntity subtype, and permits the following options:

export interface MetaOptions {
  name?: string;
}
  • name is optional. If you omit it, the property name within the class must exactly match the property name within the JSON:API representation. However, you can specify name to decouple these, and use a more natural name inside your application. This might be useful if your API uses a convention for naming that doesn't fit the application, e.g. snake-case property names: myMetaProperty versus my_meta_property.

Links

Links are predominantly a JSON:API feature to describe links where JSON:API resources reside. However, they can be useful in an application, so this library supports them.

The @link(options?: LinkOptions) decorator must be applied to a property within a JsonapiEntity subtype, and permits the following options:

export interface LinkOptions {
  name?: string;
}
  • name is optional. If you omit it, the property name within the class must exactly match the property name within the JSON:API representation. However, you can specify name to decouple these, and use a more natural name inside your application. This might be useful if your API uses a convention for naming that doesn't fit the application, e.g. snake-case property names: myLinkProperty versus my_link_property.

Creating instances of custom entities

The static .create method is exposed on any subtype of JsonapiEntity as syntactic sugar for property initialisation. Here's how our example classes might be used:

const post = BlogPost.create({
  id: 'blogpost1',
  createdDateTime: "2021-06-24T10:00:00.000Z",
  title: ,
  content: ,
  author: Author.create({ id: 'author1' }),
  tags: [Tag.create({ id: 'tag1' }), Tag.create({ id: 'tag2' })],
});

FAQs

Why can't I use Typescript interfaces?

Great question! Most Typescript code you write will use interfaces, not classes, because interfaces give all the advantages for type-safety, but are removed at runtime so don't have any consequence for your application bundle size. For this reason, interfaces tend to be preferred over classes in Typescript applications.

However, there are sometimes reasons to prefer classes over interfaces. Classes allow you to attach additional behaviours - such as decorators. It's not possible to use decorators with interfaces because they don't exist at runtime.

JSON:API serialisation is also an additional behaviour. If you were to use interfaces for your types, you'd still need to incur the costs of declaring whatever performs the serialisation - this library just takes the approach of binding this serialisation directly to the types, and therefore you need to use classes.

Why don't the examples use standard class constructors?

Another good one... ES6 class definitions mean the use of class to define classes, extends to subtype a class, and then construction would use (from our example above) new BlogPost(...).

You are at liberty to use these default constructors with this library, and would do so like this:

const post = new BlogPost();
post.id = "blogpost1";
post.createdDateTime = "2021-06-24T10:00:00.000Z";
post.title = "An introduction to blogging";
post.content = "If you take nothing else way, remember this one weird trick...";

const author1 = new Author();
author1.id = "author1";
post.author = author1;

const tag1 = new Tag();
tag1.id = "tag1";
const tag2 = new Tag();
tag2.id = "tag2";
post.tags = [tag1, tag2];

However, we have also provided some syntactic sugar to make it easier to construct and initialise properties. That looks like this:

const post = BlogPost.create({
  id: 'blogpost1',
  createdDateTime: "2021-06-24T10:00:00.000Z",
  title: ,
  content: ,
  author: Author.create({ id: 'author1' }),
  tags: [Tag.create({ id: 'tag1' }), Tag.create({ id: 'tag2' })],
});

We will use the second approach throughout the documentation, even though the previous examples with new still work.

(We tried to make this work with standard object construction, but due to property-initialisation ordering constraints between classes, we could not achieve this. The .create approach was introduced in v3 to replace this.)

Why did we need another library?

At the time this library was initially developed, we were building an Angular application that needed JSON:API, and we initially started using Yayson. Yayson is solid, well-established, and better-supported than this library.

However, we encountered a few specific issues in our use-cases that did not work with Yayson.

First, Yayson retains a single "store" of entities that it has deserialised (synced). The main implication of this is that the library encapulates its own state, and this is out of the developer's control. This library follows a functional pattern (pure functions, without internal state), so you can maintain your own state and pass it into serialisation functions as required. You can also implement a single store to sync against, should you prefer that.

Second, if Yayson cannot resolve a relationship to an entity, the information of that relationship (type/ID) is simply discarded. We wanted a little more control of that, where we could make a request for a JSON:API entity and be able to attach it to resources from a follow-up request. This use-case is therefore supported: if you want to keep relationship identifiers around, look for UnresolvedIdentifiers.

Migrations and breaking changes

Please see the CHANGELOG