Get your copy of the book

Transforming the Purchasing Experience

Download
Ebook Retail Transformation Technology

Working with JSON API, Hassle-Free

  —  
 read

Ever wished to avoid long conversations with your backend devs about how your API should be structured? Trying to focus less on your requests/responses once you set them up properly?

If you'd like to take advantage of efficient caching strategies, let me introduce you to JSON API.

JSON API Spec

Without diving into a deep explanation of the JSON API spec, take a look at the core concepts to understand how both JSON API is structured and how datx works with it.

Let’s examine the following example:

{
  "type": "users",
  "id": "1",
  "attributes": {
    "full_name": "Danijel Buhin",
    "username": "buha",
    "role": "moderator"
  },
  "relationships": {
    "posts": {
      "data": [
        {
          "type": "posts",
          "id": "9"
        }
      ]
    }
  }
}

As described in the JSON API spec for resource object, every object must contain id and type top-level members and may contain attributes, relationships, links, and meta objects.

Our example is a simple user model that has a reference to many post models.

When we make a request to https://website.com/api/users/1, we should receive this object back along with the array of posts.

Modeling on frontend

Now that we know what we will receive from the backend, it is time to create the same models on the frontend side.

For that, we use datx with JSON API mixin.

What is datx?

Datx is a data store for use with the mobx state management library. It features support for observable properties (Attribute()), references to other models (Attribute({toMany: ‘posts’})) and first-class TypeScript support. It’s framework-agnostic so you can use just like mobx, from vanilla javascript to the newest framework out there.

JSON API mixin is an opt-in package and it basically giving our models and store level-up functionality for usage with JSON API specification (e.g. giving CRUD methods to the models and collection).

For more in-depth information you can visit datx documentation.

Defining our model

Looking at the user object that we receive from the backend, we can now easily create our model on the frontend. We will not bother with the Post model now, let’s just pretend that it exists.

Now, our base model gets the full power of jsonapi mixin methods.

import { Model, Attribute } from '@datx/core';
import { jsonapi } from from '@datx/jsonapi';

import { Post } from './models/Post';

class User extends jsonapi(Model) {
    public static type = 'users';

    @Attribute({ isIdentifier: true })
    public id!: string;

    @Attribute()
    public username!: string;

    @Attribute()
    public role!: string;

    @Attribute()
    public full_name!: string;

    @Attribute({ toMany: Post })
    public posts!: Post[];
}

Let’s break this example to smaller pieces:

1. We define our User model by extending datx’s Model class wrapped into the JSON API mixin.

We assign a static type property that in the real world example you want to name by exactly the type property in the resource object you get back from the server.

Type also serves as a root endpoint for these models and in case that type and endpoint are different (e.g endpoint is plural and type is singular) you can assign another static property called endpoint.

In this case, when you make a request to the server with the model (this will be a link to the section below) it will point to the /api/v1/forumUsers and you will receive back users type.

import { Model, Attribute } from '@datx/core';
import { jsonapi } from '@datx/jsonapi';

import { Post } from './models/Post';

class User extends jsonapi(Model) {
 public static type = 'users';
 public static endpoint = 'forumUsers';

2. We populate our model with the attributes we expect from the backend and their type (if you’re into typescript).

Note that we set id as an attribute, although id is listed as a top-level member of a resource object. In this case, that same id will be assigned to our id attribute.

@Attribute({ isIndentifier: true })
public id!: string;

@Attribute()
public username!: string;

@Attribute()
public role!: string;

@Attribute()
public full_name!: string;

3. We define our last attribute that is also a relationship or has a reference to other models. In this case, it is an array of Post models.

@Attribute({ toMany: Post })
    public posts!: Post[];
}

Defining our store (collection)

Now that we have our models up and running, now we have to create our store to manage them.

We create our store by extending the Collection class from datx wrapped in JSON API mixin.

Also, with the config from @datx/jsonapi, we set our baseUrl that will be used for fetching our models.

You can see a more in-depth configuration in the datx docs.

We define which types (models) are going to be used and stored within this collection, and now we’re good to go, our collection is now fully empowered by the jsonapi methods, just like our models.

import { Collection } from '@datx/core';
import { jsonapi, config } from '@datx/jsonapi';

import { Post } from './models/Post';
import { User } from './models/User';

config.baseUrl = 'https://website.com/api';
// ...other config stuff if needed

class AppStore extends jsonapi(Collection) {
    public static types = [Post, User];
}

Making the requests

Let’s now explore how those JSON API methods on model and collection actually work. Like I mentioned earlier, datx is a framework-agnostic library, but for these examples, we will use React.

First, we must make sure that our React app is set up properly, I will not bother you with that here, you can check out the documentation for the guide how to setup react app with datx.

Fetching users (Collection.getMany())

Lets first fetch all of our users and display them (for example for a landing page).

import { useEffect, useState } from 'react';

import { useStore } from './hooks/useStore';
import { User } from './store/models/Post';

function LandingPage() {
  const store = useStore();
  const [isFetching, setIsFetching] = useState(true);
  const [usersData, setUsersData] = useState<User[]>([]);

  async function getUsers() {
    const response = await store.getMany(User);
    setUsersData(response.data);
    setIsFetching(false);
  }

  useEffect(() => {
    getUsers();
  }, []);

  return (
    <div>
      {isFetching && 'Loading users...'}
      {!isFetching && (
        <ul>
          {usersData.map((user) => (
            <li key={user.id}>
              {user.full_name} - Posted {user.posts.length} posts
            </li>
          ))}
        </ul>
      )}
    </div>
  );
}

So basically, nothing spectacular going on here if you’re used to React code, the main thing to notice here is that we use our store to fetch User models from the server with getMany() method.

Using this method, you pass a model constructor (or model type as a string) as a first parameter, and datx will make a request to the API. URL path will be structured like this:

base_url/model_type|model_endpoint

Base URL that we’ve declared to the network configuration, and model’s type (or endpoint if they differ). So in this case our URL is https://website.com/api/users.

Fetching user (Collection.getOne())

Now when we route to the user profile page, we can grab the id from the query params to fetch a specific user from the API.

import { useEffect, useState, useCallback } from 'react';
import { useParams } from 'react-router-dom';

import { useStore } from './hooks/useStore';
import { User } from './store/models/Post';

function UserProfile() {
  const store = useStore();
  const params = useParams();
  const [isFetching, setIsFetching] = useState(true);
  const [user, setUser] = useState<User>(null);

  const getUser = useCallback(async () {
    const response = await store.getOne(User, params.id);
    setUser(response.data);
    setIsFetching(false);
  }, [store]);

  useEffect(() => {
    getUser();
  }, [getUser]);

  return (
    <div>
      {isFetching && 'Loading user information...'}
      {!isFetching && (
        <div>
          <h1>{user.full_name}</h1>
          <h2>Posts: ({user.posts.length})</h2>
          <hr />
          <ul>
            {user.posts.map((post) => (
              <li key={post.id}>
                <strong>{post.title}</strong> {post.likes_count} likes
              </li>
            ))}
          </ul>
        </div>
      )}
    </div>
  );
}

Here we use getOne() method to fetch a single Model from the API. We pass a User model constructor as a first argument, and id of the model we want to grab as the second parameter.

In this case, the URL structure would look like this:

base_url/model_type|model_endpoint/model_id

And the actual URL would be https://website.com/api/users/1.

Updating user (Model.save())

Let’s pretend that on our UserProfile page, we have a form component to update our information (in case we are watching our own profile). In our case, we would like to update our full name that is displayed to all users.

For that, we will use save() method on our User model. Model.save() method will either create a new on the backend (POST) or update a current one (PATCH), depending on the current state of the model.

So, when we hit that submit button in our form, the flow should look like this:

import { useState } from 'react';

import { useStore } from './hooks/useStore';

function EditUserInfo({ user }) {
  const store = useStore();
  const [isFetching, setIsFetching] = useState(true);
  const [value, setValue] = useState(user.full_name);

  function handleChange(event) {
    setValue(event.target.value);
  }

  async function handleSubmit(event) {
    event.preventDefault();
    setIsFetching(true);

    try {
      user.full_name = value;
      await user.save();
    } catch (response) {
      // handle response
    } finally {
      setIsFetching(false);
    }
  }

  return (
    <div>
      <form onSubmit={handleSubmit}>
        <input name="full_name" id="full_name" value={value} onChange={handleChange} />
        <button>Save</button>
      </form>
    </div>
  );
}

So, we directly mutate a user model, in this case, full_name attribute, and execute save() method. In case everything went well, our model is updated on both frontend and backend.

Deleting a post (Model.destroy() / Collection.removeAll())

Just like any other API method we explored, destroying a model is just as easy. Since our role on this app is “moderator”, we can easily remove any unwanted posts.

Let’s say we have a Dashboard with all reported posts, we can easily create methods for destroying a specific post model, or maybe wipe out the entire list of reported posts.

In case we’re not sure if all posts are against our app’s guidelines, we can examine them one by one first and remove them accordingly.

Let’s take a look at this example:

import { useState, useEffect, useCallback } from 'react';
import notification from 'notification-library';

import { useStore } from './hooks/useStore';
import { Post } from './store/models/Post';

function ModeratorDashboard() {
  const store = useStore();
  const [isRemoving, setIsRemoving] = useState(false);
  const [isFetching, setIsFetching] = useState(true);
  const [posts, setPosts] = useState([]);

  function handleRemoveOne(post) {
    return async () => {
      setIsRemoving(true);
      try {
        await post.destroy();
        notification.success('Post successfully removed');
      } catch (response) {
        // handle response
      } finally {
        setIsRemoving(false);
      }
    };
  }

  async function handleRemoveAll() {
    setIsRemoving(true);
    try {
      await store.removeAll(posts);
      notification.success('Posts successfully removed');
    } catch (response) {
      // handle response
    } finally {
      setIsRemoving(false);
      setPosts([]);
    }
  }

  const fetchPosts = useCallback(async () => {
    const response = await store.getMany(Post, {
      queryParams: {
        filter: {
          status: 'reported',
        },
      },
    });
    setPosts(response.data);
    setIsFetching(false);
  }, [store]);

  useEffect(() => {
    fetchPosts();
  }, [fetchPosts]);

  return (
    <div>
      {isFetching && 'Loading users...'}
      {!isFetching && posts.length < 1 && <h1>Hurray! There is no reported posts!</h1>}
      {!isFetching && posts.length > 0 && (
        <div>
          <h1>Reported posts ({posts.length})</h1>
          <button onClick={handleRemoveAll} disabled={isRemoving}>
            {isRemoving ? 'Please wait' : 'Delete all'}
          </button>
          <table>
            <thead>
              <tr>
                <th>#</th>
                <th>Title</th>
                <th>Author</th>
              </tr>
            </thead>
            <tbody>
              {posts.map((post) => (
                <tr key={post.id}>
                  <td>{post.id}</td>
                  <td>{post.title}</td>
                  <td>{post.author}</td>
                  <td>
                    <button onClick={handleRemoveOne(post.id)} disabled={isRemoving}>
                      x
                    </button>
                  </td>
                </tr>
              ))}
            </tbody>
          </table>
        </div>
      )}
    </div>
  );
}

First, we fetch all posts that have “reported” status, so we could easily track any unwanted posts. Here you can see all available request options that you can pass to model or collection API methods.

handleRemoveOne function in our component expects a post instance which we will use to execute the destroy() method on datx Model.

In case we want to wipe out the entire list, we execute store.removeAll() that will clear our list of posts.

For those who want to know more

Because everybody loves a little extra.

Pagination

When using pagination, we should, by the specification, receive a 4 keys in a top-level links object:

  • first: the first page
  • last: the last page
  • prev: the previous page
  • next: the next page

In case some of the links are unavailable, (e.g we are on the last page, and next should not exist) the key must have a value of null.

  1. Manually building URL with page parameters If we use pagination manually, we should set a page query parameter following the pagination strategy.

    For example: .../api/v1/posts?page[size]=20&page[number]=2 means that we will get 20 results per page and we want to get a second page from the server.

  2. Using datx to automatically fetch new results You can use the response object and fetch new data based on your requirement, in this example you can see how to fetch all results from the backend.

Query parameters

Query parameters are part of the RequestOptions object in datx, and you would use it like this:

store.request('posts', 'GET', undefined, {
 queryParams: {
  custom: {
    token: 'jiOILkjsOI987'
  }
 }
});

Request URL in this case would look like this: .../api/v1/posts?token=jiOILkjsOI987.

Filters

We have seen how we can filter data coming from the server, all we need to do is declare a filter object inside our RequestOptions > queryParams object.

Every key-value pair inside filters will be parsed into a query string before making a request.

So to recap, our request with filter would like this:

store.request('posts', 'GET', undefined, {
 queryParams: {
  filter: {
   status: 'reported'
  }
 }
});

Request URL would look like this: ../api/v1/posts?filter[status]=reported.

Compound documents

Sometimes, our models can contain resources that can be included along with regular resources to reduce the number of HTTP requests. Such resources are called “compound documents”.

When receiving compound documents, all resource objects (models) will be placed inside top level “included” array.

In datx model, we define “compound document” (another model) just like we would define a regular relationship.

@Attribute({ toMany: Comment })
public comments!: Comment;

After that, we would add ‘include’ property to our RequestOptions object:

store.request('posts', 'GET', undefined, {
 queryParams: {
  include: ['comment']
 }
});

When the request is successfully resolved, we should receive an array of comment models inside the ‘included’ array.

That is all folks (for now)

You've seen a basic usage of datx with JSON API specification.

We actively work on improving datx library and adding more flexible features and mixins.

Some of the latests updates included usage without mobx as a dependency, increased library coverage (@datx/angular, @datx/react...), and the possibility to make requests no matter what API specification you're using (even with JSON API) with @datx/network.

Feel free to contribute on github to make it even more hassle-free!

Nikola Heged wrote us via pigeon post to say he'll be designing the cover illustration.