ketting
Version:
Opinionated HATEOAS / Rest client.
333 lines (276 loc) • 9.1 kB
text/typescript
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;
}