Skip to content
Permalink
Branch: master
Find file Copy path
1150 lines (902 sloc) 36.2 KB

Interceptors

Interceptor Principals

rest.js distinguishes itself from other HTTP client libraries by providing a minimal core that can be wrapped by more advanced behavior. These configured clients can then be consumed by our application. If a portion of our application needs more advanced behavior, it can continue to wrap the client without impacting other portions of the application. Functional programming FTW.

Each interceptor is a function that optionally accepts a parent client and some configuration returning a new client.

// don't do this, there's a better way
pathPrefix = require('rest/interceptor/pathPrefix');
errorCode = require('rest/interceptor/errorCode');
mime = require('rest/interceptor/mime');

client = pathPrefix(errorCode(mime(), { code: 500 }), { prefix: 'http://example.com' });

That works, but it's a mess, don't do it. The configuration is visually separated from the interceptor it belongs to. Glancing at the code, it's hard to know what's going on, never mind the fun of debugging when you give an interceptor the wrong config.

// better, but can still be improved
pathPrefix = require('rest/interceptor/pathPrefix');
errorCode = require('rest/interceptor/errorCode');
mime = require('rest/interceptor/mime');

client = mime();
client = errorCode(client, { code: 500 });
client = pathPrefix(client, { prefix: 'http://example.com' });

This example is much more clear. The configuration is now related to it's interceptor. However, it's a bit difficult to follow the client var. If we forget to provide the parent client to the next interceptor in the chain, the chain is broken and reset with the default client.

// here we go
rest = require('rest');
pathPrefix = require('rest/interceptor/pathPrefix');
errorCode = require('rest/interceptor/errorCode');
mime = require('rest/interceptor/mime');

client = rest.wrap(mime)
             .wrap(errorCode, { code: 500 })
             .wrap(pathPrefix, { prefix: 'http://example.com' });

In this last example, we're no longer redefining the client var, there's no confusion about what the client does and we can't forget to pass it along. It's clearly the combination of the default client, and the mime, errorCode and pathPrefix interceptors. The configuration for each interceptor is still directly linked with the interceptor.

It's important to consider the order that interceptors are applied, as some interceptors are more ideal near the root of the chain, while others are better being last. The request phase of the interceptors is applied from the last chained to the root, while the response phase flows in the opposite direction.

Clients may be declaratively configured using wire.js.

Provided Interceptors

Common Interceptors

Default Request Interceptor

rest/interceptor/defaultRequest (src)

Provide default values for the request object. Default values can be provided for the method, path, params, headers, entity, and/or mixin. If the value does not exist in the request already, then the default value is utilized. The method, path and entity values are direct copies, while the params, headers, and mixin values are mixed into the request. In no case will the interceptor overwrite a value in the request.

Phases

  • request

Configuration

Property Required? Default Description
method optional none default HTTP method
path optional none default path
params optional none default params, mixed into request
headers optional none default headers, mixed into request
entity optional none default entity
mixin optional none default extra parameters for the XHR object or Node.js.

Example

client = rest.wrap(defaultRequest, { method: 'PUT', entity: 'defaulted' });

client({});
// resulting request { method: 'PUT', entity: 'defaulted' }

client({ entity: 'custom' });
// resulting request { method: 'PUT', entity: 'custom' }
client = rest.wrap(defaultRequest, { headers: { 'X-Requested-With': 'rest.js' } });

client({});
// resulting request { headers: { 'X-Requested-With': 'rest.js' } }

client({ headers: { 'Some-Other-Header': 'still here' } });
// resulting request { headers: { 'Some-Other-Header': 'still here', 'X-Requested-With': 'rest.js' } }

client({ headers: { 'X-Requested-With': 'it a secret' } });
// resulting request { headers: { 'X-Requested-With': 'it a secret' } }

Hypermedia As The Engine Of Application State (HATEOAS) Interceptor

rest/interceptor/hateoas (src)

Indexes links properties inside an entity to make accessing the related resources easier to access.

Links are index in two ways:

  1. as link's rel which when accessed issues a request for the linked resource. A promise for the related resource is expected to be returned.
  2. as link's rel with 'Link' appended, as a reference to the link object.

The 'Link' response header is also parsed for related resources following rfc5988. The values parsed from the headers are indexed into the response.links object.

Also defines a clientFor factory function that creates a new client configured to communicate with a related resource.

The client for the resource reference and the clientFor function can be provided by the client config property. This method is also useful if the request for the resource

Index links are exposed by default on the entity. A child object may be configed by the 'target' config property.

The entire response object graph will be inspected looking for an Array property names links; object cycles are detected and not reindexed.

TIP: The MIME interceptor should be installed before the HATEOAS interceptor to convert the response entity from a string into proper JS Objects.

NOTE: Native EcmaScript 5 support is required to access related resources implicitly. Polyfills and shims are insufficient. Non-native environment can be supported by using the clientFor(rel) method, invoking the return client as normal.

WARNING: This interceptor is considered experimental, the behavior may change at any time

Phases

  • response

Configuration

Property Required? Default Description
target optional '' property to create on the entity and parse links into. If empty, the response entity is used directly.
client optional this client client to use for subsequent requests

Example

// assuming a native ES5 environment
client = rest.wrap(mime).wrap(hateoas);
client({ path: '/people/scott' }).then(function (response) {
    // assuming response for /people/scott: { entity: '{ "name": "Scott", "links": [ { "rel": "father", "href": "/peopele/ron" } ], ...  }', ... }
    // assuming response for /people/ron: { entity: '{ "name": "Ron", ... }', ... }

    assert.same('Scott', response.entity.name);
    return response.entity.father;
}).then(function (response) {
    assert.same('Ron', response.entity.name);
});
// fallback for non-native ES5 environments
client = rest.wrap(mime).wrap(hateoas);
client({ path: '/people/scott' }).then(function (response) {
    // assuming response for /people/scott: { entity: '{ "name": "Scott", "links": [ { "rel": "father", "href": "/peopele/ron" } ], ...  }', ... }
    // assuming response for /people/ron: { entity: '{ "name": "Ron", ... }', ... }

    assert.same('Scott', response.entity.name);
    response.entity.clientFor('father')({}).then(function (father) {
        assert.same('Ron', father.entity.name);
    });
});

Location Interceptor

rest/interceptor/location (src)

Follows the Location header, returning the response of the subsequent request. Browsers will typically automatically follow the location header for redirect in the 300s range, however, they will not follow the Location for a response in the 200s range. Other clients may not follow 300s redirects. This interceptor will always follow a redirect for the original request by default. If configured with code the response status code has to be equal or greater than code the be treated as a redirect.

Subsequent redirects can be automatically followed by including this interceptor twice in the client chain. However, in this situation, redirect loops will not be detected.

Phases

  • success

Configuration

Property Required? Default Description
client optional parent client client to use for subsequent requests
code optional 0 status code if equal or greater indicates a redirect

Example

client = rest.wrap(location);
client({ method: 'POST', path: 'http://example.com/messages', entity: 'hello world' }).then(function (response) {
    // assuming response for POST: { status: { code: 201 }, headers: { Location: 'http://example.com/messages/1' } }
    // assuming response for GET: { status: { code: 200 }, entity: 'hello world', ... }

    assert.same('hello wold', response.entity);
    assert.same('GET', response.request.method);
    assert.same('http://example.com/messages/1', response.request.path);
});

MIME Interceptor

rest/interceptor/mime (src)

Converts request and response entities using the MIME converter registry. Converters are looked up by the Content-Type header value. Content types without a converter default to plain text.

See the docs for the MIME registry for more information on available converters and how to register custom converters.

Phases

  • request
  • response

Configuration

Property Required? Default Description
mime optional Content-Type request header, or 'text/plain' MIME type for request entities
accept optional mime + ', application/json;q=0.8, text/plain;q=0.5, */*;q=0.2' Accept header to use for the request
registry optional default registry custom MIME registry
permissive optional false allow an unknown mime type for a request

Example

client = rest.wrap(mime);
client({ path: 'data.json' }).then(function (response) {
    // for the response: { entity: '{ "key": "value" }', headers: { 'Content-Type': 'application/json', ... } }
    assert.same('value', response.entity.key);
});
client = rest.wrap(mime, { mime: 'application/json' });
client({ method: 'POST', entity: { key: 'value' } }).then(function (response) {
    assert.same('{ "key": "value" }', response.request.entity);
    assert.same('application/json, application/json;q=0.8, text/plain;q=0.5, */*;q=0.2', response.request.headers['Content-Type']);
});

Path Prefix Interceptor

rest/interceptor/pathPrefix (src)

The path prefix interceptor prepends its value to the path provided in the request. The prefix can be used as a base path that the request path is then made relative to. A slash will be inserted between the prefix and path values if needed.

Phases

  • request

Configuration

Property Required? Default Description
prefix optional empty string value to prepend to the request path

Example

client = rest.wrap(pathPrefix, { prefix: 'http://example.com/messages' });
client({ path: '1' }).then(function (response) {
    assert.same('http://example.com/messages/1', response.request.path);
});

Template Interceptor

rest/interceptor/template (src)

The template interceptor fully defines the request URI by expending the path as a URI Template with the request params. Params defined by the template that are missing as well as additional params are ignored. After the template interceptor, the request.params are removed from the request object, as the URI is fully defined.

The URI Template RFC has many good examples that fully demonstrate its power and potential.

Phases

  • request

Configuration

Property Required? Default Description
template optional empty string default template if request.path is undefined
params optional empty object default params to be combined with request.params

Example

client = rest.wrap(template, { params: { lang: 'en-us' } });
client({ path: '/dictionary{/term:1,term}{?lang}', params: { term: 'hypermedia' } }).then(function (response) {
    assert.same('/dictionary/h/hypermedia?lang=en-us', response.request.path);
    assert.same(undefined, response.request.params);
});

Params Interceptor

rest/interceptor/template (src)

Deprecated

The params interceptor expands token in the path defined by the param named wrapped in curly braces. Unbound params are appended to the end of the path as a query string. The params object is consumed by this interceptor.

The Template Interceptor is recommended instead of this interceptor. It is more powerful and flexible.

Phases

  • request

Configuration

Property Required? Default Description
params optional empty object default params to be combined with request.params

Example

client = rest.wrap(params, { params: { lang: 'en-us' } });
client({ path: '/dictionary/{term}', params: { term: 'hypermedia' } }).then(function (response) {
    assert.same('/dictionary/hypermedia?lang=en-us', response.request.path);
    assert.same(undefined, response.request.params);
});

Authentication Interceptors

Basic Auth Interceptor

rest/interceptor/basicAuth (src)

Apply HTTP Basic Authentication to the request. The username and password can either be provided by the interceptor configuration, or the request.

Phases

  • request

Configuration

Property Required? Default Description
username optional none username for the authentication
password optional empty string password for the authentication

Example

client = rest.wrap(basicAuth, { username: 'admin', password: 'letmein' });
// interceptor config
client({}).then(function (response) {
    assert.same('Basic YWRtaW46bGV0bWVpbg==', response.request.headers.Authorization);
});
client = rest.wrap(basicAuth);
// request config
client({ username: 'admin', password: 'letmein' }).then(function (reponse) {
    assert.same('Basic YWRtaW46bGV0bWVpbg==', response.request.headers.Authorization);
});

OAuth Interceptor

rest/interceptor/oAuth (src)

Support for the OAuth implicit flow. In a separate window users are redirected to the authentication server when a new access token is required. That authentication server may prompt the user to authenticate and/or grant access to the application requesting an access token. The authentication server then redirects the user back to the application which then needs to parse the access token from the URL and pass it back to the intercept via a callback function placed on the window.

TIP: A client request may take a very long time to respond while the user is being prompted to authenticate. Once the user returns to the app, the original request is made with the new access token. If an access token expires, the next request may take a similarly long time to respond as a new token is obtained from the authorization server. The oAuth interceptor should typically be after time sensitive interceptors such as timeout.

IMPORTANT: rest.js is only able to provide part of the client flow. When the user is redirected back from the authentication server, the application server must handle the initial request and provide an HTML page with the scripts to parse the URL fragment containing the access token and provide the token to the callback function. As rest.js is not a server side web framework, it is unable to provide support for this part of the oAuth flow.

Phases

  • request
  • response

Configuration

Property Required? Default Description
token optional none pre-configured authorization token obtained by some other means, using this property is uncommon
clientId required none the authentication server clientId, this is given to you by the auth server owner
scope required none comma separated list of resource server scopes to request an access token for,
authorizationUrl required none base URL for the authorization server
redirectUrl requried none URL within this page's origin that the authorization server should redirect back to providing the access token
windowStrategy optional window.open strategy for opening the browser window to the authorization server
oAuthCallback optional none callback function to receive the access token, typically used with a custom windowStrategy
oAuthCallbackName optional 'oAuthCallback' name to register the callback function as on the window

Example

client = rest.wrap(oAuth, {
    clientId: 'assignedByAuthServer',
    scope: 'read, write, openid',
    authorizationUrl: 'http://authserver.example.com/oauth',
    redirectUrl: 'http://myapp.example.com/oauthhandler'
});
client({ path: 'http://resourceserver.example.com' }).then(function (response) {
    // authenticated response from the resource server
});

CSRF Interceptor

rest/interceptor/csrf (src)

Applies a Cross-Site Request Forgery protection header to a request

CSRF protection helps a server verify that a request came from a trusted client and not another client that was able to masquerade as an authorized client. Sites that use cookie based authentication are particularly vulnerable to request forgeries without extra protection.

Phases

  • request

Configuration

Property Required? Default Description
name optional 'X-Csrf-Token' name of the request header, may be overridden by `request.csrfTokenName`
token optional none CSRF token, may be overridden by `request.csrfToken`

Example

client = rest.wrap(csrf, { token: 'abc123xyz789' });
// interceptor config
client({}).then(function (response) {
    assert.same('abc123xyz789', response.request.headers['X-Csrf-Token']);
});
client = rest.wrap(csrf);
// request config
client({ csrfToken: 'abc123xyz789' }).then(function (reponse) {
    assert.same('abc123xyz789', response.request.headers['X-Csrf-Token']);
});

Error Detection and Recovery Interceptors

Error Code Interceptor

rest/interceptor/errorCode (src)

Marks a response as an error based on the status code. According to the HTTP spec, 500s status codes are server errors, 400s codes are client errors. rest.js by default will treat any response from a server as successful, this allows interceptors to define what constitutes an error. The errorCode interceptor will mark a request in error if the status code is equal or greater than the configured value.

Phases

  • response

Configuration

Property Required? Default Description
code optional 400 status code if equal or greater indicates an error

Example

client = rest.wrap(errorCode);
client({}).then(
    function (response) {
        // not called
    },
    function (response) {
        assert.same(500, response.status.code);
    }
);

Retry Interceptor

rest/interceptor/retry (src)

Reattempts an errored request after a delay. Attempts are scheduled after a failed response is received, the period between requests is the duration of request plus the delay.

Phases

  • error

Configuration

Property Required? Default Description
initial optional 100 initial delay in milliseconds after the first error response
multiplier optional 2 multiplier for the delay on each subsequent failure used for exponential back offs
max optional Infinity max delay in milliseconds

Example

client = rest.wrap(retry, { initial: 1e3, max: 10e3 });
client({}).then(function (response) {
    // assuming it takes a minute from the first request to a successful response
    // requests occur at 0s, 1s, 3s, 7s, 15s, 25s, 35s, 45s, 55s, 65s
});

Commonly combined with the timeout interceptor to define a max period to wait

client = rest.wrap(retry, { initial: 1e3, max: 10e3 }).wrap(timeout, { timeout 120e3 });
client({}).then(
    function (response) {
        // called once a request succeeds
    },
    function (response) {
        // called after two minutes waiting, no further retry attempts are made
    }
);

Timeout Interceptor

rest/interceptor/timeout (src)

Rejects a request that takes longer than the timeout. If a request is in-flight, it is canceled by default. The timeout value may be specified in the request or the interceptor config.

Phases

  • request
  • response

Configuration

Property Required? Default Description
timeout optional disabled duration in milliseconds before canceling the request. Non-positive values disable the timeout.
transient optional false disables the cancellation of timed out requests, allowing additional interceptors to gracefully handle the timeout.

Example

client = rest.wrap(timeout, { timeout: 10e3 });
client({}).then(
    function (response) {
        // called if the response took less then 10 seconds
    },
    function (response) {
        // called if the response took greater then 10 seconds
    }
);

Fallback Interceptors

JSONP Interceptor

rest/interceptor/jsonp (src)

Configures a request to use the JSONP client. For most JSONP services, the interceptor defaults are adequate. The script tag and callback function used to load the response, is automatically cleaned up after a response. The callback function may remain after a cancellation in order to avoid script errors in the response if the server responds.

Phases

  • request

Configuration

Property Required? Default Description
callback.param optional 'callback' request param containing the jsonp callback function name
callback.prefix optional 'jsonp' prefix for the jsonp callback function name
callback.name optional generated pins the name of the callback function, useful for cases where the server doesn't allow custom callback names. Generally not recommended.

Example

client = rest.wrap(jsonp);
client({ path: 'http://ajax.googleapis.com/ajax/services/search/web?v=1.0', params: { q: 'javascript' } }).then(function (response) {
    // results from google
});

Custom Interceptors

Creating a custom interceptor is easy. Fundamentally, an interceptor is a function that accepts a client and a configuration object and returns a new client. While not required, it's highly recommended that interceptor authors use the interceptor factory available in rest/interceptor.

The interceptor factory allows for interception of the request and/or response. Once the interceptor has a handle on the request or response, it can pass through, manipulate or replace the request/response.

There are five phases that may be intercepted

Phase Description
init one time setup of the interceptor config
request as the request is initiated, before the socket is opened
response after the response is fully received, success or error
success a response in a successful state (all responses from servers are successful until an interceptor puts them into an error state)
error a response in an error state (either a socket/api level error, or a server response handled as an error)

The response phase is a catchall for the success and error phases; if both response and success handlers are defined, and the request responds normally, then only the success phase fires.

Request handlers are functions that accept the request object and interceptor config. Response handlers are provided with the same arguments as request handlers, in addition to the client for the handler. The value returned by a handler becomes the request/response for the next handler in the interceptor chain.

interceptor = require('rest/interceptor');
noopInterceptor = interceptor({
    init: function (config) {
        // do stuff with the config
        return config;
    },
    request: function (request, config, meta) {
        // do stuff with the request
        return request;
    },
    response: function (response, config, meta) {
        // do stuff with the response
        return response;
    },
    success: function (response, config, meta) {
        // do stuff with the response
        return response;
    },
    error: function (response, config, meta) {
        // do stuff with the response
        return response;
    }
});

Promisses representing the request/response may be returned.

interceptor = require('rest/interceptor');
when = require('when');
delayRequestInterceptor = interceptor({
    request: function (request, config) {
        return when(request).delay(config.delay || 0);
    }
});

The meta argument contains additional information about the context of the request. It contains the client, which can be used to make subsequent requests, and the raw arguments provided to the client.

For interceptors that need to track state between request and response handlers, the context of each handler is shared and unique to each invocation.

interceptor = require('rest/interceptor');
counter = 0;
countLoggingInterceptor = interceptor({
    request: function (request) {
        this.count = counter++;
        return request;
    },
    response: function (response) {
        console.log('invocation count: ', this.count);
        return response;
    }
});

Success responses can be converted into errors by returning a rejected promise for the response.

interceptor = require('rest/interceptor');
when = require('when');
alwaysErrorInterceptor = interceptor({
    success: function (response) {
        return when.reject(response);
    }
});

Error responses can be converted into successes by returning a resolved promise for the response. This is a special ability of the error handler and is not applicable to the response handler.

interceptor = require('rest/interceptor');
when = require('when');
alwaysErrorInterceptor = interceptor({
    error: function (response) {
        return when(response);
    }
});

Interceptors may also override the default client if a parent client is not provided when instantiating the interceptor.

interceptor = require('rest/interceptor');
customDefaultClient = require(...);
customDefaultClientInterceptor = interceptor({
    client: customDefaultClient
});

Default configuration values can be provided in the init phase. The config object provided is begotten from the config object provided to the interceptor when created. This means that all the properties of the configuration are available, but updates are protected from causing side effects in other interceptors configured with the same config object.

interceptor = require('rest/interceptor');
defaultedConfigInterceptor = interceptor({
    init: function (config) {
        config.prop = config.prop || 'default-value';
        return config;
    }
});

Interceptor Best Practices

  • keep interceptors simple, focus on one thing
  • avoid replacing the request object, augment it instead
  • make properties configurable
  • provide sane defaults for configuration properties, avoid required config
  • allow a request to override configured values
  • provide default configuration values in the 'init' handler

Example Interceptors by Concept

The interceptors provided with rest.js provide are also good examples. Here are a few interceptors that demonstrate a particular capability. The order of examples within a topic is simple to complex.

Augmented Request/Response

Config Initialization

Replaced Request/Response

Reentrent Clients

Error Creators

Error Recovery

Cancellation

Sharred Request/Response Context

Async Request/Response

Abort Request (ComplexRequest)

You can’t perform that action at this time.
You signed in with another tab or window. Reload to refresh your session. You signed out in another tab or window. Reload to refresh your session.