UNPKG

ketting

Version:

Opinionated HATEOAS / Rest client.

382 lines 13.4 kB
import { BaseState } from './base-state.js'; import { parseLink } from '../http/util.js'; import { Links } from '../link.js'; import { resolve } from '../util/uri.js'; /** * Represents a resource state in the HAL format */ export class HalState extends BaseState { serializeBody() { return JSON.stringify({ _links: this.serializeLinks(), ...this.data }); } serializeLinks() { const 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].push(attributes); } else { // 1 link with this rel existed, so we will transform it to an array. links[rel] = [links[rel], attributes]; } } return links; } clone() { 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 = async (client, uri, response) => { 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, body) { if (body._links === undefined) { return []; } const result = []; /** * 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 = innerBody?._links?.self?.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, rel, links) { const result = []; 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, context, body, headers) { if (body._embedded === undefined || !body._embedded) { return []; } const result = []; for (const embedded of Object.values(body._embedded)) { let embeddedList; if (!Array.isArray(embedded)) { embeddedList = [embedded]; } else { embeddedList = embedded; } for (const embeddedItem of embeddedList) { if (embeddedItem._links?.self?.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?.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, body) { 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) { switch (halField.type) { case undefined: case 'text': case 'search': case 'tel': case 'url': case 'email': if (halField.options) { const multiple = !('maxItems' in halField.options) || !halField.options.maxItems || halField.options.maxItems > 1; const baseField = { name: halField.name, type: 'select', label: halField.prompt, required: halField.required || false, readOnly: halField.readOnly || false, value: (halField.options.selectedValues || halField.value), }; const optionsDataSource = toOptionsDataSource(halField.options); if (multiple) { return { multiple: true, selectedValues: halField.options.selectedValues, ...baseField, ...optionsDataSource }; } else { const selectedValues = halField.options.selectedValues; let selectedValue; 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) { const labelField = halFieldOptions.promptField || 'prompt'; const valueField = halFieldOptions.valueField || 'value'; if (isInlineOptions(halFieldOptions)) { const options = {}; 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) { return options.inline !== undefined; } //# sourceMappingURL=hal.js.map