ActiveResource.js: Creating a Robust JavaScript SDK for Your JSON API Quickly

Your company has recently launched its API and is looking to cultivate a user community around it. Given that most of your customer base will be working with JavaScript due to the API’s focus on simplifying web application development (similar to Twilio), it’s crucial to provide them with convenient tools.

While your RESTful API might be straightforward, you understand the need for something more developer-friendly than raw API calls. users are going to want to drop in a JavaScript package

Therefore, you’re developing a library, or perhaps a state management system for a web application that interacts with your internal API. In either case, minimizing repetitive code when performing CRUD operations on API resources is essential for maintainability and efficiency.

This is where ActiveResource.js comes in – a JavaScript ORM designed for seamless API interactions. Inspired by a project requirement to build a JavaScript SDK rapidly, it prioritizes conciseness and developer productivity.

ActiveResource.js draws inspiration from Ruby on Rails’ ActiveRecord ORM, adhering to the principles of “convention over configuration” and “exalt beautiful code.”

JavaScript SDK Principles

ActiveResource.js incorporates two key Ruby on Rails concepts:

  1. “Convention over configuration”: Assumptions are made about API endpoint structure, such as a /products endpoint for a Product resource, reducing configuration overhead and allowing developers to incorporate new API resources efficiently.
  2. “Exalt beautiful code”: As Rails’ creator DHH said it best— ActiveResource.js simplifies complex requests, abstracting away JSON manipulation and providing an elegant syntax for filtering, pagination, and managing nested relationships.

Before We Start

Currently, ActiveResource.js primarily supports APIs adhering to the JSON:API standard specification.

Resources for setting up a JSON:API server are readily available if you’re unfamiliar with the standard and wish to follow along. many good libraries

However, it’s worth noting that ActiveResource.js’s adaptability extends beyond a single API standard. Its interface can be tailored for custom APIs, which might be explored in future articles.

Setting Things Up

Begin by integrating active-resource into your project:

1
yarn add active-resource

Next, create a ResourceLibrary for your API, placing your ActiveResources in a dedicated directory, such as src/resources:

1
2
3
4
5
6
7
// /src/resources/library.js

import { createResourceLibrary } from 'active-resource';

const library = createResourceLibrary('http://example.com/api/v1');

export default library;

The createResourceLibrary function primarily requires the base URL of your API.

Building a CMS SDK

Let’s demonstrate ActiveResource.js by constructing a JavaScript SDK for a content management system API. Our CMS will feature users, posts, comments, and notifications.

Users will have the ability to manage posts (read, create, edit), engage with comments (read, add, delete), and receive notifications for new posts and comments.

For this tutorial, we’ll keep the focus solely on API interaction through ActiveResources, abstracting away specific view libraries (React, Angular, etc.) or state management solutions (Redux, etc.).

Starting with Users

We’ll commence by establishing a User resource to handle user management within the CMS.

Initially, let’s define a User resource class along with some basic attributes:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
// /src/resources/User.js

import library from './library';

class User extends library.Base {
  static define() {
    this.attributes('email', 'userName', 'admin');
  }
}

export default library.createResource(User);

For authentication, let’s assume you have an endpoint (/auth for instance) that handles user login (email and password submission) and returns an access token along with the user’s ID. A function named requestToken will manage this endpoint. Once authentication is successful, we want to retrieve the user’s complete data:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
import library from '/src/resources/library';
import User from '/src/resources/User';

async function authenticate(email, password) {
  let [accessToken, userId] = requestToken(email, password);

  library.headers = {
    Authorization: 'Bearer ' + accessToken
  };

  return await User.find(userId);
}

Here, library.headers is set to include an Authorization header with the accessToken. This ensures that subsequent requests made by the ResourceLibrary are authenticated.

The final step in authenticate involves a request to User.find(id), which queries the API endpoint /api/v1/users/:id. The response might resemble:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
{
  "data": {
    "type": "users",
    "id": "1",
    "attributes": {
      "email": "user@example.com",
      "user_name": "user1",
      "admin": false
    }
  }
}

The authenticate function will return an instance of the User class. You can then access the authenticated user’s attributes (e.g., for display purposes) using camelCase notation, aligning with JavaScript conventions.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
let user = authenticate(email, password);

console.log(user.id) // '1'
console.log(user.userName) // user1
console.log(user.email) // user@example.com

console.log(user.attributes()) /*
  {
    email: 'user@example.com',
    userName: 'user1',
    admin: false
  }
*/

You can retrieve individual attributes as object properties or obtain all attributes using user.attributes().

Implementing a Resource Index

Before proceeding with related resources (e.g., notifications), it’s beneficial to introduce a resource index file (src/resources/index.js). This serves two primary purposes:

  1. Streamlined imports: Allows destructuring multiple resources from src/resources using a single import statement.
  2. Resource initialization: Facilitates the initialization of all resources within the ResourceLibrary by calling library.createResource for each resource, which is necessary for ActiveResource.js to establish relationships between resources.
1
2
3
4
5
6
7
// /src/resources/index.js

import User from './User';

export {
  User
};

Now, let’s incorporate a related resource for UserNotification. Start by defining a Notification class that belongsTo the User class:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
// /src/resources/Notification.js

import library from './library';

class Notification extends library.Base {
  static define() {
    this.belongsTo('user');
  }
}

export default library.createResource(Notification);

Subsequently, include it in the resource index:

1
2
3
4
5
6
7
8
9
// /src/resources/index.js

import Notification from './Notification';
import User from './User';

export {
  Notification,
  User
};

Next, establish the relationship between notifications and the User class:

1
2
3
4
5
6
7
8
9
// /src/resources/User.js

class User extends library.Base {
  static define() {
    /* ... */

    this.hasMany('notifications');
  }
}

With this setup, after retrieving the user through authenticate, you can effortlessly load and display their notifications:

1
2
3
let notifications = await user.notifications().load();

console.log(notifications.map(notification => notification.message));

Alternatively, notifications can be included directly in the initial request for the authenticated user:

1
2
3
4
5
async function authenticate(email, password) {
  /* ... */
  
  return await User.includes('notifications').find(userId);
}

This showcases the flexibility of the provided DSL.

Exploring the DSL

Let’s review the capabilities of the DSL we’ve constructed so far.

You can query both collections and individual users:

1
2
3
4
5
6
let users = await User.all();
let user = await User.first();
user = await User.last();

user = await User.find('1');
user = await User.findBy({ userName: 'user1' });

Furthermore, you can refine queries using chainable relational methods:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
// Query and iterate over all users
User.each((user) => console.log(user));

// Include related resources
let users = await User.includes('notifications').all();

// Only respond with user emails as the attributes
users = await User.select('email').all();

// Order users by attribute
users = await User.order({ email: 'desc' }).all();

// Paginate users
let usersPage = await User.page(2).perPage(5).all();

// Filter users by attribute
users = await User.where({ admin: true }).all();

users = await User
  .includes('notifications')
  .select('email', { notifications: ['message', 'createdAt'] })
  .order({ email: 'desc' })
  .where({ admin: false })
  .perPage(10)
  .page(3)
  .all();

let user = await User
  .includes('notification')
  .select('email')
  .first();

Observe how queries can be composed using multiple chained modifiers and terminated with methods like .all(), .first(), .last(), or .each().

You have the option to create user instances locally or persist them on the server:

1
2
let user = User.build(attributes);
user = await User.create(attributes);

Once a user is persisted, modifications can be sent to the server for updates:

1
2
3
4
5
6
user.email = 'email@example.com';
await user.save();

/* or */

await user.update({ email: 'email@example.com' });

Deletion from the server is equally straightforward:

1
await user.destroy();

This fundamental DSL extends to related resources as well, as we’ll illustrate while developing the remaining CMS components: posts and comments.

Creating Posts

Define a Post resource class and associate it with the User class:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
// /src/resources/Post.js

import library from './library';

class Post extends library.Base {
  static define() {
    this.belongsTo('user');
  }
}

export default library.createResource(Post);
1
2
3
4
5
6
7
8
9
// /src/resources/User.js

class User extends library.Base {
  static define() {
    /* ... */
    this.hasMany('notifications');
    this.hasMany('posts');
  }
}

Remember to include Post in your resource index:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
// /src/resources/index.js

import Notification from './Notification';
import Post from './Post';
import User from './User';

export {
  Notification,
  Post,
  User
};

Now, let’s integrate the Post resource with a form for creating and editing posts. When accessing the form, a new Post instance is created. Changes in the form are reflected in the Post object:

1
2
3
4
5
6
7
import Post from '/src/resources/Post';

let post = Post.build({ user: authenticatedUser });

onChange = (event) => {
  post.content = event.target.value;
};

Next, implement an onSubmit handler for the form to persist the post to the server, incorporating error handling:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
onSubmit = async () => {
  try {
    await post.save();
    /* successful, redirect to edit post form */
  } catch {
    post.errors().each((field, error) => {
      console.log(field, error.message)
    });
  }
}

Editing Existing Posts

After saving a post, it’s linked to your API as a server-side resource. You can determine if a resource exists on the server using the persisted property:

1
if (post.persisted()) { /* post is on server */ }

ActiveResource.js supports the concept of “dirty attributes” for persisted resources, allowing you to track modifications since the last server synchronization.

Invoking save() on a persisted resource with changes triggers a PATCH request, transmitting only the modified attributes instead of the entire resource data.

Let’s track changes to the post.content attribute:

1
2
3
4
5
6
7
8
9
// /src/resources/Post.js

class Post extends library.Base {
  static define() {
    this.attributes('content');

    /* ... */
  }
}

With a persisted post, you can now implement editing functionality. The submit button can be conditionally enabled/disabled based on attribute changes:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
onEdit = (event) => {
  post.content = event.target.value;
}

onSubmit = async () => {
  try {
    await post.save();
  } catch {
    /* display edit errors */
  }
}

disableSubmitButton = () => {
  return !post.changed();
}

Methods are available for managing singular relationships, such as post.user(), if you need to modify the user associated with a post:

1
await post.updateUser(user);

This is analogous to:

1
await post.update({ user });

The Comment Resource

Next, create a Comment resource class and establish its relationship with Post. Since comments can be replies to either posts or other comments, we’ll use a polymorphic association:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
// /src/resources/Comment.js

import library from './library';

class Comment extends library.Base {
  static define() {
    this.attributes('content');

    this.belongsTo('resource', { polymorphic: true, inverseOf: 'replies' });
    this.belongsTo('user');

    this.hasMany('replies', {
      as: 'resource',
      className: 'Comment',
      inverseOf: 'resource'
    });
  }
}

export default library.createResource(Comment);

Ensure that Comment is included in /src/resources/index.js.

A slight addition to the Post class is required:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
// /src/resources/Post.js

class Post extends library.Base {
  static define() {
    /* ... */

    this.hasMany('replies', {
      as: 'resource',
      className: 'Comment',
      inverseOf: 'resource'
    });
  }
}

The inverseOf option in the hasMany definition for replies specifies that this relationship is the inverse of the polymorphic belongsTo definition for resource. The inverseOf property is crucial for establishing proper associations, especially for polymorphic relationships where automatic determination might not be accurate.

Managing Post Comments

The same DSL applies seamlessly to managing related resources. With our post-comment relationships in place, we can perform various operations:

Adding a new comment to a post:

1
2
3
onSubmitComment = async (event) => {
  let comment = await post.replies().create({ content: event.target.value, user: user });
}

Adding a reply to an existing comment:

1
2
3
onSubmitReply = async (event) => {
  let reply = await comment.replies().create({ content: event.target.value, user: user });
}

Modifying a comment:

1
2
3
onEditComment = async (event) => {
  await comment.update({ content: event.target.value });
}

Deleting a comment from a post:

1
2
3
onDeleteComment = async (comment) => {
  await post.replies().delete(comment);
}

Displaying Posts and Comments

Our SDK can power the display of paginated posts. Clicking a post loads a new page with the post and its associated comments:

1
2
3
4
5
6
7
import { Post } from '/src/resources';

let postsPage = await Post
  .order({ createdAt: 'desc' })
  .select('content')
  .perPage(10)
  .all();

This query retrieves the 10 most recent posts, optimizing the request by fetching only the content attribute.

Pagination can be implemented by handling user interactions (e.g., clicking a “Next Page” button) and dynamically loading subsequent pages:

1
2
3
4
5
6
7
onClickNextPage = async () => {
  postsPage = await postsPage.nextPage();

  if (!postsPage.hasNextPage()) {
    /* disable next page button */
  }
};

When a post link is clicked, you can load and display the full post data, including comments and replies:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
import { Post } from '/src/resources';

onClick = async (postId) => {
  let post = await Post.includes({ replies: 'replies' }).find(postId);

  console.log(post.content, post.createdAt);

  post.replies().target().each(comment => {
    console.log(
      comment.content,
      comment.replies.target().map(reply => reply.content).toArray()
    );
  });
}

Calling .target() on a hasMany relationship (e.g., post.replies()) provides an ActiveResource.Collection containing loaded comments.

This is important because post.replies().target().first() accesses the first loaded comment, whereas post.replies().first() initiates a request to fetch the first comment from the server (GET /api/v1/posts/:id/replies).

Replies for a post can also be requested separately, allowing for more specific queries. Modifiers like order, select, includes, where, perPage, page can be applied when querying hasMany relationships, similar to querying resources directly.

1
2
3
4
5
6
7
8
9
import { Post } from '/src/resources';

onClick = async (postId) => {
  let post = await Post.find(postId);
  
  let userComments = await post.replies().where({ user: user }).perPage(3).all();
  
  console.log('Your comments:', userComments.map(comment => comment.content).toArray());
}

Modifying Retrieved Resources

There are cases where you might need to modify data retrieved from the server before utilizing it. For example, you could wrap post.createdAt in a moment() object to present a user-friendly date and time representation:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
// /src/resources/Post.js

import moment from 'moment';

class Post extends library.Base {
  static define() {
    /* ... */

    this.afterRequest(function() {
      this.createdAt = moment(this.createdAt);
    });
  }
}

Immutability

ActiveResource.js can be configured to work with immutable objects, often preferred in state management systems, by adjusting the resource library settings:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
// /src/resources/library.js

import { createResourceLibrary } from 'active-resource';

const library = createResourceLibrary(
  'http://example.com/api/v1',
  {
    immutable: true
  }
);

export default library;

Integrating Authentication

To conclude, let’s integrate your authentication system with the User ActiveResource.

Assume your token-based authentication endpoint is /api/v1/tokens. Successful authentication returns the user’s data along with the authorization token.

Create a Token resource class associated with User:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
// /src/resources/Token.js

import library from './library';

class Token extends library.Base {
  static define() {
    this.belongsTo('user');
  }
}

export default library.createResource(Token);

Include Token in /src/resources/index.js.

Next, add a static authenticate method to your User resource class, and relate User to Token:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
// /src/resources/User.js

import library from './library';
import Token from './Token';

class User {
  static define() {
    /* ... */

    this.hasOne('token');
  }

  static async authenticate(email, password) {
    let user = this.includes('token').build({ email, password });

    let authUser = await this.interface().post(Token.links().related, user);
    let token = authUser.token();

    library.headers = { Authorization: 'Bearer ' + token.id };

    return authUser;
  }
}

This method utilizes resourceLibrary.interface() (JSON:API in this case) to send user data to /api/v1/tokens. The request would look like this:

1
2
3
4
5
6
7
8
9
{
  "data": {
    "type": "users",
    "attributes": {
      "email": "user@example.com",
      "password": "password"
    }
  }
}

The response includes the authenticated user and the authentication token:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
{
  "data": {
    "type": "users",
    "id": "1",
    "attributes": {
      "email": "user@example.com",
      "user_name": "user1",
      "admin": false
    },
    "relationships": {
      "token": {
        "data": {
          "type": "tokens",
          "id": "Qcg6yI1a5qCxXgKWtSAbZ2MIHFChHAq0Vc1Lo4TX",
        }
      }
    }
  },
  "included": [{
    "type": "tokens",
    "id": "Qcg6yI1a5qCxXgKWtSAbZ2MIHFChHAq0Vc1Lo4TX",
    "attributes": {
      "expires_in": 3600
    }
  }]
}

We then set the library’s Authorization header using token.id and return the user object, effectively replicating the behavior of User.find().

Now, User.authenticate(email, password) returns an authenticated user, and subsequent requests are authenticated using the access token.

Conclusion

ActiveResource.js simplifies JavaScript SDK development for managing API resources and their relationships. For further exploration, refer to the comprehensive documentation available README for ActiveResource.js.

This library aims to streamline your development process, and contributions are always welcome!

Licensed under CC BY-NC-SA 4.0