ketting
Version:
Opinionated HATEOAS / Rest client.
469 lines (388 loc) • 12.3 kB
text/typescript
import { BaseState } from './base-state.js';
import { parseLink } from '../http/util.js';
import { Link, Links } from '../link.js';
import { resolve } from '../util/uri.js';
import { ActionInfo } from '../action.js';
import {Field, OptionsDataSource} from '../field.js';
import { StateFactory } from './interface.js';
import Client from '../client.js';
import * as hal from 'hal-types';
/**
* Represents a resource state in the HAL format
*/
export class HalState<T = any> extends BaseState<T> {
serializeBody(): string {
return JSON.stringify({
_links: this.serializeLinks(),
...this.data
});
}
private serializeLinks(): hal.HalResource['_links'] {
const links: hal.HalResource['_links'] = {
self: { href: this.uri },
};
for(const link of this.links.getAll()) {
const { rel, context, ...attributes } = link;
if (rel === 'self') {
// skip
continue;
}
if (links[rel] === undefined) {
// First link of its kind
links[rel] = attributes;
} else if (Array.isArray(links[rel])) {
// Add link to link array.
(links[rel] as hal.HalLink[]).push(attributes);
} else {
// 1 link with this rel existed, so we will transform it to an array.
links[rel] = [links[rel] as hal.HalLink, attributes];
}
}
return links;
}
clone(): HalState<T> {
return new HalState({
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,
});
}
}
/**
* Turns a HTTP response into a HalState
*/
export const factory:StateFactory = async (client, uri, response): Promise<HalState> => {
const body = await response.json();
const links = parseLink(uri, response.headers.get('Link'));
// The HAL factory is also respondible for plain JSON, which might be an
// array.
if (Array.isArray(body)) {
return new HalState({
client,
uri,
data: body,
headers: response.headers,
links,
});
}
links.add(...parseHalLinks(uri, body));
// Remove _links and _embedded from body
const {
_embedded,
_links,
_templates,
...newBody
} = body;
return new HalState({
client,
uri: uri,
data: newBody,
headers: response.headers,
links: links,
embedded: parseHalEmbedded(client, uri, body, response.headers),
actions: parseHalForms(uri, body),
});
};
/**
* Parse the Hal _links object and populate the 'links' property.
*/
function parseHalLinks(context: string, body: hal.HalResource): Link[] {
if (body._links === undefined) {
return [];
}
const result: Link[] = [];
/**
* We're capturing all rel-link pairs so we don't duplicate them if they
* re-appear in _embedded.
*
* Links that are embedded _should_ appear in both lists, but not everyone
* does this.
*/
const foundLinks = new Set();
for (const [relType, links] of Object.entries(body._links)) {
const linkList = Array.isArray(links) ? links : [links];
for (const link of linkList) {
foundLinks.add(relType + ';' + link.href);
}
result.push(
...parseHalLink(context, relType, linkList)
);
}
if (body._embedded) {
// eslint-disable-next-line prefer-const
for (let [rel, innerBodies] of Object.entries(body._embedded)) {
for(const innerBody of Array.isArray(innerBodies) ? innerBodies : [innerBodies]) {
const href:string = (innerBody?._links?.self as hal.HalLink)?.href;
if (!href) {
continue;
}
if (foundLinks.has(rel + ';' + href)) {
continue;
}
result.push({
rel: rel,
href: href,
context: context,
});
}
}
}
return result;
}
/**
* Parses a single HAL link from a _links object
*/
function parseHalLink(context: string, rel: string, links: hal.HalLink[]): Link[] {
const result: Link[] = [];
for (const link of links) {
result.push({
rel,
context,
...link,
});
}
return result;
}
/**
* Parse the HAL _embedded object. Right now we're just grabbing the
* information from _embedded and turn it into links.
*/
function parseHalEmbedded(client: Client, context: string, body: hal.HalResource, headers: Headers): HalState<any>[] {
if (body._embedded === undefined || !body._embedded) {
return [];
}
const result: HalState<any>[] = [];
for (const embedded of Object.values(body._embedded)) {
let embeddedList: hal.HalResource[];
if (!Array.isArray(embedded)) {
embeddedList = [embedded];
} else {
embeddedList = embedded;
}
for (const embeddedItem of embeddedList) {
if ((embeddedItem._links?.self as hal.HalLink)?.href === undefined) {
console.warn('An item in _embedded was ignored. Each item must have a single "self" link');
continue;
}
const embeddedSelf = resolve(context, (embeddedItem._links?.self as hal.HalLink)?.href);
// Remove _links and _embedded from body
const {
_embedded,
_links,
...newBody
} = embeddedItem;
result.push(new HalState({
client,
uri: embeddedSelf,
data: newBody,
headers: new Headers({
'Content-Type': headers.get('Content-Type')!,
}),
links: new Links(embeddedSelf, parseHalLinks(context, embeddedItem)),
// Parsing nested embedded items. Note that we assume that the base url is relative to
// the outermost parent, not relative to the embedded item. HAL is not clear on this.
embedded: parseHalEmbedded(client, embeddedSelf, embeddedItem, headers),
actions: parseHalForms(embeddedSelf, embeddedItem)
}));
}
}
return result;
}
function parseHalForms(context: string, body: hal.HalResource): ActionInfo[] {
if (!body._templates) return [];
return Object.entries(body._templates).map( ([key, hf]) => {
return {
uri: resolve(context, hf.target || ''),
name: key,
title: hf.title,
method: hf.method,
contentType: hf.contentType || 'application/json',
fields: hf.properties ? hf.properties.map(prop => parseHalField(prop)).filter(prop => !!prop) : [],
};
});
}
function parseHalField(halField: hal.HalFormsProperty): Field | undefined {
switch(halField.type) {
case undefined:
case 'text' :
case 'search' :
case 'tel' :
case 'url' :
case 'email' :
if (halField.options) {
const multiple: boolean | undefined = !('maxItems' in halField.options) || !halField.options.maxItems || halField.options.maxItems > 1;
const baseField = {
name: halField.name,
type: 'select' as const,
label: halField.prompt,
required: halField.required || false,
readOnly: halField.readOnly || false,
value: (halField.options.selectedValues || halField.value) as any,
};
const optionsDataSource = toOptionsDataSource(halField.options);
if (multiple) {
return {
multiple: true,
selectedValues: halField.options.selectedValues,
...baseField,
...optionsDataSource
};
} else {
const selectedValues = halField.options.selectedValues;
let selectedValue: string | undefined;
if (selectedValues) {
if (selectedValues.length === 1) {
selectedValue = selectedValues[0];
} else if (selectedValues.length > 1) {
console.warn(`More than 1 selected value received for single select field ${baseField.name}. Ignoring all selected values for this field.`);
}
}
return {
multiple,
selectedValue,
...baseField,
...optionsDataSource
};
}
} else {
return {
name: halField.name,
type: halField.type ?? 'text',
required: halField.required || false,
readOnly: halField.readOnly || false,
value: halField.value,
pattern: halField.regex ? new RegExp(halField.regex) : undefined,
label: halField.prompt,
placeholder: halField.placeholder,
minLength: halField.minLength,
maxLength: halField.maxLength,
};
}
case 'hidden' :
return {
name: halField.name,
type: 'hidden',
required: halField.required || false,
readOnly: halField.readOnly || false,
value: halField.value,
label: halField.prompt,
placeholder: halField.placeholder,
};
case 'textarea' :
return {
name: halField.name,
type: halField.type,
required: halField.required || false,
readOnly: halField.readOnly || false,
value: halField.value,
label: halField.prompt,
placeholder: halField.placeholder,
cols: halField.cols,
rows: halField.rows,
minLength: halField.minLength,
maxLength: halField.maxLength,
};
case 'password' :
return {
name: halField.name,
type: halField.type,
required: halField.required || false,
readOnly: halField.readOnly || false,
label: halField.prompt,
placeholder: halField.placeholder,
minLength: halField.minLength,
maxLength: halField.maxLength,
};
case 'date' :
case 'month' :
case 'week' :
case 'time' :
return {
name: halField.name,
type: halField.type,
value: halField.value,
required: halField.required || false,
readOnly: halField.readOnly || false,
label: halField.prompt,
min: halField.min,
max: halField.max,
step: halField.step,
};
case 'number' :
case 'range' :
return {
name: halField.name,
type: halField.type,
value: halField.value ? +halField.value : undefined,
required: halField.required || false,
readOnly: halField.readOnly || false,
label: halField.prompt,
min: halField.min,
max: halField.max,
step: halField.step,
};
case 'datetime-local' :
return {
name: halField.name,
type: halField.type,
value: halField.value ? new Date(halField.value) : undefined,
required: halField.required || false,
readOnly: halField.readOnly || false,
label: halField.prompt,
min: halField.min,
max: halField.max,
step: halField.step,
};
case 'color' :
return {
name: halField.name,
type: halField.type,
required: halField.required || false,
readOnly: halField.readOnly || false,
label: halField.prompt,
value: halField.value,
};
case 'radio' :
case 'checkbox' :
return {
name: halField.name,
type: halField.type,
required: halField.required || false,
readOnly: halField.readOnly || false,
label: halField.prompt,
value: !!halField.value,
};
default:
return undefined;
}
}
function toOptionsDataSource(halFieldOptions: NonNullable<hal.HalFormsSimpleProperty['options']>): OptionsDataSource {
const labelField = halFieldOptions.promptField || 'prompt';
const valueField = halFieldOptions.valueField || 'value';
if (isInlineOptions(halFieldOptions)) {
const options: Record<string, string> = {};
for (const entry of halFieldOptions.inline) {
if (typeof entry === 'string') {
options[entry] = entry;
} else {
options[entry[valueField]] = entry[labelField];
}
}
return {options};
} else {
return {
dataSource: {
href: halFieldOptions.link.href,
type: halFieldOptions.link.type,
labelField,
valueField,
}
};
}
}
function isInlineOptions(options: hal.HalFormsSimpleProperty['options']): options is hal.HalFormsOptionsInline {
return (options as any).inline !== undefined;
}