ketting
Version:
Opinionated HATEOAS / Rest client.
275 lines (215 loc) • 6.86 kB
text/typescript
import { State, HeadState } from './interface.js';
import { Links, LinkVariables, LinkNotFound } from '../link.js';
import Client from '../client.js';
import { Action, ActionNotFound, ActionInfo, SimpleAction } from '../action.js';
import { Resource } from '../resource.js';
import { resolve } from '../util/uri.js';
import { expand } from '../util/uri-template.js';
import { entityHeaderNames } from '../http/util.js';
import { StateSerializedBody, serializeBody } from '#state-serialized-body';
type HeadStateInit = {
client: Client;
uri: string;
links: Links;
/**
* The full list of HTTP headers that were sent with the response.
*/
headers: Headers;
}
type StateInit<T> = {
client: Client;
uri: string;
data: T;
headers: Headers;
links: Links;
embedded?: State[];
actions?: ActionInfo[];
}
/**
* Implements a State object for HEAD responses
*/
export class BaseHeadState implements HeadState {
uri: string;
/**
* Timestamp of when the State was first generated
*/
timestamp: number;
/**
* The full list of HTTP headers that were sent with the response.
*/
headers: Headers;
/**
* All links associated with the resource.
*/
links: Links;
/**
* Reference to main client that created this state
*/
client: Client;
constructor(init: HeadStateInit) {
this.client = init.client;
this.uri = init.uri;
this.headers = init.headers;
this.timestamp = Date.now();
this.links = init.links;
}
/**
* 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): Resource<TFollowedResource> {
const link = this.links.get(rel);
if (!link) throw new LinkNotFound(`Link with rel ${rel} on ${this.uri} not found`);
let href;
if (link.templated) {
href = expand(link, variables || {});
} else {
href = resolve(link);
}
if (link.hints?.status === 'deprecated') {
/* eslint-disable-next-line no-console */
console.warn(`[ketting] The ${link.rel} link on ${this.uri} is marked deprecated.`, link);
}
return this.client.go(href);
}
/**
* Follows a relationship based on its reltype. This function returns a
* Promise that resolves to an array of Resource objects.
*
* If no resources were found, the array will be empty.
*/
followAll<TFollowedResource = any>(rel: string): Resource<TFollowedResource>[] {
return this.links.getMany(rel).map( link => {
if (link.hints?.status === 'deprecated') {
/* eslint-disable-next-line no-console */
console.warn(`[ketting] The ${link.rel} link on ${this.uri} is marked deprecated.`, link);
}
const href = resolve(link);
return this.client.go(href);
});
}
/**
* Content-headers are a subset of HTTP headers that related directly
* to the content. The obvious ones are Content-Type.
*
* This set of headers will be sent by the server along with a GET
* response, but will also be sent back to the server in a PUT
* request.
*/
contentHeaders(): Headers {
const result: {[name: string]: string} = {};
for(const contentHeader of entityHeaderNames) {
if (this.headers.has(contentHeader)) {
result[contentHeader] = this.headers.get(contentHeader)!;
}
}
return new Headers(result);
}
}
/**
* The Base State provides a convenient way to implement a new State type.
*/
export class BaseState<T> extends BaseHeadState implements State<T> {
data: T;
protected embedded: State[];
protected actionInfo: ActionInfo[];
constructor(init: StateInit<T>) {
super(init);
this.data = init.data;
this.actionInfo = init.actions || [];
this.embedded = init.embedded || [];
}
/**
* Return an action by name.
*
* If no name is given, the first action is returned. This is useful for
* formats that only supply 1 action, and no name.
*/
action<TFormData extends Record<string, any> = any>(name?: string): Action<TFormData> {
const actionSearchResult = this.doFindAction(name);
if (actionSearchResult === 'NO_ACTION_DEFINED') {
throw new ActionNotFound('This State does not define any actions');
}
if (actionSearchResult === 'NO_ACTION_FOR_THE_PROVIDED_NAME') {
throw new ActionNotFound('This State defines no action');
}
return actionSearchResult;
}
findAction<TFormData extends Record<string, any> = any>(name?: string): Action<TFormData> | undefined {
const actionSearchResult = this.doFindAction(name);
if (typeof actionSearchResult !== 'object') {
return undefined;
}
return actionSearchResult;
}
private doFindAction<TFormData extends Record<string, any> = any>(name?: string): Action<TFormData> | ActionNotFoundReason {
if (!this.actionInfo.length) {
return 'NO_ACTION_DEFINED';
}
if (name === undefined) {
return new SimpleAction(this.client, this.actionInfo[0]);
}
for(const action of this.actionInfo) {
if (action.name === name) {
return new SimpleAction(this.client, action);
}
}
return 'NO_ACTION_FOR_THE_PROVIDED_NAME';
}
/**
* Returns all actions
*/
actions(): Action[] {
return this.actionInfo.map(action => new SimpleAction(this.client, action));
}
/**
* Checks if the specified action exists.
*
* If no name is given, checks if _any_ action exists.
*/
hasAction(name?: string): boolean {
if (name===undefined) return this.actionInfo.length>0;
for(const action of this.actionInfo) {
if (name === action.name) {
return true;
}
}
return false;
}
/**
* Returns a serialization of the state that can be used in a HTTP
* response.
*
* For example, a JSON object might simply serialize using
* JSON.serialize().
*/
serializeBody(): StateSerializedBody {
return serializeBody(this.data);
}
/**
* Certain formats can embed other resources, identified by their
* own URI.
*
* When a format has embedded resources, we will use these to warm
* the cache.
*
* This method returns every embedded resource.
*/
getEmbedded(): State[] {
return this.embedded;
}
clone(): State<T> {
return new BaseState({
client: this.client,
uri: this.uri,
data: this.data,
headers: new Headers(this.headers),
links: new Links(this.links.defaultContext, this.links.getAll()),
actions: this.actionInfo,
});
}
}
type ActionNotFoundReason = 'NO_ACTION_DEFINED' | 'NO_ACTION_FOR_THE_PROVIDED_NAME';