Endpoint

Source
import { Endpoint } from "@prestojs/rest";

Describe an REST API endpoint that can then be executed.

  • Accepts a UrlPattern to define the URL used. Any arguments & query parameters can be passed at execution time
  • Accepts a decodeBody function that decodes the Response body as returned from fetch. The default decodeBody will interpret the response based on the content type
    • If type includes 'json' (eg. application/json) returns decoded json
    • If type includes 'text (eg. text/plain, text/html) returns text
    • If status is 204 or 205 will return null
  • middleware can be passed to transform the request before it is passed to fetch and/or the response after it has passed through decodeBody.
  • All options accepted by fetch and these will be used as defaults to any call to execute or prepare.

Usage:

const userList = new Action(new UrlPattern('/api/users/'));
const users = await userList.execute();

You can pass urlArgs and query to resolve the URL:

const userDetail = new Action(new UrlPattern('/api/user/:id/'));
// Resolves to /api/user/1/?showAddresses=true
const user = await userDetail.execute({ urlArgs: { id: 1 }, query: 'showAddresses': true });

You can also pass through any fetch options to both the constructor and calls to execute and prepare

// Always pass through Content-Type header to all calls to userDetail
const userDetail = new Action(new UrlPattern('/api/user/:id/'), {
'Content-Type': 'application/json'
});
// Set other fetch options at execution time
userDetail.execute({ urlArgs: { id: 1 }, method: 'PATCH', body: JSON.stringify({ name: 'Dave' }) });

Often you have some global options you want to apply everywhere. This can be set on Endpoint directly:

// Set default options to pass through to the request init option of `fetch`
Endpoint.defaultConfig.requestInit = {
headers: {
'X-CSRFToken': getCsrfToken(),
},
};
// All actions will now use the default headers specified
userDetail.execute({ urlArgs: { id: 1 } });

You can also "prepare" an action for execution by calling the prepare method. Each call to prepare will return the same object (ie. it passes strict equality checks) given the same parameters. This is useful when you need to have a stable cache key for an action. For example you may have a React hook that executes your action when things change:

import useSWR from 'swr';
...
// prepare the action and pass it to useSWR. useSWR will then call the second parameter (the "fetcher")
// which executes the prepared action.
const { data } = useSWR([action.prepare()], (preparedAction) => preparedAction.execute());

You can wrap this up in a custom hook to make usage more ergonomic:

import { useCallback } from 'react';
import useSWR from 'swr';
// Wrapper around useSWR for use with `Endpoint`
// @param action Endpoint to execute. Can be null if not yet ready to execute
// @param args Any args to pass through to `prepare`
// @return Object Same values as returned by useSWR with the addition of `execute` which
// can be used to execute the action directly, optionally with new arguments.
export default function useEndpoint(action, args) {
const preparedAction = action ? action.prepare(args) : null;
const execute = useCallback(init => preparedAction.execute(init), [preparedAction]);
return {
execute,
...useSWR(preparedAction && [preparedAction], act => act.execute()),
};
}

Pagination for an endpoint is handled by paginationMiddleware. This middleware will add a getPaginatorClass method to the Endpoint which makes it compatible with usePaginator. The default implementation chooses a paginator based on the shape of the response (eg. if the response looks like cursor based paginator it will use CursorPaginator, if page number based PageNumberPaginator or if limit/offset use LimitOffsetPaginator - see InferredPaginator. The pagination state as returned by the backend is stored on the instance of the paginator:

const paginator = usePaginator(endpoint);
// This returns the page of results
const results = await endpoint.execute({ paginator });
// This now has the total number of records (eg. if the paginator was PageNumberPaginator)
paginator.total

You can calculate the next request state by mutating the paginator:

paginator.next()
// The call to endpoint here will include the modified page request data, eg. ?page=2
const results = await endpoint.execute({ paginator });

See usePaginator for more details about how to use a paginator in React.

If your backend returns data in a different shape or uses headers instead of putting details in the response body you can handle this by a) implementing your own paginator that extends one of the base classes and customising the getPaginationState function or b) passing the getPaginationState method to usePaginator. This function can return the data in the shape expected by the paginator.

Middleware functions can be provided to alter the url or fetch options and transform the response in some way.

Middleware can be defined as either an object or as a function that is passed the url, the fetch options, the next middleware function and a context object. The function can then make changes to the url or requestInit and pass it through to the next middleware function. The call to next returns a Promise that resolves to the response of the endpoint after it's been processed by any middleware further down the chain. You can return a modified response here.

This middleware sets a custom header on a request but does nothing with the response:

function clientHeaderMiddleware(next, urlConfig, requestInit, context) {
requestInit.headers.set('X-ClientId', 'ABC123');
// Return response unmodified
return next(url.toUpperCase(), requestInit)
}

This middleware just transforms the response - converting it to uppercase.

function upperCaseResponseMiddleware(next, urlConfig, requestInit, context) {
const { result } = await next(url.toUpperCase(), requestInit)
return result.toUpperCase();
}

Note that next will return an object containing url, response, decodedBody and result. As a convenience you can return this object directly when you do not need to modify the result in any way (the first example above). result contains the value returned from any middleware that handled the response before this one or otherwise decodedBody for the first middleware.

The context object can be used to retrieve the original options from the Endpoint.execute call and re-execute the command. This is useful for middleware that may replay a request after an initial failure, eg. if user isn't authenticated on initial attempt.

// Access the original parameters passed to execute
context.executeOptions
// Re-execute the endpoint.
context.execute()

NOTE: Calling context.execute() will go through all the middleware again

Middleware can be set globally for all Endpoint's on the Endpoint.defaultConfig.middleware option or individually for each Endpoint by passing the middleware as an option when creating the endpoint.

Set globally:

Endpoint.defaultConfig.middleware = [
dedupeInflightRequestsMiddleware,
];

Or customise it per Endpoint:

new Endpoint('/users/', { middleware: [csrfTokenMiddleware] })

When middleware is passed to the Endpoint it is appended to the default middleware specified in Endpoint.defaultConfig.middleware.

To change how middleware is combined per Endpoint you can specify the getMiddleware option. This is passed the middleware for the Endpoint and the Endpoint itself and should return an array of middleware to use.

The default implementation looks like

(middleware) => [
...Endpoint.defaultConfig.middleware,
...middleware,
]

You can change the default implementation on Endpoint.defaultConfig.getMiddleware

Middleware can also be defined as an object with any of the following properties:

  • init - Called when the Endpoint is initialised and allows the middleware to modify the endpoint class or otherwise do some kind of initialisation.
  • prepare - A function that is called in Endpoint.prepare to modify the options used. Specifically this allows middleware to apply its changes to the options used (eg. change URL etc) such that Endpoint correctly caches the call.
  • process - Process the middleware. This behaves the same as the function form described above.

Advanced

For advanced use cases there's some additional hooks available.

  • addFetchStartListener - This allows middleware to be notified when the call to fetch starts and get access to the Promise. dedupeInFlightRequestsMiddleware uses this to cache an in flight request and return the same response for duplicate calls using SkipToResponse
  • SkipToResponse - This allows middleware to skip the rest of the middleware chain and the call to fetch. Instead it is passed a promise that resolves to Response and this is used instead of doing the call to fetch for you.

API

new Endpoint(urlPattern,options)

Source
ParameterTypeDescription
*urlPatternUrlPattern

The UrlPattern to use to resolve the URL for this endpoint

*options

Any options accepted by fetch in addition to those described below

options.headersHeadersInit|Record

Any headers to add to the request. You can unset default headers that might be specified in the default Endpoint.defaultConfig.requestInit by setting the value to undefined.

options.paginatorPaginatorInterface|null

The paginator instance to use. This can be provided in the constructor to use by default for all executions of this endpoint or provided for each call to the endpoint.

Only applicable if paginationMiddleware has been added to the Endpoint.

options.baseUrlstring

Base URL to use. This is prepended to the return value of urlPattern.resolve(...) and can be used to change the call to occur to a different domain.

If not specified defaults to Endpoint.defaultConfig.baseUrl

options.decodeBody
Function

Method to decode body based on response. The default implementation looks at the content type of the response and processes it accordingly (eg. handles JSON and text responses) and is suitable for most cases. If you just need to transform the decoded body (eg. change the decoded JSON object) then use middleware instead.

options.getMiddleware
Function

Get the final middleware to apply for this endpoint. This combines the global middleware and the middleware specific to this endpoint. Defaults to Endpoint.defaultConfig.getMiddleware which applies the global middleware followed by the endpoint specific middleware.

See middleware for more details

options.middlewareMiddleware[]

Middleware to apply for this endpoint. By default getMiddleware concatenates this with the global Endpoint.defaultConfig.middleware

See middleware for more details

options.resolveUrl
Function

A function to resolve the URL. It is passed the URL pattern object, any arguments for the URL and any query string parameters.

this is bound to the Endpoint.

If not provided defaults to:

function defaultResolveUrl(urlPattern, urlArgs, query, baseUrl) {
if (baseUrl[baseUrl.length - 1] === '/') {
baseUrl = baseUrl.slice(0, -1);
}
return baseUrl + this.urlPattern.resolve(urlArgs, { query });
}

Methods

Triggers the fetch call for an action

This can be called directly or indirectly via prepare.

If the fetch call itself fails due to a network error then a TypeError will be thrown.

If the fetch call is aborted due to a call to AbortController.abort an AbortError is thrown.

If the response is a non-2XX response an ApiError will be thrown.

If the call is successful the body will be decoded using decodeBody. The default implementation will decode JSON to an object or return text based on the content type. If the content type is not JSON or text the raw Response will be returned.

// Via prepare
const preparedAction = action.prepare({ urlArgs: { id: '1' }});
preparedAction.execute();
// Directly
action.execute({ urlArgs: { id: '1' }});
ParameterTypeDescription
*options

Any options accepted by fetch in addition to those described below

options.headersHeadersInit|Record

Any headers to add to the request. You can unset default headers that might be specified in the default Endpoint.defaultConfig.requestInit by setting the value to undefined.

options.paginatorPaginatorInterface|null

The paginator instance to use. This can be provided in the constructor to use by default for all executions of this endpoint or provided for each call to the endpoint.

Only applicable if paginationMiddleware has been added to the Endpoint.

options.queryQuery
options.urlArgsRecord
A Promise that resolves to an object with the following keys:
KeyTypeDescription
*decodedBodyany

The value returned by decodedBody

*queryQuery

Any query string parameters

*requestInitExecuteInitOptions

The options used to execute the endpoint with

*responseResponse

The response as returned by fetch

*resultT

The value returned from the endpoint after it has passed through decodeBody and any middleware

*urlstring

The url that the endpoint was called with

*urlArgsRecord

Any arguments that were used to resolve the URL.

Calls fetch

You can extend Endpoint and override this if you need to customise something that isn't possible with middleware.

ParameterTypeDescription
*urlstring
*requestInitRequestInit
Promise<Response>

Prepare an action for execution. Given the same parameters returns the same object. This is useful when using libraries like useSWR that accept a parameter that identifies a request and is used for caching but execution is handled by a separate function.

For example to use with useSWR you can do:

const { data } = useSWR([action.prepare()], (preparedAction) => preparedAction.execute());

If you just want to call the action directly then you can bypass prepare and just call execute directly.

ParameterTypeDescription
*options

Any options accepted by fetch in addition to those described below

options.headersHeadersInit|Record

Any headers to add to the request. You can unset default headers that might be specified in the default Endpoint.defaultConfig.requestInit by setting the value to undefined.

options.paginatorPaginatorInterface|null

The paginator instance to use. This can be provided in the constructor to use by default for all executions of this endpoint or provided for each call to the endpoint.

Only applicable if paginationMiddleware has been added to the Endpoint.

options.queryQuery
options.urlArgsRecord

Properties

The base URL to use for this endpoint. This is prepended to the URL returned from urlPattern.resolve.

If not specified then it defaults to Endpoint.defaultConfig.baseUrl.

Note that middleware can override this as well.

The UrlPattern this endpoint hits when executed.

Static Properties

KeyTypeDescription
*baseUrlstring

Base to use for all urls. This can be used to change all URL's to be on a different origin.

This can also be customised by middleware by changing urlConfig.baseUrl (defaults to this setting) or in each Endpoint by passing baseUrl in options.

*getMiddleware
Function

Get the final middleware to apply to the specified endpoint. By default applies the global middleware followed by the endpoint specific middleware.

*middlewareMiddleware[]

Default middleware to use on an endpoint. It is strongly recommended to append to this rather than replace it.

Defaults to requestDefaultsMiddleware.

See middleware for more details

*requestInitRequestInit

Default options used to execute the endpoint with

This defines the default settings to use on an endpoint globally.

All these options can be customised on individual Endpoints.