@data-client/endpoint
Version:
Declarative Network Interface Definitions
1,196 lines (1,116 loc) • 38.9 kB
JavaScript
'use strict';
/**
* Helpers to enable Immutable compatibility *without* bringing in
* the 'immutable' package as a dependency.
*/
/**
* Check if an object is immutable by checking if it has a key specific
* to the immutable library.
*
* @param {any} object
* @return {bool}
*/
function isImmutable(object) {
return !!(typeof object.hasOwnProperty === 'function' && (Object.hasOwnProperty.call(object, '__ownerID') ||
// Immutable.Map
object._map && Object.hasOwnProperty.call(object._map, '__ownerID'))); // Immutable.Record
}
/**
* Denormalize an immutable entity.
*
* @param {Schema} schema
* @param {Immutable.Map|Immutable.Record} input
* @param {function} unvisit
* @param {function} getDenormalizedEntity
* @return {Immutable.Map|Immutable.Record}
*/
function denormalizeImmutable(schema, input, unvisit) {
let deleted;
const value = Object.keys(schema).reduce((object, key) => {
// Immutable maps cast keys to strings on write so we need to ensure
// we're accessing them using string keys.
const stringKey = `${key}`;
const item = unvisit(schema[stringKey], object.get(stringKey));
if (typeof item === 'symbol') {
deleted = item;
}
if (object.has(stringKey)) {
return object.set(stringKey, item);
} else {
return object;
}
}, input);
return deleted != null ? deleted : value;
}
class PolymorphicSchema {
schema;
constructor(definition, schemaAttribute) {
if (schemaAttribute) {
this._schemaAttribute = typeof schemaAttribute === 'string' ? input => input[schemaAttribute] : schemaAttribute;
}
this.define(definition);
}
get isSingleSchema() {
return !this._schemaAttribute;
}
define(definition) {
// sending Union into another Polymorphic gets hoisted
if ('_schemaAttribute' in definition && !this._schemaAttribute) {
this.schema = definition.schema;
this._schemaAttribute = definition._schemaAttribute;
} else {
this.schema = definition;
}
}
getSchemaAttribute(input, parent, key) {
return !this.isSingleSchema && this._schemaAttribute(input, parent, key);
}
inferSchema(input, parent, key) {
if (this.isSingleSchema) {
return this.schema;
}
const attr = this.getSchemaAttribute(input, parent, key);
return this.schema[attr];
}
schemaKey() {
if (this.isSingleSchema) {
return this.schema.key;
}
return Object.values(this.schema).join(';');
}
normalizeValue(value, parent, key, args, visit) {
if (!value) return value;
const schema = this.inferSchema(value, parent, key);
if (!schema) {
/* istanbul ignore else */
if (process.env.NODE_ENV !== 'production') {
const attr = this.getSchemaAttribute(value, parent, key);
console.warn(`Schema attribute ${JSON.stringify(attr, undefined, 2)} is not expected.
Expected one of: ${Object.keys(this.schema).map(k => `"${k}"`).join(', ')}
Value: ${JSON.stringify(value, undefined, 2)}`);
}
return value;
}
const normalizedValue = visit(schema, value, parent, key, args);
return this.isSingleSchema || normalizedValue === undefined || normalizedValue === null ? normalizedValue : {
id: normalizedValue,
schema: this.getSchemaAttribute(value, parent, key)
};
}
// value is guaranteed by caller to not be null
denormalizeValue(value, unvisit) {
const schemaKey = !this.isSingleSchema && value && (isImmutable(value) ? value.get('schema') : value.schema);
if (!this.isSingleSchema && !schemaKey) {
// denormalize should also handle 'passthrough' values (not normalized) and still
// construct the correct Entity instance
if (typeof value === 'object' && value !== null) {
const schema = this.inferSchema(value, undefined, undefined);
if (schema) return unvisit(schema, value);
}
/* istanbul ignore else */
if (process.env.NODE_ENV !== 'production' && value) {
console.warn(`TypeError: Unable to infer schema for ${this.constructor.name}
Value: ${JSON.stringify(value, undefined, 2)}.`);
}
return value;
}
const id = this.isSingleSchema ? undefined : isImmutable(value) ? value.get('id') : value.id;
const schema = this.isSingleSchema ? this.schema : this.schema[schemaKey];
return unvisit(schema, id || value);
}
}
/**
* Represents polymorphic values.
* @see https://dataclient.io/rest/api/Union
*/
class UnionSchema extends PolymorphicSchema {
constructor(definition, schemaAttribute) {
if (!schemaAttribute) {
throw new Error('Expected option "schemaAttribute" not found on UnionSchema.');
}
super(definition, schemaAttribute);
}
normalize(input, parent, key, args, visit, addEntity, getEntity, checkLoop) {
return this.normalizeValue(input, parent, key, args, visit);
}
denormalize(input, args, unvisit) {
return this.denormalizeValue(input, unvisit);
}
queryKey(args, queryKey, getEntity, getIndex) {
if (!args[0]) return;
const schema = this.getSchemaAttribute(args[0], undefined, '');
const discriminatedSchema = this.schema[schema];
// Was unable to infer the entity's schema from params
if (discriminatedSchema === undefined) return;
const id = queryKey(discriminatedSchema, args, getEntity, getIndex);
if (id === undefined) return;
return {
id,
schema
};
}
}
/**
* Represents variably sized objects
* @see https://dataclient.io/rest/api/Values
*/
class ValuesSchema extends PolymorphicSchema {
normalize(input, parent, key, args, visit, addEntity, getEntity, checkLoop) {
return Object.keys(input).reduce((output, key, index) => {
const value = input[key];
return value !== undefined && value !== null ? {
...output,
[key]: this.normalizeValue(value, input, key, args, visit)
} : output;
}, {});
}
denormalize(input, args, unvisit) {
return Object.keys(input).reduce((output, key) => {
const entityOrId = input[key];
const value = this.denormalizeValue(entityOrId, unvisit);
// remove empty or deleted values
if (!value || typeof value === 'symbol') return output;
return {
...output,
[key]: value
};
}, {});
}
queryKey(args, queryKey, getEntity, getIndex) {
return undefined;
}
}
const getValues = input => Array.isArray(input) ? input : Object.keys(input).map(key => input[key]);
const filterEmpty = item => item !== undefined && typeof item !== 'symbol';
/**
* Represents arrays
* @see https://dataclient.io/rest/api/Array
*/
class ArraySchema extends PolymorphicSchema {
normalize(input, parent, key, args, visit, addEntity, getEntity, checkLoop) {
const values = getValues(input);
return values.map((value, index) => this.normalizeValue(value, parent, key, args, visit));
}
denormalize(input, args, unvisit) {
return input.map ? input.map(entityOrId => this.denormalizeValue(entityOrId, unvisit)).filter(filterEmpty) : input;
}
queryKey(args, queryKey, getEntity, getIndex) {
return undefined;
}
toJSON() {
return [this.schema];
}
}
const INVALID = Symbol('INVALID');
/**
* Retrieves all entities in cache
*
* @see https://dataclient.io/rest/api/All
*/
class AllSchema extends ArraySchema {
constructor(definition, schemaAttribute) {
super(definition, schemaAttribute);
}
normalize(input, parent, key, args, visit, addEntity, getEntity, checkLoop) {
// we return undefined
super.normalize(input, parent, key, args, visit, addEntity, getEntity, checkLoop);
}
queryKey(args, queryKey, getEntity, getIndex) {
if (this.isSingleSchema) {
const entitiesEntry = getEntity(this.schema.key);
// we must wait until there are entries for any 'All' query to be Valid
if (entitiesEntry === undefined) return INVALID;
return Object.values(entitiesEntry).map(entity => entity && this.schema.pk(entity));
}
let found = false;
const list = Object.values(this.schema).flatMap(schema => {
const entitiesEntry = getEntity(schema.key);
if (entitiesEntry === undefined) return [];
found = true;
return Object.values(entitiesEntry).map(entity => ({
id: entity && schema.pk(entity),
schema: this.getSchemaAttribute(entity, undefined, undefined)
}));
});
// we need at least one table entry of the Union for this to count as Valid.
if (!found) return INVALID;
return list;
}
}
const normalize = (schema, input, parent, key, args, visit, addEntity, getEntity, checkLoop) => {
const object = {
...input
};
Object.keys(schema).forEach(key => {
const localSchema = schema[key];
const value = visit(localSchema, input[key], input, key, args);
if (value === undefined) {
delete object[key];
} else {
object[key] = value;
}
});
return object;
};
function denormalize$1(schema, input, args, unvisit) {
if (isImmutable(input)) {
return denormalizeImmutable(schema, input, unvisit);
}
const object = {
...input
};
for (const key of Object.keys(schema)) {
const item = unvisit(schema[key], object[key]);
if (object[key] !== undefined) {
object[key] = item;
}
if (typeof item === 'symbol') {
return item;
}
}
return object;
}
function objectQueryKey(schema, args, queryKey, getEntity, getIndex) {
const resultObject = {};
Object.keys(schema).forEach(k => {
resultObject[k] = queryKey(schema[k], args, getEntity, getIndex);
});
return resultObject;
}
/**
* Represents objects with statically known members
* @see https://dataclient.io/rest/api/Object
*/
class ObjectSchema {
schema;
constructor(definition) {
this.define(definition);
}
define(definition) {
this.schema = Object.keys(definition).reduce((entitySchema, key) => {
const schema = definition[key];
return {
...entitySchema,
[key]: schema
};
}, this.schema || {});
}
normalize(...args) {
return normalize(this.schema, ...args);
}
denormalize(input, args, unvisit) {
return denormalize$1(this.schema, input, args, unvisit);
}
queryKey(args, queryKey, getEntity, getIndex) {
return objectQueryKey(this.schema, args, queryKey, getEntity, getIndex);
}
}
/**
* Marks entity as Invalid.
*
* This triggers suspense for all endpoints requiring it.
* Optional (like variable sized Array and Values) will simply remove the item.
* @see https://dataclient.io/rest/api/Invalidate
*/
class Invalidate {
/**
* Marks entity as Invalid.
*
* This triggers suspense for all endpoints requiring it.
* Optional (like variable sized Array and Values) will simply remove the item.
* @see https://dataclient.io/rest/api/Invalidate
*/
constructor(entity) {
if (process.env.NODE_ENV !== 'production' && !entity) {
throw new Error('Expected option "entity" not found on DeleteSchema.');
}
this._entity = entity;
}
get key() {
return this._entity.key;
}
/** Normalize lifecycles **/
normalize(input, parent, key, args, visit, addEntity, getEntity, checkLoop) {
// TODO: what's store needs to be a differing type from fromJS
const processedEntity = this._entity.process(input, parent, key, args);
const id = this._entity.pk(processedEntity, parent, key, args);
if (process.env.NODE_ENV !== 'production' && (id === undefined || id === '' || id === 'undefined')) {
const error = new Error(`Missing usable primary key when normalizing response.
This is likely due to a malformed response.
Try inspecting the network response or fetch() return value.
Or use debugging tools: https://dataclient.io/docs/getting-started/debugging
Learn more about schemas: https://dataclient.io/docs/api/schema
Invalidate(Entity): Invalidate(${this._entity.key})
Value (processed): ${input && JSON.stringify(input, null, 2)}
`);
error.status = 400;
throw error;
}
addEntity(this, INVALID, id);
return id;
}
/* istanbul ignore next */
merge(existing, incoming) {
return incoming;
}
mergeWithStore(existingMeta, incomingMeta, existing, incoming) {
// any queued updates are meaningless with delete, so we should just set it
return this.merge(existing, incoming);
}
mergeMetaWithStore(existingMeta, incomingMeta, existing, incoming) {
return incomingMeta;
}
/** /End Normalize lifecycles **/
queryKey(args, queryKey, getEntity, getIndex) {
return undefined;
}
denormalize(id, args, unvisit) {
return unvisit(this._entity, id);
}
/* istanbul ignore next */
_denormalizeNullable() {
return {};
}
/* istanbul ignore next */
_normalizeNullable() {
return {};
}
}
/** This serializes in consistent way even if members are added in differnet orders */
function consistentSerialize(obj) {
const keys = Object.keys(obj).sort();
const sortedObj = {};
for (const key of keys) {
sortedObj[key] = obj[key];
}
return JSON.stringify(sortedObj);
}
const pushMerge = (existing, incoming) => {
return [...existing, ...incoming];
};
const unshiftMerge = (existing, incoming) => {
return [...incoming, ...existing];
};
const valuesMerge = (existing, incoming) => {
return {
...existing,
...incoming
};
};
const createArray = value => [...value];
const createValue = value => ({
...value
});
/**
* Entities but for Arrays instead of classes
* @see https://dataclient.io/rest/api/Collection
*/
class CollectionSchema {
addWith(merge, createCollectionFilter) {
return CreateAdder(this, merge, createCollectionFilter);
}
// this adds to any list *in store* that has same members as the urlParams
// so fetch(create, { userId: 'bob', completed: true }, data)
// would possibly add to {}, {userId: 'bob'}, {completed: true}, {userId: 'bob', completed: true } - but only those already in the store
// it ignores keys that start with sort as those are presumed to not filter results
createCollectionFilter(...args) {
return collectionKey => Object.entries(collectionKey).every(([key, value]) => {
var _args$;
return this.nonFilterArgumentKeys(key) ||
// strings are canonical form. See pk() above for value transformation
`${args[0][key]}` === value || `${(_args$ = args[1]) == null ? void 0 : _args$[key]}` === value;
});
}
nonFilterArgumentKeys(key) {
return key.startsWith('order');
}
constructor(schema, options) {
this.schema = Array.isArray(schema) ? new ArraySchema(schema[0]) : schema;
if (!options) {
this.argsKey = params => ({
...params
});
} else {
if ('nestKey' in options) {
this.nestKey = options.nestKey;
} else if ('argsKey' in options) {
this.argsKey = options.argsKey;
} else {
this.argsKey = params => ({
...params
});
}
}
this.key = keyFromSchema(this.schema);
if (options != null && options.nonFilterArgumentKeys) {
const {
nonFilterArgumentKeys
} = options;
if (typeof nonFilterArgumentKeys === 'function') {
this.nonFilterArgumentKeys = nonFilterArgumentKeys;
} else if (nonFilterArgumentKeys instanceof RegExp) {
this.nonFilterArgumentKeys = key => nonFilterArgumentKeys.test(key);
} else {
this.nonFilterArgumentKeys = key => nonFilterArgumentKeys.includes(key);
}
} else if (options != null && options.createCollectionFilter)
// TODO(breaking): rename to filterCollections
this.createCollectionFilter = options.createCollectionFilter.bind(this);
// >>>>>>>>>>>>>>CREATION<<<<<<<<<<<<<<
if (this.schema instanceof ArraySchema) {
this.createIfValid = createArray;
this.push = CreateAdder(this, pushMerge);
this.unshift = CreateAdder(this, unshiftMerge);
} else if (schema instanceof ValuesSchema) {
this.createIfValid = createValue;
this.assign = CreateAdder(this, valuesMerge);
}
}
get cacheWith() {
return this.schema.schema;
}
toString() {
return this.key;
}
toJSON() {
return {
key: this.key,
schema: this.schema.schema.toJSON()
};
}
pk(value, parent, key, args) {
const obj = this.argsKey ? this.argsKey(...args) : this.nestKey(parent, key);
for (const key in obj) {
if (['number', 'boolean'].includes(typeof obj[key])) obj[key] = `${obj[key]}`;
}
return consistentSerialize(obj);
}
// >>>>>>>>>>>>>>NORMALIZE<<<<<<<<<<<<<<
normalize(input, parent, key, args, visit, addEntity, getEntity, checkLoop) {
const normalizedValue = this.schema.normalize(input, parent, key, args, visit, addEntity, getEntity, checkLoop);
const id = this.pk(normalizedValue, parent, key, args);
addEntity(this, normalizedValue, id);
return id;
}
// always replace
merge(existing, incoming) {
return incoming;
}
shouldReorder(existingMeta, incomingMeta, existing, incoming) {
return incomingMeta.fetchedAt < existingMeta.fetchedAt;
}
mergeWithStore(existingMeta, incomingMeta, existing, incoming) {
return this.shouldReorder(existingMeta, incomingMeta, existing, incoming) ? this.merge(incoming, existing) : this.merge(existing, incoming);
}
mergeMetaWithStore(existingMeta, incomingMeta, existing, incoming) {
return this.shouldReorder(existingMeta, incomingMeta, existing, incoming) ? existingMeta : incomingMeta;
}
// >>>>>>>>>>>>>>DENORMALIZE<<<<<<<<<<<<<<
queryKey(args, queryKey, getEntity, getIndex) {
if (this.argsKey) {
const id = this.pk(undefined, undefined, '', args);
// ensure this actually has entity or we shouldn't try to use it in our query
if (getEntity(this.key, id)) return id;
}
}
denormalize(input, args, unvisit) {
return this.schema.denormalize(input, args, unvisit);
}
}
function CreateAdder(collection, merge, createCollectionFilter) {
const properties = {
merge: {
value: merge
},
normalize: {
value: normalizeCreate
},
queryKey: {
value: queryKeyCreate
}
};
if (collection.schema instanceof ArraySchema) {
properties.createIfValid = {
value: createIfValid
};
properties.denormalize = {
value: denormalize
};
}
if (createCollectionFilter) {
properties.createCollectionFilter = {
value: createCollectionFilter
};
}
return Object.create(collection, properties);
}
function queryKeyCreate() {}
function normalizeCreate(input, parent, key, args, visit, addEntity, getEntity, checkLoop) {
if (process.env.NODE_ENV !== 'production') {
// means 'this is a creation endpoint' - so real PKs are not required
// this is used by Entity.normalize() to determine whether to allow empty pks
// visit instances are created on each normalize call so this will safely be reset
visit.creating = true;
}
const normalizedValue = this.schema.normalize(!(this.schema instanceof ArraySchema) || Array.isArray(input) ? input : [input], parent, key, args, visit, addEntity, getEntity, checkLoop);
// parent is args when not nested
const filterCollections = this.createCollectionFilter(...args);
// add to any collections that match this
const entities = getEntity(this.key);
if (entities) Object.keys(entities).forEach(collectionPk => {
if (!filterCollections(JSON.parse(collectionPk))) return;
addEntity(this, normalizedValue, collectionPk);
});
return normalizedValue;
}
function createIfValid(value) {
return Array.isArray(value) ? [...value] : {
...value
};
}
// only for arrays
function denormalize(input, args, unvisit) {
return Array.isArray(input) ? this.schema.denormalize(input, args, unvisit) : this.schema.denormalize([input], args, unvisit)[0];
}
/**
* We call schema.denormalize and schema.normalize directly
* instead of visit/unvisit as we are not operating on new data
* so the additional checks in those methods are redundant
*/
function keyFromSchema(schema) {
if (schema instanceof ArraySchema) {
// this assumes the definition of Array/Values is Entity
return `[${schema.schemaKey()}]`;
} else if (schema instanceof ValuesSchema) {
return `{${schema.schemaKey()}}`;
}
return `(${schema.schemaKey()})`;
}
/**
* Turns any class into an Entity.
* @see https://dataclient.io/rest/api/EntityMixin
*/
// id is in Instance, so we default to that as pk
// pk was specified in options, so we don't need to redefine
function EntityMixin(Base, options = {}) {
/**
* Entity defines a single (globally) unique object.
* @see https://dataclient.io/rest/api/Entity
*/
class EntityMixin extends Base {
static toString() {
return this.key;
}
static toJSON() {
return {
key: this.key,
schema: this.schema
};
}
/** Defines nested entities */
/**
* A unique identifier for each Entity
*
* @see https://dataclient.io/rest/api/Entity#pk
* @param [parent] When normalizing, the object which included the entity
* @param [key] When normalizing, the key where this entity was found
* @param [args] ...args sent to Endpoint
*/
/** Returns the globally unique identifier for the static Entity */
// default implementation in class static block at bottom of definition
/** Defines indexes to enable lookup by */
/**
* A unique identifier for each Entity
*
* @see https://dataclient.io/rest/api/Entity#pk
* @param [value] POJO of the entity or subset used
* @param [parent] When normalizing, the object which included the entity
* @param [key] When normalizing, the key where this entity was found
* @param [args] ...args sent to Endpoint
*/
static pk(value, parent, key, args) {
return this.prototype.pk.call(value, parent, key, args);
}
/** Return true to merge incoming data; false keeps existing entity
*
* @see https://dataclient.io/rest/api/Entity#shouldUpdate
*/
static shouldUpdate(existingMeta, incomingMeta, existing, incoming) {
return true;
}
/** Determines the order of incoming entity vs entity already in store\
*
* @see https://dataclient.io/rest/api/Entity#shouldReorder
* @returns true if incoming entity should be first argument of merge()
*/
static shouldReorder(existingMeta, incomingMeta, existing, incoming) {
return incomingMeta.fetchedAt < existingMeta.fetchedAt;
}
/** Creates new instance copying over defined values of arguments
*
* @see https://dataclient.io/rest/api/Entity#merge
*/
static merge(existing, incoming) {
return {
...existing,
...incoming
};
}
/** Run when an existing entity is found in the store
*
* @see https://dataclient.io/rest/api/Entity#mergeWithStore
*/
static mergeWithStore(existingMeta, incomingMeta, existing, incoming) {
const shouldUpdate = this.shouldUpdate(existingMeta, incomingMeta, existing, incoming);
if (shouldUpdate) {
// distinct types are not mergeable (like delete symbol), so just replace
if (typeof incoming !== typeof existing) {
return incoming;
} else {
return this.shouldReorder(existingMeta, incomingMeta, existing, incoming) ? this.merge(incoming, existing) : this.merge(existing, incoming);
}
} else {
return existing;
}
}
/** Run when an existing entity is found in the store
*
* @see https://dataclient.io/rest/api/Entity#mergeMetaWithStore
*/
static mergeMetaWithStore(existingMeta, incomingMeta, existing, incoming) {
return this.shouldReorder(existingMeta, incomingMeta, existing, incoming) ? existingMeta : incomingMeta;
}
/** Factory method to convert from Plain JS Objects.
*
* @param [props] Plain Object of properties to assign.
*/
static fromJS(
// TODO: this should only accept members that are not functions
props = {}) {
// we type guarded abstract case above, so ok to force typescript to allow constructor call
const instance = new this(props);
// we can't rely on constructors and override the defaults provided as property assignments
// all occur after the constructor
Object.assign(instance, props);
return instance;
}
/** Called when denormalizing an entity to create an instance when 'valid'
*
* @see https://dataclient.io/rest/api/Entity#createIfValid
* @param [props] Plain Object of properties to assign.
*/
static createIfValid(
// TODO: this should only accept members that are not functions
props) {
if (this.validate(props)) {
return undefined;
}
return this.fromJS(props);
}
/** Do any transformations when first receiving input
*
* @see https://dataclient.io/rest/api/Entity#process
*/
static process(input, parent, key, args) {
return {
...input
};
}
static normalize(input, parent, key, args, visit, addEntity, getEntity, checkLoop) {
const processedEntity = this.process(input, parent, key, args);
let id;
if (typeof processedEntity === 'undefined') {
id = this.pk(input, parent, key, args);
addEntity(this, INVALID, id);
return id;
}
id = this.pk(processedEntity, parent, key, args);
if (id === undefined || id === '' || id === 'undefined') {
// create a random id if a valid one cannot be computed
// this is useful for optimistic creates that don't need real ids - just something to hold their place
id = `MISS-${Math.random()}`;
// 'creates' conceptually should allow missing PK to make optimistic creates easy
if (process.env.NODE_ENV !== 'production' && !visit.creating) {
let why;
if (!('pk' in options) && EntityMixin.prototype.pk === this.prototype.pk && !('id' in processedEntity)) {
why = `'id' missing but needed for default pk(). Try defining pk() for your Entity.`;
} else {
why = `This is likely due to a malformed response.
Try inspecting the network response or fetch() return value.
Or use debugging tools: https://dataclient.io/docs/getting-started/debugging`;
}
const error = new Error(`Missing usable primary key when normalizing response.
${why}
Learn more about primary keys: https://dataclient.io/rest/api/Entity#pk
Entity: ${this.key}
Value (processed): ${processedEntity && JSON.stringify(processedEntity, null, 2)}
`);
error.status = 400;
throw error;
}
} else {
id = `${id}`;
}
/* Circular reference short-circuiter */
if (checkLoop(this.key, id, input)) return id;
const errorMessage = this.validate(processedEntity);
throwValidationError(errorMessage);
Object.keys(this.schema).forEach(key => {
if (Object.hasOwn(processedEntity, key)) {
processedEntity[key] = visit(this.schema[key], processedEntity[key], processedEntity, key, args);
}
});
addEntity(this, processedEntity, id);
return id;
}
static validate(processedEntity) {
return;
}
static queryKey(args, queryKey, getEntity, getIndex) {
if (!args[0]) return;
const id = queryKeyCandidate(this, args, getIndex);
// ensure this actually has entity or we shouldn't try to use it in our query
if (getEntity(this.key, id)) return id;
}
static denormalize(input, args, unvisit) {
if (typeof input === 'symbol') {
return input;
}
// note: iteration order must be stable
for (const key of Object.keys(this.schema)) {
const schema = this.schema[key];
const value = unvisit(schema, input[key]);
if (typeof value === 'symbol') {
// if default is not 'falsy', then this is required, so propagate INVALID symbol
if (this.defaults[key]) {
return value;
}
input[key] = undefined;
} else {
input[key] = value;
}
}
return input;
}
/** All instance defaults set */
static get defaults() {
// we use hasOwn because we don't want to use a parents' defaults
if (!Object.hasOwn(this, '__defaults')) Object.defineProperty(this, '__defaults', {
value: new this(),
writable: true,
configurable: true
});
return this.__defaults;
}
}
const {
pk,
schema,
key,
...staticProps
} = options;
// remaining options
Object.assign(EntityMixin, staticProps);
if ('schema' in options) {
EntityMixin.schema = options.schema;
} else if (!Base.schema) {
EntityMixin.schema = {};
}
if ('pk' in options) {
if (typeof options.pk === 'function') {
EntityMixin.prototype.pk = function (parent, key) {
return options.pk(this, parent, key);
};
} else {
EntityMixin.prototype.pk = function () {
return this[options.pk];
};
}
// default to 'id' field if the base class doesn't have a pk
} else if (typeof Base.prototype.pk !== 'function') {
EntityMixin.prototype.pk = function () {
return this.id;
};
}
if ('key' in options) {
Object.defineProperty(EntityMixin, 'key', {
value: options.key,
configurable: true,
writable: true,
enumerable: true
});
} else if (!('key' in Base)) {
function set(value) {
Object.defineProperty(this, 'key', {
value,
writable: true,
enumerable: true,
configurable: true
});
}
const baseGet = function () {
const name = this.name === 'EntityMixin' ? Base.name : this.name;
/* istanbul ignore next */
if (process.env.NODE_ENV !== 'production' && (name === '' || name === 'EntityMixin' || name === '_temp')) throw new Error('Entity classes without a name must define `static key`\nSee: https://dataclient.io/rest/api/Entity#key');
return name;
};
const get = /* istanbul ignore if */
typeof document !== 'undefined' && document.CLS_MANGLE ? /* istanbul ignore next */function () {
document.CLS_MANGLE == null || document.CLS_MANGLE(this);
Object.defineProperty(EntityMixin, 'key', {
get: baseGet,
set,
enumerable: true,
configurable: true
});
return baseGet.call(this);
} : baseGet;
Object.defineProperty(EntityMixin, 'key', {
get,
set,
enumerable: true,
configurable: true
});
}
return EntityMixin;
}
function indexFromParams(params, indexes) {
if (!indexes) return undefined;
return indexes.find(index => Object.hasOwn(params, index));
}
// part of the reason for pulling this out is that all functions that throw are deoptimized
function throwValidationError(errorMessage) {
if (errorMessage) {
const error = new Error(errorMessage);
error.status = 400;
throw error;
}
}
function queryKeyCandidate(schema, args, getIndex) {
if (['string', 'number'].includes(typeof args[0])) {
return `${args[0]}`;
}
const id = schema.pk(args[0], undefined, '', args);
// Was able to infer the entity's primary key from params
if (id !== undefined && id !== '') return id;
// now attempt lookup in indexes
const indexName = indexFromParams(args[0], schema.indexes);
if (!indexName) return;
const value = args[0][indexName];
return getIndex(schema.key, indexName, value)[value];
}
/**
* Programmatic cache reading
*
* @see https://dataclient.io/rest/api/Query
*/
class Query {
/**
* Programmatic cache reading
*
* @see https://dataclient.io/rest/api/Query
*/
constructor(schema, process) {
this.schema = schema;
this.process = process;
}
normalize(...args) {
return this.schema.normalize(...args);
}
denormalize(input, args, unvisit) {
const value = unvisit(this.schema, input);
return typeof value === 'symbol' ? value : this.process(value, ...args);
}
queryKey(args, queryKey, getEntity, getIndex) {
return queryKey(this.schema, args, getEntity, getIndex);
}
}
/* istanbul ignore file */
var schema = /*#__PURE__*/Object.freeze({
__proto__: null,
All: AllSchema,
Array: ArraySchema,
Collection: CollectionSchema,
Entity: EntityMixin,
EntityMixin: EntityMixin,
Invalidate: Invalidate,
Object: ObjectSchema,
Query: Query,
Union: UnionSchema,
Values: ValuesSchema
});
const EmptyBase = class {};
/**
* Entity defines a single (globally) unique object.
* @see https://dataclient.io/rest/api/Entity
*/
class Entity extends EntityMixin(EmptyBase) {
/** Control how automatic schema validation is handled
*
* `undefined`: Defaults - throw error in worst offense
* 'warn': only ever warn
* 'silent': Don't bother with processing at all
*
* Note: this only applies to non-nested members.
*/
/** Factory method to convert from Plain JS Objects.
*
* @see https://dataclient.io/rest/api/Entity#fromJS
* @param [props] Plain Object of properties to assign.
*/
/**
* A unique identifier for each Entity
*
* @see https://dataclient.io/rest/api/Entity#pk
* @param [value] POJO of the entity or subset used
* @param [parent] When normalizing, the object which included the entity
* @param [key] When normalizing, the key where this entity was found
* @param [args] ...args sent to Endpoint
*/
/** Do any transformations when first receiving input
*
* @see https://dataclient.io/rest/api/Entity#process
*/
static process(input, parent, key, args) {
/* istanbul ignore else */
if (process.env.NODE_ENV !== 'production' && this.automaticValidation !== 'silent') {
if (Array.isArray(input)) {
const errorMessage = `Attempted to initialize ${this.name} with an array, but named members were expected
This is likely due to a malformed response.
Try inspecting the network response or fetch() return value.
Or use debugging tools: https://dataclient.io/docs/getting-started/debugging
Learn more about schemas: https://dataclient.io/docs/api/schema
If this is a mistake, you can disable this check by setting static automaticValidation = 'silent'
First three members: ${JSON.stringify(input.slice(0, 3), null, 2)}`;
if (this.automaticValidation !== 'warn') {
const error = new Error(errorMessage);
error.status = 400;
throw error;
}
console.warn(errorMessage);
}
}
return super.process(input, parent, key, args);
}
}
function validateRequired(processedEntity, requiredDefaults) {
let missingKey = '';
if (Object.keys(requiredDefaults).some(key => {
if (!Object.hasOwn(processedEntity, key)) {
missingKey = key;
return true;
}
return false;
})) {
return `Missing key ${missingKey}`;
}
}
var _document$querySelect;
/* istanbul ignore file */
const isBrowser = typeof document !== 'undefined';
let CSP = isBrowser && !((_document$querySelect = document.querySelector("meta[http-equiv='Content-Security-Policy']")) != null && _document$querySelect.getAttribute('content'));
try {
if (!CSP) Function();
} catch (e) {
CSP = true;
// TODO: figure out how to supress the error log instead of tell people it's okay
if (isBrowser) {
console.error('Content Security Policy: The previous CSP log can be safely ignored - @data-client/endpoint will use setPrototypeOf instead');
}
}
var _Endpoint;
/**
* Defines an async data source.
* @see https://dataclient.io/docs/api/Endpoint
*/
class Endpoint extends Function {
constructor(fetchFunction, options) {
let self;
if (CSP) {
self = (...args) => self.fetch(...args);
Object.setPrototypeOf(self, new.target.prototype);
} else {
super('return arguments.callee.fetch.apply(arguments.callee, arguments)');
self = this;
}
if (fetchFunction) self.fetch = fetchFunction;
/** Name propery block
*
* To make things callable, we force every instance to be constructed as a function
* Because of this the name property will be autoset
* To create a usable naming inheritance pattern, we use __name as a proxy.
* Every instance then overrides the name property.
*
* For protocol specific extensions that wish to customize default naming
* behavior, be sure to add your own `Object.defineProperty(self, 'name'`
* in your constructor to override this one.
*/
let autoName;
if (!(options && 'name' in options) && fetchFunction && fetchFunction.name && fetchFunction.name !== 'anonymous') {
autoName = fetchFunction.name;
}
Object.defineProperty(self, 'name', {
get() {
if (/* istanbul ignore else */process.env.NODE_ENV !== 'production' && self.key === Endpoint.prototype.key && !(autoName || this.__name)) {
console.error('Endpoint: Autonaming failure.\n\nEndpoint initialized with anonymous function.\nPlease add `name` option or hoist the function definition. https://dataclient.io/rest/api/Endpoint#name');
}
return autoName || this.__name;
},
set(v) {
this.__name = v;
}
});
/** End name property block */
Object.assign(self, options);
return self;
}
key(...args) {
return `${this.name} ${JSON.stringify(args)}`;
}
testKey(key) {
return key.startsWith(this.name);
}
bind(thisArg, ...args) {
const fetchFunc = this.fetch;
const keyFunc = this.key;
return this.extend({
fetch() {
return fetchFunc.apply(thisArg != null ? thisArg : this, args);
},
key() {
return keyFunc.apply(this, args);
}
});
}
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(options.fetch, options);
}
/* istanbul ignore next */
}
_Endpoint = Endpoint;
(() => {
/* istanbul ignore if */
if (typeof document !== 'undefined' && document.FUNC_MANGLE) {
const baseKey = _Endpoint.prototype.key;
_Endpoint.prototype.key = function (...args) {
document.FUNC_MANGLE == null || document.FUNC_MANGLE(this);
this.prototype.key = baseKey;
return baseKey.call(this, ...args);
};
}
})();
const ExtendableEndpoint = Endpoint;
Object.hasOwn = Object.hasOwn || /* istanbul ignore next */function hasOwn(it, key) {
return Object.prototype.hasOwnProperty.call(it, key);
};
exports.Endpoint = Endpoint;
exports.Entity = Entity;
exports.EntityMixin = EntityMixin;
exports.ExtendableEndpoint = ExtendableEndpoint;
exports.INVALID = INVALID;
exports.schema = schema;
exports.validateRequired = validateRequired;