@data-client/rest
Version:
Quickly define typed REST resources and endpoints
533 lines (512 loc) • 16.8 kB
JavaScript
'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]; }
});
});