UNPKG

ketting

Version:

Opiniated HATEAOS / Rest client.

282 lines (235 loc) 7.76 kB
import { Fetcher, FetchMiddleware } from './http/fetcher'; import Resource from './resource'; import { State, StateFactory } from './state'; import { halStateFactory, binaryStateFactory, jsonApiStateFactory, sirenStateFactory, textStateFactory, cjStateFactory, htmlStateFactory } from './state'; import { parseContentType, isSafeMethod } from './http/util'; import { resolve } from './util/uri'; import { LinkVariables } from './link'; import { FollowPromiseOne } from './follow-promise'; import { StateCache, ForeverCache } from './cache'; import * as LinkHeader from 'http-link-header'; export default class Client { /** * All relative urls will by default use the bookmarkUri to * expand. It should usually be the starting point of your * API */ bookmarkUri: string; /** * Supported content types * * Each content-type has a 'factory' that turns a HTTP response * into a State object. * * The last value in the array is the 'q=' value, used in Accept * headers. Higher means higher priority. */ contentTypeMap: { [mimeType: string]: [StateFactory<any>, string], } = { 'application/prs.hal-forms+json': [halStateFactory, '1.0'], 'application/hal+json': [halStateFactory, '0.9'], 'application/vnd.api+json': [jsonApiStateFactory, '0.8'], 'application/vnd.siren+json': [sirenStateFactory, '0.8'], 'application/vnd.collection+json': [cjStateFactory, '0.8'], 'application/json': [halStateFactory, '0.7'], 'text/html': [htmlStateFactory, '0.6'], } /** * The cache for 'State' objects */ cache: StateCache; /** * The cache for 'Resource' objects. Each unique uri should * only ever get 1 associated resource. */ resources: Map<string, Resource>; /** * Fetcher is a utility object that handles fetch() requests * and middlewares. */ fetcher: Fetcher; constructor(bookmarkUri: string) { this.bookmarkUri = bookmarkUri; this.fetcher = new Fetcher(); this.fetcher.use( this.cacheExpireHandler ); this.fetcher.use( this.acceptHeader ); this.cache = new ForeverCache(); this.resources = new Map(); } /** * Follows a relationship, based on its reltype. For example, this might be * 'alternate', 'item', 'edit' or a custom url-based one. * * This function can also follow templated uris. You can specify uri * variables in the optional variables argument. */ follow<TFollowedResource = any>(rel: string, variables?: LinkVariables): FollowPromiseOne<TFollowedResource> { return this.go().follow(rel, variables); } /** * Returns a resource by its uri. * * This function doesn't do any HTTP requests. The uri is optional. If it's * not specified, it will return the bookmark resource. * * If a relative uri is passed, it will be resolved based on the bookmark * uri. * * @example * const res = ketting.go('https://example.org/); * @example * const res = ketting.go<Author>('/users/1'); * @example * const res = ketting.go(); // bookmark */ go<TResource = any>(uri?: string): Resource<TResource> { let absoluteUri; if (uri !== undefined) { absoluteUri = resolve(this.bookmarkUri, uri); } else { absoluteUri = this.bookmarkUri; } if (!this.resources.has(absoluteUri)) { const resource = new Resource(this, absoluteUri); this.resources.set(absoluteUri, resource); return resource; } return this.resources.get(absoluteUri)!; } /** * Adds a fetch middleware, which will be executed for * each fetch() call. * * If 'origin' is specified, fetch middlewares can be executed * only if the host/origin matches. */ use(middleware: FetchMiddleware, origin: string = '*') { this.fetcher.use(middleware, origin); } /** * Clears the entire state cache */ clearCache() { this.cache.clear(); } /** * Transforms a fetch Response to a State object. */ async getStateForResponse(uri: string, response: Response): Promise<State> { const contentType = parseContentType(response.headers.get('Content-Type')!); let state: State; if (!contentType) { return binaryStateFactory(uri, response); } if (contentType in this.contentTypeMap) { state = await this.contentTypeMap[contentType][0](uri, response); } else if (contentType.startsWith('text/')) { // Default to TextState for any format starting with text/ state = await textStateFactory(uri, response); } else if (contentType.match(/^application\/[A-Za-z-.]+\+json/)) { // Default to HalState for any format containing a pattern like application/*+json state = await halStateFactory(uri, response); } else { state = await binaryStateFactory(uri, response); } state.client = this; return state; } /** * Caches a State object * * This function will also emit 'update' events to resources, and store all * embedded states. */ cacheState(state: State) { this.cache.store(state); const resource = this.resources.get(state.uri); if (resource) { // We have a resource for this object, notify it as well. resource.emit('update', state); } for(const embeddedState of state.getEmbedded()) { // Recursion. MADNESS this.cacheState(embeddedState); } } private acceptHeader: FetchMiddleware = (request, next) => { if (!request.headers.has('Accept')) { const acceptHeader = Object.entries(this.contentTypeMap).map( ([contentType, [stateFactory, q]]) => contentType + ';q=' + q ).join(', '); request.headers.set('Accept', acceptHeader); } return next(request); }; private cacheExpireHandler: FetchMiddleware = async(request, next) => { /** * Prevent a 'stale' event from being emitted, but only for the main * uri */ let noStaleEvent = false; if (request.headers.has('X-KETTING-NO-STALE')) { noStaleEvent = true; request.headers.delete('X-KETTING-NO-STALE'); } const response = await next(request); if (isSafeMethod(request.method)) { return response; } if (!response.ok) { // There was an error, no cache changes return response; } // We just processed an unsafe method, lets notify all subsystems. const expireUris = []; if (!noStaleEvent && request.method !== 'DELETE') { // Sorry for the double negative expireUris.push(request.url); } // If the response had a Link: rel=invalidate header, we want to // expire those too. if (response.headers.has('Link')) { for (const httpLink of LinkHeader.parse(response.headers.get('Link')!).rel('invalidates')) { const uri = resolve(request.url, httpLink.uri); expireUris.push(uri); } } // Location headers should also expire if (response.headers.has('Location')) { expireUris.push( resolve(request.url, response.headers.get('Location')!) ); } // Content-Location headers should also expire if (response.headers.has('Content-Location')) { expireUris.push( resolve(request.url, response.headers.get('Content-Location')!) ); } for (const uri of expireUris) { this.cache.delete(request.url); const resource = this.resources.get(uri); if (resource) { // We have a resource for this object, notify it as well. resource.emit('stale'); } } if (request.method === 'DELETE') { this.cache.delete(request.url); const resource = this.resources.get(request.url); if (resource) { resource.emit('delete'); } } return response; }; }