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
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
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' },
byId: { path: '/users/{id}' },
byGroup: { path: '/users/groups/{group}', params: { group: 'general' } }
},
Blog: {
create: { method: 'post', path: '/blogs', headers: { 'X-Special-Header': 'value' } },
addComment: { method: 'put', path: '/blogs/{id}/comment' }
}
}
})
Parameters
If your method doesn't require any parameter, you can just call it without them:
client.User
.all()
.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 })
When a method requires a parameters and the method is called without it, Mappersmith will raise an error:
client.User.byId()
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()
And, of course, we can override the defaults:
client.User.byGroup({ group: 'cool' })
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
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' } })
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.
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.
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()
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 Requeststatus()
- Returns the status numbersuccess()
- Returns true for status greater than 200 and lower than 400headers()
- Returns an object with all headers, keys in lower caseheader(name)
- Returns the value of the headerdata()
- Returns the response data, if Content-Type
is application/json
it parses the response and returns an object
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()
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]
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()
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']
})
})
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()
** The default auth can be overridden with the explicit use of the auth parameter, example:
client.User.all({ auth: { username: 'bill', password: 'bill' } })
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' } })
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' } })
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'))
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.
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.
import { setRetryConfigs } from 'mappersmith/middleware/retry'
setRetryConfigs({
headerRetryCount: 'X-Mappersmith-Retry-Count',
headerRetryTime: 'X-Mappersmith-Retry-Time',
maxRetryTimeInSecs: 5,
initialRetryTimeInSecs: 0.1,
factor: 0.2,
multiplier: 2,
retries: 5,
validateRetry: (response) => true
})
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 })
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)
, ex: status(204)
response(responseData)
, ex: response({ user: { id: 1 } })
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 = {}
const client = forge(myManifest)
mockClient(client)
.resource('User')
.method('all')
.response({ allUsers: [{id: 1}] })
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())
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 = {}
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¶m2=B',
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/)
m.stringContaining('some-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
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.