is a lightweight rest client for node.js and the browser
Latest commit dd27b5c Sep 20, 2018

README.md

npm version Build Status Windows Tests

Mappersmith

Mappersmith is a lightweight rest client for node.js and the browser. It creates a client for your API, gathering all configurations into a single place, freeing your code from HTTP configurations.

Table of Contents

Installation

NPM

npm install mappersmith --save
# yarn add mappersmith

Browser

Download the tag/latest version from the dist folder.

Build from the source

Install the dependencies

yarn

Build

npm run build
npm run release # for minified version

Usage

To create a client for your API you will need to provide a simple manifest. If your API reside in the same domain as your app you can skip the host configuration. Each resource has a name and a list of methods with its definitions, like:

import forge from 'mappersmith'

const github = forge({
  clientId: 'github',
  host: 'https://status.github.com',
  resources: {
    Status: {
      current: { path: '/api/status.json' },
      messages: { path: '/api/messages.json' },
      lastMessage: { path: '/api/last-message.json' }
    }
  }
})

github.Status.lastMessage().then((response) => {
  console.log(`status: ${response.data()}`)
})

Commonjs

If you are using commonjs, your require should look like:

const forge = require('mappersmith').default

Configuring my resources

Each resource has a name and a list of methods with its definitions. A method definition can have host, path, method, headers, params, bodyAttr, headersAttr and authAttr. Example:

const client = forge({
  resources: {
    User: {
      all: { path: '/users' },

      // {id} is a dynamic segment and will be replaced by the parameter "id"
      // when called
      byId: { path: '/users/{id}' },

      // {group} is also a dynamic segment but it has default value "general"
      byGroup: { path: '/users/groups/{group}', params: { group: 'general' } }
    },
    Blog: {
      // The HTTP method can be configured through the `method` key, and a default
      // header "X-Special-Header" has been configured for this resource
      create: { method: 'post', path: '/blogs', headers: { 'X-Special-Header': 'value' } },

      // There are no restrictions for dynamic segments and HTTP methods
      addComment: { method: 'put', path: '/blogs/{id}/comment' },

      // `queryParamAlias` will map parameter names to their alias when
      // constructing the query string
      bySubject: { path: '/blogs', queryParamAlias: { subjectId: 'subject_id' } }
    }
  }
})

Parameters

If your method doesn't require any parameter, you can just call it without them:

client.User
  .all() // https://my.api.com/users
  .then((response) => console.log(response.data()))
  .catch((response) => console.error(response.data()))

Every parameter that doesn't match a pattern {parameter-name} in path will be sent as part of the query string:

client.User.all({ active: true }) // https://my.api.com/users?active=true

When a method requires a parameters and the method is called without it, Mappersmith will raise an error:

client.User.byId(/* missing id */)
// throw '[Mappersmith] required parameter missing (id), "/users/{id}" cannot be resolved'

Default Parameters

It is possible to configure default parameters for your resources, just use the key params in the definition. It will replace params in the URL or include query strings.

If we call client.User.byGroup without any params it will default group to "general"

client.User.byGroup() // https://my.api.com/users/groups/general

And, of course, we can override the defaults:

client.User.byGroup({ group: 'cool' }) // https://my.api.com/users/groups/cool

Renaming query parameters

Sometimes the expected format of your query parameters doesn't match that of your codebase. For example, maybe you're using camelCase in your code but the API you are calling expects snake_case. In that case, set queryParamAlias in the definition to an object that describes a mapping between your input parameter and the desired output format.

This mapping will not be applied to params in the URL.

client.Blog.all({ subjectId: 10 }) // https://my.api.com/blogs?subject_id=10

Body

To send values in the request body (usually for POST, PUT or PATCH methods) you will use the special parameter body:

client.Blog.create({
  body: {
    title: 'Title',
    tags: ['party', 'launch']
  }
})

By default, it will create a urlencoded version of the object (title=Title&tags[]=party&tags[]=launch). If the body used is not an object it will use the original value. If body is not possible as a special parameter for your API you can configure it through the param bodyAttr:

// ...
{
  create: { method: 'post', path: '/blogs', bodyAttr: 'payload' }
}
// ...

client.Blog.create({
  payload: {
    title: 'Title',
    tags: ['party', 'launch']
  }
})

NOTE: It's possible to post body as JSON, check the EncodeJsonMiddleware below for more information NOTE: The bodyAttr param can be set at manifest level.

Headers

To define headers in the method call use the parameter headers:

client.User.all({ headers: { Authorization: 'token 1d1435k' } })

If headers is not possible as a special parameter for your API you can configure it through the param headersAttr:

// ...
{
  all: { path: '/users', headersAttr: 'h' }
}
// ...

client.User.all({ h: { Authorization: 'token 1d1435k' } })

NOTE: The headersAttr param can be set at manifest level.

Basic auth

To define credentials for basic auth use the parameter auth:

client.User.all({ auth: { username: 'bob', password: 'bob' } })

The available attributes are: username and password. This will set an Authorization header. This can still be overridden by custom headers.

If auth is not possible as a special parameter for your API you can configure it through the param authAttr:

// ...
{
  all: { path: '/users', authAttr: 'secret' }
}
// ...

client.User.all({ secret: { username: 'bob', password: 'bob' } })

NOTE: A default basic auth can be configured with the use of the BasicAuthMiddleware, check the middleware section below for more information. NOTE: The authAttr param can be set at manifest level.

Timeout

To define the number of milliseconds before the request times out use the parameter timeout:

client.User.all({ timeout: 1000 })

If timeout is not possible as a special parameter for your API you can configure it through the param timeoutAttr:

// ...
{
  all: { path: '/users', timeoutAttr: 'maxWait' }
}
// ...

client.User.all({ maxWait: 500 })

NOTE: A default timeout can be configured with the use of the TimeoutMiddleware, check the middleware section below for more information. NOTE: The timeoutAttr param can be set at manifest level.

Alternative host

There are some cases where a resource method resides in another host, in those cases you can use the host key to configure a new host:

// ...
{
  all: { path: '/users', host: 'http://old-api.com' }
}
// ...

client.User.all() // http://old-api.com/users

Binary data

If the data being fetched is in binary form, such as a PDF, you may add the binary key, and set it to true. The response data will then be a Buffer in NodeJS, and a Blob in the browser.

// ...
{
  report: { path: '/report.pdf', binary: true }
}
// ...

Promises

Mappersmith does not apply any polyfills, it depends on a native Promise implementation to be supported. If your environment doesn't support Promises, please apply the polyfill first. One option can be then/promises

In some cases it is not possible to use/assign the global Promise constant, for those cases you can define the promise implementation used by Mappersmith.

For example, using the project rsvp.js (a tiny implementation of Promises/A+):

import RSVP from 'rsvp'
import { configs } from 'mappersmith'

configs.Promise = RSVP.Promise

All Promise references in Mappersmith use configs.Promise. The default value is the global Promise.

Response object

Mappersmith will provide an instance of its own Response object to the promises. This object has the methods:

  • request() - Returns the original Request
  • status() - Returns the status number
  • success() - Returns true for status greater than 200 and lower than 400
  • headers() - Returns an object with all headers, keys in lower case
  • header(name) - Returns the value of the header
  • data() - Returns the response data, if Content-Type is application/json it parses the response and returns an object
  • error() - Returns the last error instance that caused the request to fail or null

Middleware

The behavior between your client and the API can be customized with middleware. A middleware is a function which returns an object with two methods: request and response.

The request method receives an instance of the Request object and it must return a Request. The method enhance can be used to generate a new request based on the previous one.

The response method receives a function which returns a Promise resolving the Response. This function must return a Promise resolving the Response. The method enhance can be used to generate a new response based on the previous one.

You don't need to implement both methods, you can define only the phase you need.

Example:

const MyMiddleware = () => ({
  request(request) {
    return request.enhance({
      headers: { 'x-special-request': '->' }
    })
  },

  response(next) {
    return next().then((response) => response.enhance({
      headers: { 'x-special-response': '<-' }
    }))
  }
})

The middleware can be configured using the key middleware in the manifest, example:

const client = forge({
  clientId: 'myClient',
  middleware: [ MyMiddleware ],
  resources: {
    User: {
      all: { path: '/users' }
    }
  }
})

It can, optionally, receive resourceName, resourceMethod, #context and clientId. Example:

const MyMiddleware = ({ resourceName, resourceMethod, context, clientId }) => ({
  /* ... */
})

client.User.all()
// resourceName: 'User'
// resourceMethod: 'all'
// clientId: 'myClient'
// context: {}

NOTE: The request phase can be asynchronous, just return a promise resolving a request. Example:

const MyMiddleware = () => ({
  request(request) {
    return Promise.resolve(
      request.enhance({
        headers: { 'x-special-token': 'abc123' }
      })
    )
  }
})

The response phase can optionally receive a function called "renew". This function can be used to rerun the middleware stack. This feature is useful in some scenarios, for example, automatically refreshing an expired access token. Example:

const AccessTokenMiddleware = () => {
  // maybe this is stored elsewhere, here for simplicity
  let accessToken = null

  return () => ({
    request(request) {
      return Promise
        .resolve(accessToken)
        .then((token) => token || fetchAccessToken())
        .then((token) => {
          accessToken = token
          return request.enhance({
            headers: { 'Authorization': `Token ${token}` }
          })
        })
    },
    response(next, renew) {
      return next().catch(response => {
        if (response.status() === 401) { // token expired
          accessToken = null
          return renew()
        }

        return next()
      })
    }
  })
}

Then:

const AccessToken = AccessTokenMiddleware()
const client = forge({
  // ...
  middleware: [ AccessToken ],
  // ...
})

"renew" can only be invoked sometimes before it's considered an infinite loop, make sure your middleware can distinguish an error from a "renew". By default, mappersmith will allow 2 calls to "renew". This can be configured with configs.maxMiddlewareStackExecutionAllowed. It's advised to keep this number low. Example:

import { configs } from 'mappersmith'
configs.maxMiddlewareStackExecutionAllowed = 3

If an infinite loop is detected, mappersmith will throw an error.

Global middleware

Middleware can also be defined globally, so new clients will automatically include the defined middleware:

import forge, { configs } from 'mappersmith'

configs.middleware = [MyMiddleware]
// all clients defined from now on will automatically include MyMiddleware
  • Global middleware can be disabled for specific clients with the option ignoreGlobalMiddleware, e.g:
forge({
  ignoreGlobalMiddleware: true,
  // + the usual configurations
})

Context

Sometimes you may need to set data to be available to all your client's middleware. In this case you can use the setContext helper, like so:

import { setContext } from 'mappersmith'

const MyMiddleware = ({ context }) => ({
  /* ... */
})

setContext({ some: 'data'})

client.User.all()
// context: { some: 'data' }

This is specially useful when using mappermith coupled with back-end services. For instance you could define a globally available correlation id middleware like this:

import forge, { configs, setContext } from 'mappersmith'
import express from 'express'

const CorrelationIdMiddleware = ({ context }) => ({
  request(request) {
    return request.enhance({
      headers: {
        'correlation-id': context.correlationId
      }
    })
  }
})

configs.middleware = [CorrelationIdMiddleware]

const api = forge({ ... })

const app = express()
app.use((req, res, next) => {
  setContext({
    correlationId: req.headers['correlation-id']
  })
})

// Then, when calling `api.User.all()` in any handler it will include the
// `correlation-id` header automatically.

Note that setContext will merge the object provided with the current context instead of replacing it.

Built-in middleware

BasicAuth

Automatically configure your requests with basic auth

import BasicAuthMiddleware from 'mappersmith/middleware/basic-auth'
const BasicAuth = BasicAuthMiddleware({ username: 'bob', password: 'bob' })

const client = forge({
  middleware: [ BasicAuth ],
  /* ... */
})

client.User.all()
// => header: "Authorization: Basic Ym9iOmJvYg=="

** The default auth can be overridden with the explicit use of the auth parameter, example:

client.User.all({ auth: { username: 'bill', password: 'bill' } })
// auth will be { username: 'bill', password: 'bill' } instead of { username: 'bob', password: 'bob' }

CSRF

Automatically configure your requests by adding a header with the value of a cookie - If it exists. The name of the cookie (defaults to "csrfToken") and the header (defaults to "x-csrf-token") can be set as following;

import CSRF from 'mappersmith/middleware/csrf'

const client = forge({
  middleware: [ CSRF('csrfToken', 'x-csrf-token') ],
  /* ... */
})

client.User.all()

Duration

Automatically adds X-Started-At, X-Ended-At and X-Duration headers to the response.

import Duration from 'mappersmith/middleware/duration'

const client = forge({
  middleware: [ Duration ],
  /* ... */
})

client.User.all({ body: { name: 'bob' } })
// => headers: "X-Started-At=1492529128453;X-Ended-At=1492529128473;X-Duration=20"

EncodeJson

Automatically encode your objects into JSON

import EncodeJson from 'mappersmith/middleware/encode-json'

const client = forge({
  middleware: [ EncodeJson ],
  /* ... */
})

client.User.all({ body: { name: 'bob' } })
// => body: {"name":"bob"}
// => header: "Content-Type=application/json;charset=utf-8"

GlobalErrorHandler

Provides a catch-all function for all requests. If the catch-all function returns true it prevents the original promise to continue.

import GlobalErrorHandler, { setErrorHandler } from 'mappersmith/middleware/global-error-handler'

setErrorHandler((response) => {
  console.log('global error handler')
  return response.status() === 500
})

const client = forge({
  middleware: [ GlobalErrorHandler ],
  /* ... */
})

client.User
  .all()
  .catch((response) => console.error('my error'))

// If status != 500
// output:
//   -> global error handler
//   -> my error

// IF status == 500
// output:
//   -> global error handler

Log

Log all requests and responses. Might be useful in development mode.

import Log from 'mappersmith/middleware/log'

const client = forge({
  middleware: [ Log ],
  /* ... */
})

Retry

This middleware will automatically retry GET requests up to the configured amount of retries using a randomization function that grows exponentially. The retry count and the time used will be included as a header in the response. By default on requests with response statuses >= 500 will be retried.

v2

It's possible to configure the header names and parameters used in the calculation by providing a configuration object when creating the middleware.

If no configuration is passed when creating the middleware then the defaults will be used.

import Retry from 'mappersmith/middleware/retry/v2'

const retryConfigs = {
  headerRetryCount: 'X-Mappersmith-Retry-Count',
  headerRetryTime: 'X-Mappersmith-Retry-Time',
  maxRetryTimeInSecs: 5,
  initialRetryTimeInSecs: 0.1,
  factor: 0.2, // randomization factor
  multiplier: 2, // exponential factor
  retries: 5, // max retries
  validateRetry: (response) => response.responseStatus >= 500 // a function that returns true if the request should be retried
}

const client = forge({
  middleware: [ Retry(retryConfigs) ],
  /* ... */
})
v1 (deprecated)

The v1 retry middleware is now deprecated as it relies on global configuration which can cause issues if you need to have multiple different configurations.

import Retry from 'mappersmith/middleware/retry'

const client = forge({
  middleware: [ Retry ],
  /* ... */
})

It's possible to configure the header names and parameters used in the calculation by calling the deprecated setRetryConfigs method.

import { setRetryConfigs } from 'mappersmith/middleware/retry'

// Using the default values as an example
setRetryConfigs({
  headerRetryCount: 'X-Mappersmith-Retry-Count',
  headerRetryTime: 'X-Mappersmith-Retry-Time',
  maxRetryTimeInSecs: 5,
  initialRetryTimeInSecs: 0.1,
  factor: 0.2, // randomization factor
  multiplier: 2, // exponential factor
  retries: 5, // max retries
  validateRetry: (response) => response.responseStatus >= 500 // a function that returns true if the request should be retried
})

Timeout

Automatically configure your requests with a default timeout

import TimeoutMiddleware from 'mappersmith/middleware/timeout'
const Timeout = TimeoutMiddleware(500)

const client = forge({
  middleware: [ Timeout ],
  /* ... */
})

client.User.all()

** The default timeout can be overridden with the explicit use of the timeout parameter, example:

client.User.all({ timeout: 100 })
// timeout will be 100 instead of 500

Testing Mappersmith

Mappersmith plays nice with all test frameworks, the generated client is a plain javascript object and all the methods can be mocked without any problem. However, this experience can be greatly improved with the test library.

The test library has 4 utilities: install, uninstall, mockClient and mockRequest

install and uninstall

They are used to setup the test library, example using jasmine:

import { install, uninstall } from 'mappersmith/test'

describe('Feature', () => {
  beforeEach(() => install())
  afterEach(() => uninstall())
})

mockClient

mockClient offers a high level abstraction, it works directly on your client mocking the resources and their methods.

It accepts the methods:

  • resource(resourceName), ex: resource('Users')
  • method(resourceMethodName), ex: method('byId')
  • with(resourceMethodArguments), ex: with({ id: 1 })
  • status(statusNumber | statusHandler), ex: status(204) or status((request, mock) => 200)
  • response(responseData | responseHandler), ex: response({ user: { id: 1 } }) or response((request, mock) => ({ user: { id: request.body().id } }))
  • assertObject()

Example using jasmine:

import forge from 'mappersmith'
import { install, uninstall, mockClient } from 'mappersmith/test'

describe('Feature', () => {
  beforeEach(() => install())
  afterEach(() => uninstall())

  it('works', (done) => {
    const myManifest = {} // Let's assume I have my manifest here
    const client = forge(myManifest)

    mockClient(client)
      .resource('User')
      .method('all')
      .response({ allUsers: [{id: 1}] })

    // now if I call my resource method, it should return my mock response
    client.User
      .all()
      .then((response) => expect(response.data()).toEqual({ allUsers: [{id: 1}] }))
      .then(done)
  })
})

To mock a failure just use the correct HTTP status, example:

// ...
mockClient(client)
  .resource('User')
  .method('byId')
  .with({ id: 'ABC' })
  .status(422)
  .response({ error: 'invalid ID' })
// ...

The method with accepts the body and headers attributes, example:

// ...
mockClient(client)
  .with({
    id: 'abc',
    headers: { 'x-special': 'value'},
    body: { payload: 1 }
  })
  // ...

It's possible to use a match function to assert params and body, example:

import { m } from 'mappersmith/test'

mockClient(client)
  .with({
    id: 'abc',
    name: m.stringContaining('john'),
    headers: { 'x-special': 'value'},
    body: m.stringMatching(/token=[^&]+&other=true$/)
  })

The assert object can be used to retrieve the requests, example:

const mock = mockClient(client)
  .resource('User')
  .method('all')
  .response({ allUsers: [{id: 1}] })
  .assertObject()

console.log(mock.mostRecentCall())
console.log(mock.callsCount())
console.log(mock.calls())

response and status can accept functions to generate response body or status. This can be useful when you want to return different responses for the same request being made several times.

const generateResponse = () => {
  return (request, mock) => mock.callsCount() === 0
    ? {}
    : { user: { id: 1 } }
}

const mock = mockClient(client)
  .resource('User')
  .method('create')
  .response(generateResponse())

mockRequest

mockRequest offers a low level abstraction, very useful for automations.

It accepts the params: method, url, body and response

It returns an assert object

Example using jasmine:

import forge from 'mappersmith'
import { install, uninstall, mockRequest } from 'mappersmith/test'

describe('Feature', () => {
  beforeEach(() => install())
  afterEach(() => uninstall())

  it('works', (done) => {
    mockRequest({
      method: 'get',
      url: 'https://my.api.com/users?someParam=true',
      response: {
        body: { allUsers: [{id: 1}] }
      }
    })

    const myManifest = {} // Let's assume I have my manifest here
    const client = forge(myManifest)

    client.User
      .all()
      .then((response) => expect(response.data()).toEqual({ allUsers: [{id: 1}] }))
      .then(done)
  })
})

A more complete example:

// ...
mockRequest({
  method: 'post',
  url: 'http://example.org/blogs',
  body: 'param1=A&param2=B', // request body
  response: {
    status: 503,
    body: { error: true },
    headers: { 'x-header': 'nope' }
  }
})
// ...

It's possible to use a match function to assert the body and the URL, example:

import { m } from 'mappersmith/test'

mockRequest({
  method: 'post',
  url: m.stringMatching(/example\.org/),
  body: m.anything(),
  response: {
    body: { allUsers: [{id: 1}] }
  }
})

Using the assert object:

const mock = mockRequest({
  method: 'get',
  url: 'https://my.api.com/users?someParam=true',
  response: {
    body: { allUsers: [{id: 1}] }
  }
})

console.log(mock.mostRecentCall())
console.log(mock.callsCount())
console.log(mock.calls())

Match functions

mockClient and mockRequest accept match functions, the available built-in match functions are:

import { m } from 'mappersmith/test'

m.stringMatching(/something/) // accepts a regexp
m.stringContaining('some-string') // accepts a string
m.anything()
m.uuid4()

A match function is a function which returns a boolean, example:

mockClient(client)
  .with({
    id: 'abc',
    headers: { 'x-special': 'value'},
    body: (body) => body === 'something'
  })

Note: mockClient only accepts match functions for body and params mockRequest only accepts match functions for body and url

Gateways

Mappersmith has a pluggable transport layer and it includes by default three gateways: xhr, http and fetch. Mappersmith will pick the correct gateway based on the environment you are running (nodejs or the browser).

You can write your own gateway, take a look at XHR for an example. To configure, import the configs object and assign the gateway option, like:

import { configs } from 'mappersmith'
configs.gateway = MyGateway

It's possible to globally configure your gateway through the option gatewayConfigs.

HTTP

When running with node.js you can configure the configure callback to further customize the http/https module, example:

import fs from 'fs'
import https from 'https'
import { configs } from 'mappersmith'

const key = fs.readFileSync('/path/to/my-key.pem')
const cert =  fs.readFileSync('/path/to/my-cert.pem')

configs.gatewayConfigs.HTTP = {
  configure() {
    return {
      agent: new https.Agent({ key, cert })
    }
  }
}

The new configurations will be merged. configure also receives the requestParams as the first argument. Take a look here for more options.

XHR

When running in the browser you can configure withCredentials and configure to further customize the XMLHttpRequest object, example:

import { configs } from 'mappersmith'
configs.gatewayConfigs.XHR = {
  withCredentials: true,
  configure(xhr) {
    xhr.ontimeout = () => console.error('timeout!')
  }
}

Take a look here for more options.

Fetch

Mappersmith does not apply any polyfills, it depends on a native fetch implementation to be supported. It is possible assign the fetch implementation used by Mappersmith:

import { configs } from 'mappersmith'
configs.fetch = fetchFunction

Fetch is not used by default, you can configure it through configs.gateway.

import FetchGateway from 'mappersmith/gateway/fetch'
import { configs } from 'mappersmith'

configs.gateway = FetchGateway

// Extra configurations, if needed
configs.gatewayConfigs.Fetch = {
  credentials: 'same-origin'
}

Take a look here for more options.

Development

Running unit tests:

yarn test:browser
yarn test:node

Running integration tests:

yarn integration-server &
yarn test:browser:integration
yarn test:node:integration

Running all tests

node spec/integration/server.js &
yarn test

Compile and release

NODE_ENV=production yarn build

Contributors

Check it out!

https://github.com/tulios/mappersmith/graphs/contributors

License

See LICENSE for more details.