UNPKG

@ember-data/json-api

Version:

Provides a JSON:API document and resource cache implementation for EmberData

691 lines (672 loc) 23.1 kB
import { setBuildURLConfig as setBuildURLConfig$1, buildBaseURL, buildQueryParams } from '@ember-data/request-utils'; import { pluralize } from '@ember-data/request-utils/string'; import { recordIdentifierFor } from '@ember-data/store'; import { macroCondition, getGlobalConfig } from '@embroider/macros'; /** * @module @ember-data/json-api/request */ const JsonApiAccept = 'application/vnd.api+json'; const DEFAULT_CONFIG = { host: '', namespace: '' }; let ACCEPT_HEADER_VALUE = 'application/vnd.api+json'; /** * Allows setting extensions and profiles to be used in the `Accept` header. * * Extensions and profiles are keyed by their namespace with the value being * their URI. * * Example: * * ```ts * setBuildURLConfig({ * extensions: { * atomic: 'https://jsonapi.org/ext/atomic' * }, * profiles: { * pagination: 'https://jsonapi.org/profiles/ethanresnick/cursor-pagination' * } * }); * ``` * * This also sets the global configuration for `buildBaseURL` * for host and namespace values for the application * in the `@ember-data/request-utils` package. * * These values may still be overridden by passing * them to buildBaseURL directly. * * This method may be called as many times as needed * * ```ts * type BuildURLConfig = { * host: string; * namespace: string' * } * ``` * * @method setBuildURLConfig * @static * @public * @for @ember-data/json-api/request * @param {BuildURLConfig} config * @return void */ function setBuildURLConfig(config) { Object.assign({}, DEFAULT_CONFIG, config); if (config.profiles || config.extensions) { let accept = JsonApiAccept; if (config.profiles) { const profiles = Object.values(config.profiles); if (profiles.length) { accept += ';profile="' + profiles.join(' ') + '"'; } } if (config.extensions) { const extensions = Object.values(config.extensions); if (extensions.length) { accept += ';ext=' + extensions.join(' '); } } ACCEPT_HEADER_VALUE = accept; } setBuildURLConfig$1(config); } function copyForwardUrlOptions(urlOptions, options) { if ('host' in options) { urlOptions.host = options.host; } if ('namespace' in options) { urlOptions.namespace = options.namespace; } if ('resourcePath' in options) { urlOptions.resourcePath = options.resourcePath; } } function extractCacheOptions(options) { const cacheOptions = {}; if ('reload' in options) { cacheOptions.reload = options.reload; } if ('backgroundReload' in options) { cacheOptions.backgroundReload = options.backgroundReload; } return cacheOptions; } /** * @module @ember-data/json-api/request */ /** * Builds request options to fetch a single resource by a known id or identifier * configured for the url and header expectations of most JSON:API APIs. * * **Basic Usage** * * ```ts * import { findRecord } from '@ember-data/json-api/request'; * * const data = await store.request(findRecord('person', '1')); * ``` * * **With Options** * * ```ts * import { findRecord } from '@ember-data/json-api/request'; * * const options = findRecord('person', '1', { include: ['pets', 'friends'] }); * const data = await store.request(options); * ``` * * **With an Identifier** * * ```ts * import { findRecord } from '@ember-data/json-api/request'; * * const options = findRecord({ type: 'person', id: '1' }, { include: ['pets', 'friends'] }); * const data = await store.request(options); * ``` * * **Supplying Options to Modify the Request Behavior** * * The following options are supported: * * - `host` - The host to use for the request, defaults to the `host` configured with `setBuildURLConfig`. * - `namespace` - The namespace to use for the request, defaults to the `namespace` configured with `setBuildURLConfig`. * - `resourcePath` - The resource path to use for the request, defaults to pluralizing the supplied type * - `reload` - Whether to forcibly reload the request if it is already in the store, not supplying this * option will delegate to the store's CachePolicy, defaulting to `false` if none is configured. * - `backgroundReload` - Whether to reload the request if it is already in the store, but to also resolve the * promise with the cached value, not supplying this option will delegate to the store's CachePolicy, * defaulting to `false` if none is configured. * - `urlParamsSetting` - an object containing options for how to serialize the query params (see `buildQueryParams`) * * ```ts * import { findRecord } from '@ember-data/json-api/request'; * * const options = findRecord('person', '1', { include: ['pets', 'friends'] }, { namespace: 'api/v2' }); * const data = await store.request(options); * ``` * * @method findRecord * @public * @static * @for @ember-data/json-api/request * @param identifier * @param options */ function findRecord(arg1, arg2, arg3) { const identifier = typeof arg1 === 'string' ? { type: arg1, id: arg2 } : arg1; const options = (typeof arg1 === 'string' ? arg3 : arg2) || {}; const cacheOptions = extractCacheOptions(options); const urlOptions = { identifier, op: 'findRecord', resourcePath: pluralize(identifier.type) }; copyForwardUrlOptions(urlOptions, options); const url = buildBaseURL(urlOptions); const headers = new Headers(); headers.append('Accept', ACCEPT_HEADER_VALUE); return { url: options.include?.length ? `${url}?${buildQueryParams({ include: options.include }, options.urlParamsSettings)}` : url, method: 'GET', headers, cacheOptions, op: 'findRecord', records: [identifier] }; } /** * @module @ember-data/json-api/request */ /** * Builds request options to query for resources, usually by a primary * type, configured for the url and header expectations of most JSON:API APIs. * * The key difference between this and `postQuery` is that this method will send the query * as query params in the url of a "GET" request instead of as the JSON body of a "POST" * request. * * **Basic Usage** * * ```ts * import { query } from '@ember-data/json-api/request'; * * const data = await store.request(query('person')); * ``` * * **With Query Params** * * ```ts * import { query } from '@ember-data/json-api/request'; * * const options = query('person', { include: ['pets', 'friends'] }); * const data = await store.request(options); * ``` * * **Supplying Options to Modify the Request Behavior** * * The following options are supported: * * - `host` - The host to use for the request, defaults to the `host` configured with `setBuildURLConfig`. * - `namespace` - The namespace to use for the request, defaults to the `namespace` configured with `setBuildURLConfig`. * - `resourcePath` - The resource path to use for the request, defaults to pluralizing the supplied type * - `reload` - Whether to forcibly reload the request if it is already in the store, not supplying this * option will delegate to the store's CachePolicy, defaulting to `false` if none is configured. * - `backgroundReload` - Whether to reload the request if it is already in the store, but to also resolve the * promise with the cached value, not supplying this option will delegate to the store's CachePolicy, * defaulting to `false` if none is configured. * - `urlParamsSetting` - an object containing options for how to serialize the query params (see `buildQueryParams`) * * ```ts * import { query } from '@ember-data/json-api/request'; * * const options = query('person', { include: ['pets', 'friends'] }, { reload: true }); * const data = await store.request(options); * ``` * * @method query * @public * @static * @for @ember-data/json-api/request * @param identifier * @param query * @param options */ function query(type, // eslint-disable-next-line @typescript-eslint/no-shadow query = {}, options = {}) { const cacheOptions = extractCacheOptions(options); const urlOptions = { identifier: { type }, op: 'query', resourcePath: pluralize(type) }; copyForwardUrlOptions(urlOptions, options); const url = buildBaseURL(urlOptions); const headers = new Headers(); headers.append('Accept', ACCEPT_HEADER_VALUE); const queryString = buildQueryParams(query, options.urlParamsSettings); return { url: queryString ? `${url}?${queryString}` : url, method: 'GET', headers, cacheOptions, op: 'query' }; } /** * Builds request options to query for resources, usually by a primary * type, configured for the url and header expectations of most JSON:API APIs. * * The key difference between this and `query` is that this method will send the query * as the JSON body of a "POST" request instead of as query params in the url of a "GET" * request. * * A CacheKey is generated from the url and query params, and used to cache the response * in the store. * * ```ts * import { postQuery } from '@ember-data/json-api/request'; * * const options = postQuery('person', { include: ['pets', 'friends'] }); * const data = await store.request(options); * ``` * * **Supplying Options to Modify the Request Behavior** * * The following options are supported: * * - `host` - The host to use for the request, defaults to the `host` configured with `setBuildURLConfig`. * - `namespace` - The namespace to use for the request, defaults to the `namespace` configured with `setBuildURLConfig`. * - `resourcePath` - The resource path to use for the request, defaults to pluralizing the supplied type * - `reload` - Whether to forcibly reload the request if it is already in the store, not supplying this * option will delegate to the store's CachePolicy, defaulting to `false` if none is configured. * - `backgroundReload` - Whether to reload the request if it is already in the store, but to also resolve the * promise with the cached value, not supplying this option will delegate to the store's CachePolicy, * defaulting to `false` if none is configured. * - `urlParamsSetting` - an object containing options for how to serialize the query params (see `buildQueryParams`) * * ```ts * import { postQuery } from '@ember-data/json-api/request'; * * const options = postQuery('person', { include: ['pets', 'friends'] }, { reload: true }); * const data = await store.request(options); * ``` * * @method postQuery * @public * @static * @for @ember-data/json-api/request * @param identifier * @param query * @param options */ function postQuery(type, // eslint-disable-next-line @typescript-eslint/no-shadow query = {}, options = {}) { const cacheOptions = extractCacheOptions(options); const urlOptions = { identifier: { type }, op: 'query', resourcePath: options.resourcePath ?? pluralize(type) }; copyForwardUrlOptions(urlOptions, options); const url = buildBaseURL(urlOptions); const headers = new Headers(); headers.append('Accept', ACCEPT_HEADER_VALUE); const queryData = structuredClone(query); cacheOptions.key = cacheOptions.key ?? `${url}?${buildQueryParams(queryData, options.urlParamsSettings)}`; return { url, method: 'POST', body: JSON.stringify(query), headers, cacheOptions: cacheOptions, op: 'query' }; } function isExisting(identifier) { return 'id' in identifier && identifier.id !== null && 'type' in identifier && identifier.type !== null; } /** * Builds request options to delete record for resources, * configured for the url, method and header expectations of most JSON:API APIs. * * **Basic Usage** * * ```ts * import { deleteRecord } from '@ember-data/json-api/request'; * * const person = store.peekRecord('person', '1'); * * // mark record as deleted * store.deleteRecord(person); * * // persist deletion * const data = await store.request(deleteRecord(person)); * ``` * * **Supplying Options to Modify the Request Behavior** * * The following options are supported: * * - `host` - The host to use for the request, defaults to the `host` configured with `setBuildURLConfig`. * - `namespace` - The namespace to use for the request, defaults to the `namespace` configured with `setBuildURLConfig`. * - `resourcePath` - The resource path to use for the request, defaults to pluralizing the supplied type * - `reload` - Whether to forcibly reload the request if it is already in the store, not supplying this * option will delegate to the store's CachePolicy, defaulting to `false` if none is configured. * - `backgroundReload` - Whether to reload the request if it is already in the store, but to also resolve the * promise with the cached value, not supplying this option will delegate to the store's CachePolicy, * defaulting to `false` if none is configured. * - `urlParamsSetting` - an object containing options for how to serialize the query params (see `buildQueryParams`) * * ```ts * import { deleteRecord } from '@ember-data/json-api/request'; * * const person = store.peekRecord('person', '1'); * * // mark record as deleted * store.deleteRecord(person); * * // persist deletion * const options = deleteRecord(person, { namespace: 'api/v1' }); * const data = await store.request(options); * ``` * * @method deleteRecord * @public * @static * @for @ember-data/json-api/request * @param record * @param options */ function deleteRecord(record, options = {}) { const identifier = recordIdentifierFor(record); macroCondition(getGlobalConfig().WarpDrive.env.DEBUG) ? (test => { if (!test) { throw new Error(`Expected to be given a record instance`); } })(identifier) : {}; macroCondition(getGlobalConfig().WarpDrive.env.DEBUG) ? (test => { if (!test) { throw new Error(`Cannot delete a record that does not have an associated type and id.`); } })(isExisting(identifier)) : {}; const urlOptions = { identifier: identifier, op: 'deleteRecord', resourcePath: pluralize(identifier.type) }; copyForwardUrlOptions(urlOptions, options); const url = buildBaseURL(urlOptions); const headers = new Headers(); headers.append('Accept', ACCEPT_HEADER_VALUE); return { url, method: 'DELETE', headers, op: 'deleteRecord', data: { record: identifier }, records: [identifier] }; } /** * Builds request options to create new record for resources, * configured for the url, method and header expectations of most JSON:API APIs. * * **Basic Usage** * * ```ts * import { createRecord } from '@ember-data/json-api/request'; * * const person = store.createRecord('person', { name: 'Ted' }); * const data = await store.request(createRecord(person)); * ``` * * **Supplying Options to Modify the Request Behavior** * * The following options are supported: * * - `host` - The host to use for the request, defaults to the `host` configured with `setBuildURLConfig`. * - `namespace` - The namespace to use for the request, defaults to the `namespace` configured with `setBuildURLConfig`. * - `resourcePath` - The resource path to use for the request, defaults to pluralizing the supplied type * - `reload` - Whether to forcibly reload the request if it is already in the store, not supplying this * option will delegate to the store's CachePolicy, defaulting to `false` if none is configured. * - `backgroundReload` - Whether to reload the request if it is already in the store, but to also resolve the * promise with the cached value, not supplying this option will delegate to the store's CachePolicy, * defaulting to `false` if none is configured. * - `urlParamsSetting` - an object containing options for how to serialize the query params (see `buildQueryParams`) * * ```ts * import { createRecord } from '@ember-data/json-api/request'; * * const person = store.createRecord('person', { name: 'Ted' }); * const options = createRecord(person, { namespace: 'api/v1' }); * const data = await store.request(options); * ``` * * @method createRecord * @public * @static * @for @ember-data/json-api/request * @param record * @param options */ function createRecord(record, options = {}) { const identifier = recordIdentifierFor(record); macroCondition(getGlobalConfig().WarpDrive.env.DEBUG) ? (test => { if (!test) { throw new Error(`Expected to be given a record instance`); } })(identifier) : {}; const urlOptions = { identifier: identifier, op: 'createRecord', resourcePath: pluralize(identifier.type) }; copyForwardUrlOptions(urlOptions, options); const url = buildBaseURL(urlOptions); const headers = new Headers(); headers.append('Accept', ACCEPT_HEADER_VALUE); return { url, method: 'POST', headers, op: 'createRecord', data: { record: identifier }, records: [identifier] }; } /** * Builds request options to update existing record for resources, * configured for the url, method and header expectations of most JSON:API APIs. * * **Basic Usage** * * ```ts * import { updateRecord } from '@ember-data/json-api/request'; * * const person = store.peekRecord('person', '1'); * person.name = 'Chris'; * const data = await store.request(updateRecord(person)); * ``` * * **Supplying Options to Modify the Request Behavior** * * The following options are supported: * * - `patch` - Allows caller to specify whether to use a PATCH request instead of a PUT request, defaults to `false`. * - `host` - The host to use for the request, defaults to the `host` configured with `setBuildURLConfig`. * - `namespace` - The namespace to use for the request, defaults to the `namespace` configured with `setBuildURLConfig`. * - `resourcePath` - The resource path to use for the request, defaults to pluralizing the supplied type * - `reload` - Whether to forcibly reload the request if it is already in the store, not supplying this * option will delegate to the store's CachePolicy, defaulting to `false` if none is configured. * - `backgroundReload` - Whether to reload the request if it is already in the store, but to also resolve the * promise with the cached value, not supplying this option will delegate to the store's CachePolicy, * defaulting to `false` if none is configured. * - `urlParamsSetting` - an object containing options for how to serialize the query params (see `buildQueryParams`) * * ```ts * import { updateRecord } from '@ember-data/json-api/request'; * * const person = store.peekRecord('person', '1'); * person.name = 'Chris'; * const options = updateRecord(person, { patch: true }); * const data = await store.request(options); * ``` * * @method updateRecord * @public * @static * @for @ember-data/json-api/request * @param record * @param options */ function updateRecord(record, options = {}) { const identifier = recordIdentifierFor(record); macroCondition(getGlobalConfig().WarpDrive.env.DEBUG) ? (test => { if (!test) { throw new Error(`Expected to be given a record instance`); } })(identifier) : {}; macroCondition(getGlobalConfig().WarpDrive.env.DEBUG) ? (test => { if (!test) { throw new Error(`Cannot update a record that does not have an associated type and id.`); } })(isExisting(identifier)) : {}; const urlOptions = { identifier: identifier, op: 'updateRecord', resourcePath: pluralize(identifier.type) }; copyForwardUrlOptions(urlOptions, options); const url = buildBaseURL(urlOptions); const headers = new Headers(); headers.append('Accept', ACCEPT_HEADER_VALUE); return { url, method: options.patch ? 'PATCH' : 'PUT', headers, op: 'updateRecord', data: { record: identifier }, records: [identifier] }; } /** * @module @ember-data/json-api/request */ /** * Serializes the current state of a resource or array of resources for use with POST or PUT requests. * * @method serializeResources * @static * @public * @for @ember-data/json-api/request * @param {Cache} cache} * @param {StableRecordIdentifier} identifier * @return {object} An object with a `data` property containing the serialized resource patch */ function serializeResources(cache, identifiers) { return { data: Array.isArray(identifiers) ? identifiers.map(identifier => _serializeResource(cache, identifier)) : _serializeResource(cache, identifiers) }; } function _serializeResource(cache, identifier) { const { id, lid, type } = identifier; // yup! this method actually does nothing. It's just here for the dev assertion // and to assist in providing a little sugar to the consuming app via the `serializeResources` utility const record = cache.peek(identifier); macroCondition(getGlobalConfig().WarpDrive.env.DEBUG) ? (test => { if (!test) { throw new Error(`A record with id ${String(id)} and type ${type} for lid ${lid} was not found not in the supplied Cache.`); } })(record) : {}; return record; } /** * Serializes changes to a resource for use with PATCH requests. * * Only attributes which are changed are serialized. * Only relationships which are changed are serialized. * * Collection relationships serialize the collection as a whole. * * If you would like to serialize updates to a collection more granularly * (for instance, as operations) request the diff from the store and * serialize as desired: * * ```ts * const relationshipDiffMap = cache.changedRelationships(identifier); * ``` * * @method serializePatch * @static * @public * @for @ember-data/json-api/request * @param {Cache} cache} * @param {StableRecordIdentifier} identifier * @return {object} An object with a `data` property containing the serialized resource patch */ function serializePatch(cache, identifier) { const { id, lid, type } = identifier; const record = cache.peek(identifier); macroCondition(getGlobalConfig().WarpDrive.env.DEBUG) ? (test => { if (!test) { throw new Error(`A record with id ${String(id)} and type ${type} for lid ${lid} was not found not in the supplied Cache.`); } })(record) : {}; const data = { type, lid, id }; if (cache.hasChangedAttrs(identifier)) { const attrsChanges = cache.changedAttrs(identifier); const attributes = {}; Object.keys(attrsChanges).forEach(key => { const change = attrsChanges[key]; const newVal = change[1]; attributes[key] = newVal === undefined ? null : newVal; }); data.attributes = attributes; } const changedRelationships = cache.changedRelationships(identifier); if (changedRelationships.size) { const relationships = {}; changedRelationships.forEach((diff, key) => { relationships[key] = { data: diff.localState }; }); data.relationships = relationships; } return { data }; } export { createRecord, deleteRecord, findRecord, postQuery, query, serializePatch, serializeResources, setBuildURLConfig, updateRecord };