@ember-data/json-api
Version:
Provides a JSON:API document and resource cache implementation for EmberData
691 lines (672 loc) • 23.1 kB
JavaScript
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 };