UNPKG

ketting

Version:

Opinionated HATEOAS / Rest client.

333 lines (276 loc) 9.1 kB
import { Fetcher, FetchMiddleware } from './http/fetcher.js'; import Resource from './resource.js'; import { halStateFactory, binaryStateFactory, jsonApiStateFactory, sirenStateFactory, textStateFactory, cjStateFactory, htmlStateFactory, State, StateFactory } from './state/index.js'; import { parseContentType } from './http/util.js'; import { resolve } from './util/uri.js'; import { Link, LinkVariables } from './link.js'; import { FollowPromiseOne } from './follow-promise.js'; import { StateCache, ForeverCache } from './cache/index.js'; import cacheExpireMiddleware from './middlewares/cache.js'; import acceptMiddleware from './middlewares/accept-header.js'; import warningMiddleware from './middlewares/warning.js'; 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(cacheExpireMiddleware(this)); this.fetcher.use(acceptMiddleware(this)); this.fetcher.use(warningMiddleware()); 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|Link): Resource<TResource> { let absoluteUri; if (uri === undefined) { absoluteUri = this.bookmarkUri; } else if (typeof uri === 'string') { absoluteUri = resolve(this.bookmarkUri, uri); } else { absoluteUri = resolve(uri); } 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(); this.cacheDependencies = new Map(); } /** * Caches a State object * * This function will also emit 'update' events to resources, and store all * embedded states. */ cacheState(state: State) { // Flatten the list of state objects. const newStates = flattenState(state); // Register all cache dependencies. for(const nState of newStates) { for(const invByLink of nState.links.getMany('inv-by')) { this.addCacheDependency(resolve(invByLink), nState.uri); } } // Store all new caches for(const nState of newStates) { this.cache.store(nState); } // Emit 'update' events for(const nState of newStates) { const resource = this.resources.get(nState.uri); if (resource) { // We have a resource for this object, notify it as well. resource.emit('update', nState); } } } /** * cacheDependencies contains all cache relationships between * resources. * * This lets a user (for example) let a resource automatically * expire, if another one expires. * * A server can populate this list using the `inv-by' link. * * @deprecated This property will go private in a future release. */ public cacheDependencies: Map<string, Set<string>> = new Map(); /** * Adds a cache dependency between two resources. * * If the 'target' resource ever expires, it will cause 'dependentUri' to * also expire. * * Both argument MUST be absolute urls. */ addCacheDependency(targetUri: string, dependentUri: string): void { if (this.cacheDependencies.has(targetUri)) { this.cacheDependencies.get(targetUri)!.add(dependentUri); } else { this.cacheDependencies.set(targetUri, new Set([dependentUri])); } } /** * Helper function for clearing the cache for a resource. * * This function will also emit the 'stale' event for resources that have * subscribers, and handle any dependent resource caches. * * If any resources are specified in deletedUris, those will not * receive 'stale' events, but 'delete' events instead. */ clearResourceCache(staleUris: string[], deletedUris: string[]) { let stale = new Set<string>(); const deleted = new Set<string>(); for(const uri of staleUris) { stale.add(resolve(this.bookmarkUri, uri)); } for(const uri of deletedUris) { stale.add(resolve(this.bookmarkUri, uri)); deleted.add(resolve(this.bookmarkUri, uri)); } stale = expandCacheDependencies( new Set([...stale, ...deleted]), this.cacheDependencies ); for(const uri of stale) { this.cache.delete(uri); const resource = this.resources.get(uri); if (resource) { if (deleted.has(uri)) { resource.emit('delete'); } else { resource.emit('stale'); } } } } /** * Transforms a fetch Response to a State object. */ async getStateForResponse(uri: string, response: Response): Promise<State> { const contentType = parseContentType(response.headers.get('Content-Type')!); if (!contentType || response.status === 204) { return binaryStateFactory(this, uri, response); } if (contentType in this.contentTypeMap) { return this.contentTypeMap[contentType][0](this, uri, response); } else if (contentType.startsWith('text/')) { // Default to TextState for any format starting with text/ return textStateFactory(this, uri, response); } else if (contentType.match(/^application\/[A-Za-z-.]+\+json/)) { // Default to HalState for any format containing a pattern like application/*+json return halStateFactory(this, uri, response); } else { return binaryStateFactory(this, uri, response); } } } /** * Find all dependencies for a given resource. * * For example, if * * if resourceA depends on resourceB * * and resourceB depends on resourceC * * Then if 'resourceC' expires, so should 'resourceA' and 'resourceB'. * * This function helps us find these dependencies recursively and guarding * against recursive loops. */ function expandCacheDependencies(uris: Set<string>, dependencies: Map<string, Set<string>>, output?: Set<string>): Set<string> { if (!output) output = new Set(); for(const uri of uris) { if (!output.has(uri)) { output.add(uri); if (dependencies.has(uri)) { expandCacheDependencies(dependencies.get(uri)!, dependencies, output); } } } return output; } /** * Take a State object, find all it's embedded resources and return a flat * array of all resources at any depth. */ function flattenState(state: State, result: Set<State> = new Set<State>()): Set<State> { result.add(state); for(const embedded of state.getEmbedded()) { flattenState(embedded, result); } return result; }