UNPKG

@data-client/rest

Version:

Quickly define typed REST resources and endpoints

624 lines (600 loc) 19.9 kB
'use strict'; var endpoint = require('@data-client/endpoint'); var pathToRegexp = require('path-to-regexp'); function extractCollection(s, mapper) { if (typeof s !== 'object' || s === undefined || Array.isArray(s)) return; if (s instanceof endpoint.schema.Collection) { return mapper(s); } const objCopy = { ...(s instanceof endpoint.schema.Object ? s.schema : s) }; for (const k in objCopy) { if (!objCopy[k]) continue; const collection = extractCollection(objCopy[k], mapper); if (collection) return collection; } } function mapCollection(s, mapper) { if (typeof s !== 'object' || s === undefined) return s; if (s instanceof endpoint.schema.Collection) { return mapper(s); } const objCopy = { ...(s instanceof endpoint.schema.Object ? s.schema : s) }; for (const k in objCopy) { if (!objCopy[k]) continue; objCopy[k] = mapCollection(objCopy[k], mapper); } return objCopy; } /** An error with a Rest Endpoint fetch * * @see https://dataclient.io/rest/api/NetworkError */ class NetworkError extends Error { name = 'NetworkError'; constructor(response) { super(`${response.url}: ${response.statusText || `Status not 'ok': ${response.status}`}`); this.status = response.status; this.response = response; } /** Serialize the error for logging and debugging. * * Error properties are non-enumerable by default, so `JSON.stringify()` * on a plain Error produces `{}`. This ensures status, message, and the * request URL are always included in serialized output. */ toJSON() { return { name: this.name, status: this.status, message: this.message, url: this.response.url }; } } function paginatedMerge(existing, incoming) { const existingSet = new Set(existing); const mergedList = [...existing]; for (const pk of incoming) { if (!existingSet.has(pk)) mergedList.push(pk); } return mergedList; } function createPaginationSchema(removeCursor, collection) { return Object.create(collection, { name: { value: `Pagination(${collection.schema})` }, merge: { value: paginatedMerge }, pk: { value: function (value, parent, key, args) { return collection.pk.call(this, value, parent, key, removeCursor(...args)); } } }); } function paramsToString(searchParams) { const params = new URLSearchParams(searchParams); params.sort(); return params.toString(); } const urlBaseCache = Object.create(null); function getUrlBase(path) { if (!(path in urlBaseCache)) { urlBaseCache[path] = pathToRegexp.compile(path); } return urlBaseCache[path]; } const urlTokensCache = Object.create(null); function getUrlTokens(path) { if (!(path in urlTokensCache)) { urlTokensCache[path] = tokenMap(pathToRegexp.parse(path).tokens); } return urlTokensCache[path]; } const pathRegexCache = Object.create(null); function getPathRegex(path) { if (!(path in pathRegexCache)) { pathRegexCache[path] = pathToRegexp.pathToRegexp(path).regexp; } return pathRegexCache[path]; } function tokenMap(tokens) { const tokenNames = new Set(); for (const token of tokens) { switch (token.type) { case 'param': case 'wildcard': tokenNames.add(token.name); break; case 'group': for (const name of tokenMap(token.tokens)) { tokenNames.add(name); } break; } } return tokenNames; } const proto = Object.prototype; const gpo = Object.getPrototypeOf; function isPojo(obj) { if (obj === null || typeof obj !== 'object') { return false; } return gpo(obj) === proto; } function shortenPath(path) { const lastTokenIndex = Math.max(path.lastIndexOf(':'), path.lastIndexOf('*')); if (lastTokenIndex === -1) throw new Error('Resource path requires at least one :parameter or *wildcard'); let shortUrlRoot = path.substring(0, lastTokenIndex); if (shortUrlRoot[shortUrlRoot.length - 1] === '/') shortUrlRoot = shortUrlRoot.substring(0, shortUrlRoot.length - 1); return shortUrlRoot; } const textLikeRe = /\btext\b|\bxml\b|\bhtml\b|\bjavascript\b|\bcss\b|\bcsv\b|\burlencoded\b/; function jsonResponse(response) { return response.json().catch(error => { error.status = 400; throw error; }); } /** Simplifies endpoint definitions that follow REST patterns * * @see https://dataclient.io/rest/api/RestEndpoint */ class RestEndpoint extends endpoint.Endpoint { #hasBody; constructor(options) { var _options$fetch; super((_options$fetch = options.fetch) != null ? _options$fetch : async function (...args) { const urlParams = this.#hasBody && args.length < 2 ? {} : args[0] || {}; const body = this.#hasBody ? args[args.length - 1] : undefined; return this.fetchResponse(this.url(urlParams), await this.getRequestInit(body)).then(response => this.parseResponse(response)).then(res => this.process(res, ...args)); }, options); // we want to use the prototype chain here if (!('sideEffect' in this) || 'method' in options && !('sideEffect' in options)) { this.sideEffect = options.method === 'GET' || options.method === undefined ? undefined : true; } if (this.method === undefined) { this.method = this.sideEffect ? 'POST' : 'GET'; } if (this.urlPrefix === undefined) { this.urlPrefix = ''; } this.#hasBody = (!('body' in this) || this.body !== undefined) && !['GET', 'DELETE'].includes(this.method); /* istanbul ignore else */ if (process.env.NODE_ENV !== 'production') { if (this.content && this.content !== 'json' && this.schema != null && typeof this.schema !== 'string' && typeof this.schema !== 'undefined') { console.error(`content '${this.content}' is incompatible with schema. Binary/text responses cannot be normalized. Use schema: undefined.`); } } Object.defineProperty(this, 'name', { get() { // using 'in' to ensure inheritance lookup if ('__name' in this) return this.__name; return this.urlPrefix + this.path; } }); } key(...args) { return `${this.method} ${this.url(this.#hasBody && args.length < 2 ? {} : args[0] || {})}`; } /** Get the url */ url(urlParams = {}) { const tokens = getUrlTokens(this.path); const pathParams = {}; const searchParams = {}; Object.keys(urlParams).forEach(k => { if (tokens.has(k)) { if (urlParams[k] !== undefined) pathParams[k] = Array.isArray(urlParams[k]) ? urlParams[k].map(String) : String(urlParams[k]); } else { searchParams[k] = urlParams[k]; } }); const urlBase = getUrlBase(this.path)(pathParams); if (Object.keys(searchParams).length) { return `${this.urlPrefix}${urlBase}?${this.searchToString(searchParams)}`; } return `${this.urlPrefix}${urlBase}`; } /** Encode the url searchParams */ searchToString(searchParams) { return paramsToString(searchParams); } getHeaders(headers) { return headers; } /** Init options for fetch - run at fetch */ async getRequestInit(body) { const bodyIsPojo = isPojo(body); if (bodyIsPojo) { body = JSON.stringify(body); } const init = { ...this.requestInit, method: this.method, signal: this.signal, body }; if (!body || bodyIsPojo) { init.headers = { // default to application/json but allow user explicit overrides 'Content-Type': 'application/json', ...init.headers }; } init.headers = await this.getHeaders(init.headers); return init; } /** Perform network request and resolve with HTTP Response */ fetchResponse(input, init) { return fetch(input, init).then(response => { if (!response.ok) { throw new NetworkError(response); } return response; }).catch(error => { // ensure CORS, network down, and parse errors are still caught by NetworkErrorBoundary if (error instanceof TypeError) { error.status = 500; } throw error; }); } parseResponse(response) { if (response.status === 204) return Promise.resolve(null); if (this.content) { /* istanbul ignore else */ if (process.env.NODE_ENV !== 'production') { if (this.content !== 'json' && this.schema != null && typeof this.schema !== 'string' && typeof this.schema !== 'undefined') { const error = new NetworkError(response); error.status = 400; error.message = `content '${this.content}' is incompatible with schema. Binary/text responses cannot be normalized. Use schema: undefined.`; throw error; } } if (this.content === 'stream') return Promise.resolve(response.body); return this.content === 'json' ? jsonResponse(response) : response[this.content](); } const contentType = response.headers.get('content-type'); if (contentType != null && contentType.includes('json')) return jsonResponse(response); const isBinary = contentType && !textLikeRe.test(contentType); if (typeof this.schema === 'string' || typeof this.schema === 'undefined' || this.schema === null || this.process !== RestEndpoint.prototype.process) return isBinary ? response.blob() : response.text(); if (isBinary) { const error = new NetworkError(response); error.status = 400; error.message = `Unexpected binary content-type for schema ${this.schema}`; /* istanbul ignore else */ if (process.env.NODE_ENV !== 'production') { error.message = `Unexpected binary content-type "${contentType}" for schema ${this.schema}. Binary responses cannot be normalized. Use schema: undefined or set content: 'blob'.`; } throw error; } return response.text().then(text => { const error = new NetworkError(response); error.status = 404; error.message = `Unexpected text response for schema ${this.schema}`; /* istanbul ignore else */ if (process.env.NODE_ENV !== 'production') { if (!(contentType != null && contentType.includes('html') || text.startsWith('<!doctype html>'))) { if (tryParse(text) !== undefined) { error.message = `"content-type" header does not include "json", but JSON response found. See https://www.rfc-editor.org/rfc/rfc4627 for information on JSON responses Using parsed JSON. If text content was expected see https://dataclient.io/rest/api/RestEndpoint#parseResponse`; } } else { error.message = `Unexpected html response for schema ${this.schema} This likely means no API endpoint was configured for this request, resulting in an HTML fallback. Response (first 300 characters): ${text.substring(0, 300)}`; } } throw error; }); } process(value) { return value; } errorPolicy(error) { return error.status >= 500 ? 'soft' : undefined; } get pathRegex() { return getPathRegex(this.path); } testKey(key) { const prefix = this.method + ' ' + this.urlPrefix; if (!key.startsWith(prefix)) return false; let lastQuestion = key.lastIndexOf('?'); if (lastQuestion === -1) lastQuestion = undefined; return this.pathRegex.test(key.substring(prefix.length, lastQuestion)); } extend(options) { // make a constructor/prototype based off this // extend from it and init with options sent class E extends this.constructor {} Object.assign(E.prototype, this); return new E( // fetch get overridden by function prototype, so we must set it explicitly every time { fetch: this.fetch, ...options }); } paginated(removeCursor) { if (typeof removeCursor === 'string') { const fieldName = removeCursor; removeCursor = ({ ...params }) => { delete params[fieldName]; return [params]; }; } let found = false; const createPaginatedSchema = collection => { found = true; return createPaginationSchema(removeCursor, collection); }; const newSchema = mapCollection(this.schema, createPaginatedSchema); if (!found) throw new Error('Missing Collection'); const sup = this; return this.extend({ schema: newSchema, key(...args) { return sup.key.call(this, ...removeCursor(...args)); }, name: this.name + '.getPage' }); } get getPage() { return this.paginated(this.paginationField); } get push() { return this.extend({ method: 'POST', schema: extractCollection(this.schema, s => s.push), name: this.name + '.create' }); } get unshift() { return this.extend({ method: 'POST', schema: extractCollection(this.schema, s => s.unshift), name: this.name + '.create' }); } get assign() { return this.extend({ method: 'POST', schema: extractCollection(this.schema, s => s.assign), name: this.name + '.create' }); } get remove() { return this.extend({ method: 'PATCH', schema: extractCollection(this.schema, s => s.remove), name: this.name + '.partialUpdate' }); } get move() { const options = { method: 'PATCH', schema: extractCollection(this.schema, s => s.move), searchParams: undefined, name: this.name + '.partialUpdate' }; if (this.movePath) options.path = this.movePath; return this.extend(options); } } const tryParse = input => { try { return JSON.parse(input); } catch (e) { return undefined; } }; const { Invalidate, Collection: BaseCollection } = endpoint.schema; /** Creates collection of Endpoints for common operations on a given data/schema. * * @see https://dataclient.io/rest/api/resource */ function resource({ path, schema, Endpoint = RestEndpoint, Collection = BaseCollection, nonFilterArgumentKeys, optimistic, paginationField, ...extraOptions }) { if (process.env.NODE_ENV !== 'production') { // if they lowercase and it looks like they meant to use upper-case version if ('endpoint' in extraOptions && Endpoint === RestEndpoint && typeof extraOptions['endpoint'] === 'function' && extraOptions['endpoint'] && Object.prototype.isPrototypeOf.call(RestEndpoint.prototype, extraOptions['endpoint'].prototype)) { console.warn(`You passed 'endpoint' option; did you mean to use Endpoint? https://dataclient.io/rest/api/resource#endpoint This parameter must be capitalized. This warning will not show in production.`); } // if they lowercase and it looks like they meant to use upper-case version if ('collection' in extraOptions && Collection === BaseCollection && typeof extraOptions['collection'] === 'function' && extraOptions['collection'] && Object.prototype.isPrototypeOf.call(BaseCollection.prototype, extraOptions['collection'].prototype)) { console.warn(`You passed 'collection' option; did you mean to use Collection? https://dataclient.io/rest/api/resource#collection This parameter must be capitalized. This warning will not show in production.`); } } const shortenedPath = shortenPath(path); const getName = name => `${schema == null ? void 0 : schema.name}.${name}`; // this accounts for derivative endpoints function extendMember(extended, key, options) { extended[key] = extended[key].extend(options); } const extraMutateOptions = { ...extraOptions }; const extraPartialOptions = { ...extraOptions }; const get = new Endpoint({ ...extraOptions, path, schema, name: getName('get') }); if (optimistic) { extraMutateOptions.getOptimisticResponse = optimisticUpdate; // TODO: Check that schema is a queryable, otherwise this doesn't make sense extraPartialOptions.getOptimisticResponse = optimisticPartial(schema); } const getList = new Endpoint({ ...extraMutateOptions, paginationField: paginationField, movePath: path, path: shortenedPath, schema: new Collection([schema], nonFilterArgumentKeys === undefined ? undefined : { nonFilterArgumentKeys }), name: getName('getList') }); const ret = { get, getList, // TODO(deprecated): remove this once we remove creates create: getList.push.extend({ name: getName('create') }), update: new Endpoint({ ...extraMutateOptions, path, schema, method: 'PUT', name: getName('update') }), // TODO(breaking): Move to getList.move partialUpdate: new Endpoint({ ...extraPartialOptions, path, schema, method: 'PATCH', name: getName('partialUpdate') }), delete: new Endpoint({ ...extraMutateOptions, path, schema: // Entity || Union schema.process || schema._hoistable ? new Invalidate(schema) : schema, method: 'DELETE', name: getName('delete'), process(res, params) { return res && Object.keys(res).length ? res : params; }, getOptimisticResponse: optimistic ? optimisticDelete : undefined }), extend(...args) { if (typeof args[0] === 'string') { const [key, options] = args; if (key in this) { const extended = { ...this }; extendMember(extended, key, options); return extended; } else { return { ...this, [key]: this.get.extend(options) }; } } else if (typeof args[0] === 'function') { const extended = args[0](this); return { ...this, ...extended }; } const overrides = args[0]; const extended = { ...this }; for (const key in overrides) { // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-ignore extendMember(extended, key, overrides[key]); } return extended; } }; return ret; } function optimisticUpdate(snap, params, body) { return { ...params, ...ensurePojo(body) }; } function optimisticPartial(schema) { return function (snap, params, body) { const data = snap.get(schema, params); if (!data) throw snap.abort; return { ...params, ...data, // even tho we don't always have two arguments, the extra one will simply be undefined which spreads fine ...ensurePojo(body) }; }; } function optimisticDelete(snap, params) { return params; } function ensurePojo(body) { return body instanceof FormData ? Object.fromEntries(body.entries()) : body; } /** Turns a collection of Endpoints (Resource) into a collection of hooks. * This is useful for Endpoints that need hooks to prepare their fetch requests. * * @see https://dataclient.io/rest/api/hookifyResource */ function hookifyResource(resource, useRequestInit) { const usingResource = {}; Object.keys(resource).forEach(key => { const endpoint = resource[key]; if (endpoint.extend !== undefined) usingResource[`use${capitalizeFirstLetter(key)}`] = () => { // this is false positive due to the dynamic nature of assignment // eslint-disable-next-line react-hooks/rules-of-hooks const requestInit = useRequestInit(); return endpoint.extend({ requestInit }); }; }); return usingResource; } function capitalizeFirstLetter(s) { return s.charAt(0).toUpperCase() + s.slice(1); } exports.NetworkError = NetworkError; exports.RestEndpoint = RestEndpoint; exports.createResource = resource; exports.getUrlBase = getUrlBase; exports.getUrlTokens = getUrlTokens; exports.hookifyResource = hookifyResource; exports.resource = resource; Object.keys(endpoint).forEach(function (k) { if (k !== 'default' && !Object.prototype.hasOwnProperty.call(exports, k)) Object.defineProperty(exports, k, { enumerable: true, get: function () { return endpoint[k]; } }); });