UNPKG

ketting

Version:

Opinionated HATEOAS / Rest client.

469 lines (388 loc) 12.3 kB
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; }