rest-resource

Simplify your REST API resources


Keywords
rest, api, model, cache, caching, express, restify, vue
License
MIT
Install
npm install rest-resource@0.11.0

Documentation

REST Resource

Simplified Interface for consuming REST APIs

REST Resource is a library that makes your life simpler when working with REST API endpoints. It takes RESTful Resource/Service URIs and simplifies them into a Class that can be called with various built-in methods. Think of it like a Model for REST API Endpoints.

Features:

What is a REST Resource?

REST is acronym for REpresentational State Transfer. It is architectural style for distributed hypermedia systems and was first presented by Roy Fielding in 2000 in his famous dissertation.

Like any other architectural style, REST also does have it’s own 6 guiding constraints which must be satisfied if an interface needs to be referred as RESTful.

Please read further documentation

Installation

npm install rest-resource

or

<script src="https://unpkg.com/rest-resource"></script>

Documentation

Please see Documentation

npm run serve-docs

Examples

Play around with a working example on CodePen

(assuming Node >= v12)

Given the URIs:

/users
/users/<id>
/roles
/roles/<id>
/groups
/groups/<id>

Code:

import Resource from 'rest-resource'

class UserResource extends Resource {
    static endpoint = '/users'

    greet() {
        console.log('I am %s, %s!', this.attributes.name, this.attributes.role)
    }
}

const arthur = await UserResource.detail(123)
// GET /users/123
arthur.greet()
// => I am Arthur, King of the Britons!

// Or, get many resources
const users = await UserResource.list()
// GET /users

let robin = users.resources[0]
robin.greet()
// => I am Brave Sir Robin, Knight of the Round Table!
console.log(robin.id)
// => 456
robin.set('weapon', 'Sword')
robin.save()
// PATCH { weapon: 'Sword' } to /users/456

Cached Resource Resolution

REST Resource has a caching mechanism that sorts out which resources your app needs and satisfies both requirements once that resource has been obtained, provided they're made within n seconds where n is a Resource's cacheMaxAge. Resource retrieval can be defined synchronously or asynchronously and REST Resource will still only need to make the initial request.

In the example below, only one request is made to /users/123, even though the data from that endpoint is required twice:

let user = await UserResource.detail(123)
// GET /users/123

user.greet()
// => I am Arthur, King of the Britons!

console.log(user === await UserResource.detail(123))
// => true

If an object has been retrieved and is called upon from other related models, REST Resource will resolve cross-referenced lookups, provided they're made within n seconds where n is a Resource's cacheMaxAge. In the example below, notice the groups attribute contains an already-retrieved Group Resource:

let arthur = await UserResource.detail(123)
console.log(arthur.attributes)
// => {
//        id: 123,
//        name: 'King Arthur',
//        weapon: 'Sword',
//        role: 1,
//        groups: [1]
//    }

let patsy = await UserResource.detail(654)
console.log(patsy.attributes)
// => {
//        id: 654,
//        name: 'Patsy',
//        weapon: 'Coconuts',
//        role: 2,
//        groups: [1, 2] // Notice this list contains an already retrieved group (1)
//    }

let arthurTitle = arthur.resolveAttribute('role.title')
let arthurGroup = arthur.resolveAttribute('groups.name')
// GET /roles/1
// GET /groups/1

let patsyTitle = patsy.resolveAttribute('role.title')
let patsyGroup = patsy.resolveAttribute('groups.name')
// GET /roles/2
// GET /groups/2
// Does not need to GET /groups/1

Changing cache lifespan:

class CustomResource extends Resource {
    static cacheMaxAge = 60 // Defaults to 10 seconds
    // ...
}

Turning cache off:

class NoCacheResource extends Resource {
    static cacheMaxAge = -1
    // ...
}

Related Resources

Related Resources can be wired up very easily.

Defining One to Many relationships

import Resource from 'rest-resource'

class RoleResource extends Resource {
    static endpont = '/roles'
}

class UserResource extends Resource {
    static endpoint = '/users'
    static related = {
        role: RoleResource
    }
}

let user = await UserResource.detail(321, { resolveRelated: true }) // Add resolveRelated: true here and it'll automatically resolve related resources
// GET /users/321
// GET /roles/<id>

// Using get() gets the attribute `title `on related key `role`
let title = user.get('role.title')
let name = user.get('name')
console.log('%s the %s!', name, title)
// => Tim the Enchanter!

console.log(user.get('role'))
// => RoleResource({ id: 654, title: 'Enchanter' })

Defining Many to Many relationships

Many to many relationships work exactly the same as One to Many relationships with one key difference: when using resource.get(attribute) where attribute is the field of a related resource, the returned value is not the related Resource instance, it's a RelatedManager instance:

class UserResource extends Resource {
    static endpoint = '/users'
    static related = {
        role: RoleResource,
        groups: GroupResource // Many to many relationship
    }
}

let user = await UserResource.detail(654)

console.log(user.attributes)
// => {
//        id: 654,
//        name: 'Patsy',
//        weapon: 'Coconuts',
//        role: 2,
//        groups: [1, 2]
//    }

let manager = user.rel('groups') // Many to many relationship

console.log(manager.resolved)
// => false

// REST Resource doesn't automatically resolve related lookups unless instructed by using resolveRelated
await manager.resolve()
// GET /groups/1
// GET /groups/2

console.log(manager.resolved)
// => true

console.log(manager.resources)
// => [GroupResource, GroupResource]

Using resolveRelated() and { resolveRelated: true }

When using ResourceClass.detail() and ResourceClass.list(), one of the available options is { resolveRelated: true }, which will automatically resolve related resources.

Using { resolveRelated: true } with ResourceClass.detail():

let user = await UserResource.detail(654, { resolveRelated: true })
// GET /users/654
// GET /roles/2
// GET /groups/1
// GET /groups/2
console.log('groups.name')
// => ["Some Group", "Another Group"]

Using { resolveRelated: true } with ResourceClass.list():

let users = await UserResource.list({ resolveRelated: true })
// GET /users
// GET /roles/1
// GET /roles/2
// GET /groups/1
// GET /groups/2

Using resourceInstance.resolveRelated():

let user = await UserResource.detail(654)
// GET /users/654
await user.resolveRelated()
// GET /roles/2
// GET /groups/1
// GET /groups/2

Additionally, you can also provide a list of managers that you want to resolve:

let user = await UserResource.detail(654)
// GET /users/654
await user.resolveRelated(['role']) // Will ignore all but "role" field (notice the "groups" were not resolved)
// GET /roles/2

Recursive resolving with { resolveRelatedDeep: true }

To resolve all attributes recursively, you can provide { resolveRelatedDeep: true } instead. This will retrieve all related resources, and any other related resources attached to those resources. Be careful when using this option as it may cause performance issues.

REST Resource automatically resolves properties from related lookups, then decides what fields it needs to call resource.resolveRelated()

let roger = await UserResource.detail(543)
// GET /users/543

let title = await roger.resolveAttribute('role.title')
// GET /roles/<id>

console.log(title)
// => Shrubber

Note: resource.resolveAttribute(attribute) is one of the most useful features of REST Resource as it allows you to define the fields that are necessary to build your app, and REST Resource will intelligently request only the data it needs from the API!

Resource.related as a function

You can also define related resources with a function. This is useful when lazy loading modules or resolving circular dependencies:

class UserResource extends Resource {
    static endpoint = '/users'
    static related() {
        return {
            role: require('./resources/role').default
        }
    }
}

Working with Nested Objects

The response from the API might be returning nested objects. For example:

// GET /posts/1
{
    "title": "Nullam nibh enim, ultrices quis",
    "body": "Pellentesque ultrices augue eleifend augue posuere pretium. Nulla sodales turpis eget pretium efficitur.",
    "user": {
        "id": 123,
        "name": "King Arthur",
        "weapon": "Sword",
        "role": 1,
        "groups": [1]
    }
}

In the response body above, the post object contains a nested user object that you may want to cache. You can do so by setting nested: true when defining a relationship.

class PostResource extends Resource {
    static endpoint = '/posts'
    static related = {
        user: {
            to: UserResource,
            nested: true
        }
    }
}

Note: If for any reason you'd like to translate that nested object into a Primary Key, please see Attribute Normalization

Acts like a Model

You can use REST Resource like a RESTful Model. REST Resource tracks changes in each Resource instance's attributes and uses RESTful HTTP Verbs like GET, POST, PUT, PATCH, and DELETE accordingly:

let robin = new UserResource({
    name: 'Brave Sir Robin',
    weapon: 'Sword'
})

await robin.save()
// POST /users
let knight = new RoleResource({ title: 'Knight of the Round Table' })
await knight.save()
// POST /roles

robin.set('role', knight.id)
robin.save()
// PATCH /users/<robin-id>

robin.update()
// GET /users/<robin-id>

robin.runAway()
robin.delete()
// DELETE /users/<robin-id>

Resource Instance Defaults

You can also specify the defaults that a Resource instance should have:

class UserResource extends Resource {
    static endpoint = '/users'
    static defaults = {
        name: 'Unknown User',
        weapon: 'Hyperbolic Taunting',
        groups: [1]
    }
}

let unknown = new UserResource({ groups: [2] })
console.log(unknown.get('name'))
// => Unknown User

Working with older versions of JavaScript <= ES5

You can use Resource.extend to define static members. For example:

const UserResource = Resource.extend({
    endpoint: '/users',
    related: {
        // ...etc
    }
})

The Client Class

The Client class is a simple request library that is meant to be customized to suit your needs.

REST Resource assumes the API you are consuming is rendering JSON and authenticated endpoints are using a Bearer JWT

For requests, REST Resource uses a basic Axios client (JWTBearerClient class in src/client). You can override this by creating a custom client.

For more information on how Axios works, please refer to Axios documentation

Creating a client

When creating a custom client, you can override any methods you'd like. One method in particular you'll need to focus on is negotiateContent() which should return a function that parses the body of the response into a list of objects.

Assigning a Client

There are a number of ways you can set the client:

import Resource from 'rest-resource'
import { DefaultClient } from 'rest-resource/dist/client'

class CustomClient extends DefaultClient {
    negotiateContent(ResourceClass) {
        return (response) => {
            let objects = []
            if(Array.isArray(response.data)) {
                response.data.forEach((attributes) => objects.push(new ResourceClass(attributes)))
            } else {
                objects.push(new ResourceClass(response.data))
            }

            return {
                response,
                objects,
                pages: () => response.headers['Pagination-Count'],
                currentPage: () => response.headers['Pagination-Page'],
                perPage: () => response.headers['Pagination-Limit'],
            }
        }
    }
}

class CustomResource extends Resource {
    static client = new CustomClient('http://some-api.com')
}

// Or: (this method can also be used to override client globally by replacing "CustomResource" with "Resource")
CustomResource.client = new CustomClient('http://some-api.com')

JSON Web Tokens (JWT) and Authenticated Endpoints

Whenever the user wants to access a protected route or resource, the user agent should send the JWT, typically in the Authorization header using the Bearer schema. The content of the header should look like the following:

Authorization: Bearer <token>

For more information on how JWTs work, please see JSON Web Token Documentation

Customizing RelatedManager

Whenever a related field is defined, a manager is created to that field. You can customize this class by extending it and assigning it when you create a class.

import Resource from 'rest-resource'
import RelatedManager from 'rest-resource/dist/related'

class CustomRelatedManager extends RelatedManager {
    batchSize: 50 // Only GET 50 related objects at a time (default: Infinity)
    /**
     * @param options Object (resolveRelated, etc.)
     * @returns Resource[] List of Resource instances
     */
    resolve(options) {
        // etc
    }
}

class UserResource extends Resource {
    static endpoint = '/users'
    // Define custom related manager here
    static RelatedManagerClass = CustomRelatedManager
}

Validation

You can specify a validation object literal as a static member of any Resource class and run validations against any attribute. Validations are ran automatically when using resource.save(). Note: validation functions should throw exceptions.ValidationError(field, message)

import Resource from 'rest-resource'

function phoneIsValid(value, instance, ValidationError) {
    let regExp = /^[\+]?[(]?[0-9]{3}[)]?[-\s\.]?[0-9]{3}[-\s\.]?[0-9]{4,6}$/im
    if(!regExp.test(value)) {
        throw new ValidationError('phone')
    }
}

function isFiveChars(value, instance, ValidationError) {
    if(String(value).length < 5) {
        throw new ValidationError('', 'This field must be at least 5 characters')
    }
}

function isAlphaNumeric(value, instance, ValidationError) {
    let regExp = /^[a-z0-9]+$/i
    if(!regExp.test(value)) {
        throw new ValidationError('', 'This field must be alphanumeric')
    }
}

class UserResource extends Resource {
    static endpoint = '/users'
    static validation = {
        phone: phoneIsValid,
        username: [
            isFiveChars,
            isAlphaNumeric
        ]
    }
}

let user = await UserResource.detail(1)

user.set('phone', '415-555-1234')
user.validate()
// => []
user.set('phone', '555555x1234')
user.validate()
// => [{ ValidationError: phone: This field is not valid }]

Note: You can also define validations with a function. This is useful when resolving circular dependencies:

class UserResource extends Resource {
    static endpoint = '/users'
    static validation() {
        return {
            phone: phoneIsValid,
            username: [
                isFiveChars,
                isAlphaNumeric
            ]
        }
    }
}

Attribute Normalization

Whenever an attribute is defined, you can normalize its value into a desired output (think of it as a unidirectional serializer). The built-in normalizers are:

  • StringNormalizer
  • NumberNormalizer
  • BooleanNormalizer
  • CurrencyNormalizer

Attribute Normalizers can ensure that the values assigned to resource.attributes[key] (and subsequently the data being sent to the API) is consistent.

Normalizing Inconsistent Properties

Attribute Normalization is particularly useful when trying to normalize inconsistent data from the API. The example below represents two responses from the API, one representing a comment object and a post object. Both objects are referencing a user. However, the value of user on each comment and post objects are inconsistent (one is a primary key, and one is an nested user object):

// GET /comments/1
{
    "content": "Nullam vel ultrices eros. Nullam at metus venenatis, bibendum lectus sed",
    "post": 1,
    "user": 123
}

// GET /posts/1
{
    "title": "Nullam nibh enim, ultrices quis",
    "body": "Pellentesque ultrices augue eleifend augue posuere pretium. Nulla sodales turpis eget pretium efficitur.",
    "user": {
        "id": 123,
        "name": "King Arthur",
        "weapon": "Sword",
        "role": 1,
        "groups": [1]
    }
}

You can use attribute normalization to solve this issue

import Resource from 'rest-resource'
import { NumberNormalizer } from 'rest-resource/dist/helpers/normalization'

class PostResource extends Resource {
    static endpoint = '/posts'

    static related = {
        user: UserResource
    }
    
    static normalization = {
        user: new NumberNormalizer()
    }
}

class CommentResource extends Resource {
    static endpoint = '/comments'
    static related = {
        user: UserResource,
        post: PostResource
    }

    static normalization = {
        user: new NumberNormalizer(),
    }
}

let post = await PostResource.detail(1)
console.log(post.attributes.user)
// => 123

let comment = await CommentResource.detail(1)
console.log(comment.attributes.user)
// => 123

You can also specify custom normalizer

class PostResource extends Resource {
    static endpoint = '/posts'

    static related = {
        user: UserResource
    }
    
    static normalization = {
        user: {
            normalize: (value) => {
                // Normalize and return new value
            }
        }
    }
}

You can also use the normalizerFactory(normalizerClassName) helper instead of importing all normalizer classes:

import { normalizerFactory } from 'rest-resource/dist/helpers/normalization'

class PostResource extends Resource {
    static endpoint = '/posts'

    static related = {
        user: UserResource
    }
    
    static normalization = {
        user: normalizerFactory('NumberNormalizer')
    }
}

Note: If for any reason you'd like to automatically cache the nested object instead of translating it into a Primary Key, please see Working with Nested Objects

Calling Other Related/Nested Routes

You can wrap routes on the Resource's endpoint by using wrap(path, query) methods on the class constructor and its prototype. Once you define the wrapped endpoint, you can get(options), post(data, options), put(data, options), etc.

List routes

let wrappedRequest = PostResource.wrap('/pending')
let comments = await wrappedRequest.get()
// GET /posts/pending

Detail routes

let firstPost = await PostResource.detail(1)
// GET /posts/1
let wrappedRequest = firstPost.wrap('/comments')
let comments = await wrappedRequest.get()
// GET /posts/1/comments

With Filter/Query Params

You can provide a query on wrap(path, query) methods

let firstPost = await PostResource.detail(1)
// GET /posts/1
await firstPost.wrap('/comments', { approved: true, user: 1 }).get()
// GET /posts/1/comments?approved=true&user=1

POST, PUT, PATCH, etc.

let firstPost = await PostResource.detail(1)
// GET /posts/1

let approveEndpoint = firstPost.wrap('/approve')
await approveEndpoint.post({ approverId: 1 })
// POST /posts/1/approve 
// {
//     approverId: 1
// }

Note: If the instance of the Resource does not have an ID (eg. is not saved), an error will be thrown:

let newPost = new PostResource({ user: 1, title: "sunt aut facere repellat" })
newPost.wrap('/comments') // AssertionError!! (Resource is not saved)

Further Reading

Please see Documentation

npm run serve-docs