UNPKG

@data-client/rest

Version:

Quickly define typed REST resources and endpoints

533 lines (512 loc) 16.8 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; } } 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 = new Map(); function getUrlBase(path) { if (!urlBaseCache.has(path)) { urlBaseCache.set(path, pathToRegexp.compile(path, { encode: encodeURIComponent, validate: false })); } return urlBaseCache.get(path); } const urlTokensCache = new Map(); function getUrlTokens(path) { if (!urlTokensCache.has(path)) { urlTokensCache.set(path, new Set(pathToRegexp.parse(path).map(t => typeof t === 'string' ? t : `${t['name']}`))); } return urlTokensCache.get(path); } 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 lastColonIndex = path.lastIndexOf(':'); if (lastColonIndex === -1) throw new Error('Resource path requires at least one :parameter'); // this is for when not specifying a specific item like create/list let shortUrlRoot = path.substring(0, lastColonIndex); if (shortUrlRoot[shortUrlRoot.length - 1] === '/') shortUrlRoot = shortUrlRoot.substring(0, shortUrlRoot.length - 1); return shortUrlRoot; } /** 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); 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 urlBase = getUrlBase(this.path)(urlParams); const tokens = getUrlTokens(this.path); const searchParams = {}; Object.keys(urlParams).forEach(k => { if (!tokens.has(k)) { searchParams[k] = urlParams[k]; } }); 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) { var _response$headers$get; // this should not have any content to read if (response.status === 204) return Promise.resolve(null); if (!((_response$headers$get = response.headers.get('content-type')) != null && _response$headers$get.includes('json'))) { return response.text().then(text => { // string or 'not set' schema, are valid // when overriding process they might handle other cases, so we don't want to block on our logic if (['string', 'undefined'].includes(typeof this.schema) || this.schema === null || this.process !== RestEndpoint.prototype.process) return text; const error = new NetworkError(response); error.status = 404; error.message = `Unexpected text response for schema ${this.schema}`; // custom dev-only messages for more detailed cause /* istanbul ignore else */ if (process.env.NODE_ENV !== 'production') { var _response$headers$get2; if (!((_response$headers$get2 = response.headers.get('content-type')) != null && _response$headers$get2.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; }); } return response.json().catch(error => { error.status = 400; throw error; }); } process(value) { return value; } errorPolicy(error) { return error.status >= 500 ? 'soft' : undefined; } get pathRegex() { return pathToRegexp.pathToRegexp(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' }); } } 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, 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, path: shortenedPath, schema: new Collection([schema]), 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') }), partialUpdate: new Endpoint({ ...extraPartialOptions, path, schema, method: 'PATCH', name: getName('partialUpdate') }), delete: new Endpoint({ ...extraMutateOptions, path, schema: schema.process ? 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]; } }); });