diff --git a/docs-generator/yuidoc.json b/docs-generator/yuidoc.json index 06b19aba864..ed1d1ca0d67 100644 --- a/docs-generator/yuidoc.json +++ b/docs-generator/yuidoc.json @@ -18,6 +18,9 @@ "../packages/adapter/src", "../packages/model/src", "../packages/serializer/src", + "../packages/rest/src", + "../packages/active-record/src", + "../packages/request-utils/src", "../packages/store/src", "../packages/json-api/src", "../packages/graph/src", diff --git a/packages/active-record/src/-private/builders/-types.ts b/ember-data-types/request.ts similarity index 56% rename from packages/active-record/src/-private/builders/-types.ts rename to ember-data-types/request.ts index 1eb54bd77fb..75a4e62cf13 100644 --- a/packages/active-record/src/-private/builders/-types.ts +++ b/ember-data-types/request.ts @@ -1,5 +1,6 @@ import { QueryParamsSerializationOptions } from '@ember-data/request-utils'; import type { ResourceIdentifierObject } from '@ember-data/types/q/ember-data-json-api'; +import { StableRecordIdentifier } from '@ember-data/types/q/identifier'; export type CacheOptions = { key?: string; @@ -23,6 +24,36 @@ export type QueryRequestOptions = { op: 'query'; }; +export type DeleteRequestOptions = { + url: string; + method: 'DELETE'; + headers: Headers; + op: 'deleteRecord'; + data: { + record: StableRecordIdentifier; + }; +}; + +export type UpdateRequestOptions = { + url: string; + method: 'PATCH' | 'PUT'; + headers: Headers; + op: 'updateRecord'; + data: { + record: StableRecordIdentifier; + }; +}; + +export type CreateRequestOptions = { + url: string; + method: 'POST'; + headers: Headers; + op: 'createRecord'; + data: { + record: StableRecordIdentifier; + }; +}; + export type RemotelyAccessibleIdentifier = { id: string; type: string; @@ -37,3 +68,7 @@ export type ConstrainedRequestOptions = { resourcePath?: string; urlParamsSettings?: QueryParamsSerializationOptions; }; + +export type FindRecordOptions = ConstrainedRequestOptions & { + include?: string | string[]; +}; diff --git a/packages/active-record/package.json b/packages/active-record/package.json index 63f082c68f5..50916c2c20c 100644 --- a/packages/active-record/package.json +++ b/packages/active-record/package.json @@ -25,7 +25,8 @@ "ember-cli-babel": "^7.26.11" }, "peerDependencies": { - "ember-inflector": "^4.0.2" + "ember-inflector": "^4.0.2", + "@ember-data/store": "^4.12.0 || ^5.0.0" }, "files": [ "addon-main.js", diff --git a/packages/active-record/src/-private/builders/-utils.ts b/packages/active-record/src/-private/builders/-utils.ts index 4b61550b934..5acdd78588a 100644 --- a/packages/active-record/src/-private/builders/-utils.ts +++ b/packages/active-record/src/-private/builders/-utils.ts @@ -1,6 +1,5 @@ import { type UrlOptions } from '@ember-data/request-utils'; - -import type { CacheOptions, ConstrainedRequestOptions } from './-types'; +import type { CacheOptions, ConstrainedRequestOptions } from '@ember-data/types/request'; export function copyForwardUrlOptions(urlOptions: UrlOptions, options: ConstrainedRequestOptions): void { if ('host' in options) { diff --git a/packages/active-record/src/-private/builders/find-record.ts b/packages/active-record/src/-private/builders/find-record.ts index df57e77a4d2..de1f00b6bd7 100644 --- a/packages/active-record/src/-private/builders/find-record.ts +++ b/packages/active-record/src/-private/builders/find-record.ts @@ -3,8 +3,12 @@ import { underscore } from '@ember/string'; import { pluralize } from 'ember-inflector'; import { buildBaseURL, buildQueryParams, type FindRecordUrlOptions } from '@ember-data/request-utils'; +import type { + ConstrainedRequestOptions, + FindRecordRequestOptions, + RemotelyAccessibleIdentifier, +} from '@ember-data/types/request'; -import type { ConstrainedRequestOptions, FindRecordRequestOptions, RemotelyAccessibleIdentifier } from './-types'; import { copyForwardUrlOptions, extractCacheOptions } from './-utils'; type FindRecordOptions = ConstrainedRequestOptions & { diff --git a/packages/active-record/src/-private/builders/query.ts b/packages/active-record/src/-private/builders/query.ts index faba00e92c9..3b90b3f3312 100644 --- a/packages/active-record/src/-private/builders/query.ts +++ b/packages/active-record/src/-private/builders/query.ts @@ -3,8 +3,8 @@ import { underscore } from '@ember/string'; import { pluralize } from 'ember-inflector'; import { buildBaseURL, buildQueryParams, QueryParamsSource, type QueryUrlOptions } from '@ember-data/request-utils'; +import type { ConstrainedRequestOptions, QueryRequestOptions } from '@ember-data/types/request'; -import type { ConstrainedRequestOptions, QueryRequestOptions } from './-types'; import { copyForwardUrlOptions, extractCacheOptions } from './-utils'; export function query( diff --git a/packages/active-record/src/-private/builders/save-record.ts b/packages/active-record/src/-private/builders/save-record.ts new file mode 100644 index 00000000000..b1a3ec7279c --- /dev/null +++ b/packages/active-record/src/-private/builders/save-record.ts @@ -0,0 +1,112 @@ +import { assert } from '@ember/debug'; +import { underscore } from '@ember/string'; + +import { pluralize } from 'ember-inflector'; + +import { + buildBaseURL, + type CreateRecordUrlOptions, + type DeleteRecordUrlOptions, + type UpdateRecordUrlOptions, +} from '@ember-data/request-utils'; +import { recordIdentifierFor } from '@ember-data/store'; +import type { StableExistingRecordIdentifier, StableRecordIdentifier } from '@ember-data/types/q/identifier'; +import { + ConstrainedRequestOptions, + CreateRequestOptions, + DeleteRequestOptions, + UpdateRequestOptions, +} from '@ember-data/types/request'; + +import { copyForwardUrlOptions } from './-utils'; + +function isExisting(identifier: StableRecordIdentifier): identifier is StableExistingRecordIdentifier { + return 'id' in identifier && identifier.id !== null && 'type' in identifier && identifier.type !== null; +} + +export function deleteRecord(record: unknown, options: ConstrainedRequestOptions = {}): DeleteRequestOptions { + const identifier = recordIdentifierFor(record); + assert(`Expected to be given a record instance`, identifier); + assert(`Cannot delete a record that does not have an associated type and id.`, isExisting(identifier)); + + const urlOptions: DeleteRecordUrlOptions = { + identifier: identifier, + op: 'deleteRecord', + resourcePath: pluralize(underscore(identifier.type)), + }; + + copyForwardUrlOptions(urlOptions, options); + + const url = buildBaseURL(urlOptions); + const headers = new Headers(); + headers.append('Content-Type', 'application/json; charset=utf-8'); + + return { + url, + method: 'DELETE', + headers, + op: 'deleteRecord', + data: { + record: identifier, + }, + }; +} + +export function createRecord(record: unknown, options: ConstrainedRequestOptions = {}): CreateRequestOptions { + const identifier = recordIdentifierFor(record); + assert(`Expected to be given a record instance`, identifier); + assert(`Cannot delete a record that does not have an associated type and id.`, isExisting(identifier)); + + const urlOptions: CreateRecordUrlOptions = { + identifier: identifier, + op: 'createRecord', + resourcePath: pluralize(underscore(identifier.type)), + }; + + copyForwardUrlOptions(urlOptions, options); + + const url = buildBaseURL(urlOptions); + const headers = new Headers(); + headers.append('Content-Type', 'application/json; charset=utf-8'); + + return { + url, + method: 'POST', + headers, + op: 'createRecord', + data: { + record: identifier, + }, + }; +} + +export function updateRecord( + record: unknown, + options: ConstrainedRequestOptions & { patch?: boolean } = {} +): UpdateRequestOptions { + const identifier = recordIdentifierFor(record); + assert(`Expected to be given a record instance`, identifier); + assert(`Cannot delete a record that does not have an associated type and id.`, isExisting(identifier)); + + const urlOptions: UpdateRecordUrlOptions = { + identifier: identifier, + op: 'updateRecord', + resourcePath: pluralize(underscore(identifier.type)), + }; + + copyForwardUrlOptions(urlOptions, options); + + const url = buildBaseURL(urlOptions); + const headers = new Headers(); + headers.append('Content-Type', 'application/json; charset=utf-8'); + + return { + url, + method: options.patch ? 'PATCH' : 'PUT', + headers, + op: 'updateRecord', + data: { + record: identifier, + }, + }; +} diff --git a/packages/active-record/src/request.ts b/packages/active-record/src/request.ts index 17b1cc2771b..2f4654e5eaa 100644 --- a/packages/active-record/src/request.ts +++ b/packages/active-record/src/request.ts @@ -1,2 +1,3 @@ export { findRecord } from './-private/builders/find-record'; export { query } from './-private/builders/query'; +export { deleteRecord, createRecord, updateRecord } from './-private/builders/save-record'; diff --git a/packages/json-api/src/-private/builders/-types.ts b/packages/json-api/src/-private/builders/-types.ts deleted file mode 100644 index 1eb54bd77fb..00000000000 --- a/packages/json-api/src/-private/builders/-types.ts +++ /dev/null @@ -1,39 +0,0 @@ -import { QueryParamsSerializationOptions } from '@ember-data/request-utils'; -import type { ResourceIdentifierObject } from '@ember-data/types/q/ember-data-json-api'; - -export type CacheOptions = { - key?: string; - reload?: boolean; - backgroundReload?: boolean; -}; -export type FindRecordRequestOptions = { - url: string; - method: 'GET'; - headers: Headers; - cacheOptions: CacheOptions; - op: 'findRecord'; - records: [ResourceIdentifierObject]; -}; - -export type QueryRequestOptions = { - url: string; - method: 'GET'; - headers: Headers; - cacheOptions: CacheOptions; - op: 'query'; -}; - -export type RemotelyAccessibleIdentifier = { - id: string; - type: string; - lid?: string; -}; - -export type ConstrainedRequestOptions = { - reload?: boolean; - backgroundReload?: boolean; - host?: string; - namespace?: string; - resourcePath?: string; - urlParamsSettings?: QueryParamsSerializationOptions; -}; diff --git a/packages/json-api/src/-private/builders/-utils.ts b/packages/json-api/src/-private/builders/-utils.ts index 4b61550b934..5acdd78588a 100644 --- a/packages/json-api/src/-private/builders/-utils.ts +++ b/packages/json-api/src/-private/builders/-utils.ts @@ -1,6 +1,5 @@ import { type UrlOptions } from '@ember-data/request-utils'; - -import type { CacheOptions, ConstrainedRequestOptions } from './-types'; +import type { CacheOptions, ConstrainedRequestOptions } from '@ember-data/types/request'; export function copyForwardUrlOptions(urlOptions: UrlOptions, options: ConstrainedRequestOptions): void { if ('host' in options) { diff --git a/packages/json-api/src/-private/builders/find-record.ts b/packages/json-api/src/-private/builders/find-record.ts index c6bedf559e7..27f63bde855 100644 --- a/packages/json-api/src/-private/builders/find-record.ts +++ b/packages/json-api/src/-private/builders/find-record.ts @@ -1,14 +1,14 @@ import { pluralize } from 'ember-inflector'; import { buildBaseURL, buildQueryParams, type FindRecordUrlOptions } from '@ember-data/request-utils'; +import type { + FindRecordOptions, + FindRecordRequestOptions, + RemotelyAccessibleIdentifier, +} from '@ember-data/types/request'; -import type { ConstrainedRequestOptions, FindRecordRequestOptions, RemotelyAccessibleIdentifier } from './-types'; import { copyForwardUrlOptions, extractCacheOptions } from './-utils'; -type FindRecordOptions = ConstrainedRequestOptions & { - include?: string | string[]; -}; - export function findRecord( identifier: RemotelyAccessibleIdentifier, options?: FindRecordOptions diff --git a/packages/json-api/src/-private/builders/query.ts b/packages/json-api/src/-private/builders/query.ts index 0f606982c45..4f8f8b4d472 100644 --- a/packages/json-api/src/-private/builders/query.ts +++ b/packages/json-api/src/-private/builders/query.ts @@ -1,8 +1,8 @@ import { pluralize } from 'ember-inflector'; import { buildBaseURL, buildQueryParams, QueryParamsSource, type QueryUrlOptions } from '@ember-data/request-utils'; +import type { ConstrainedRequestOptions, QueryRequestOptions } from '@ember-data/types/request'; -import type { ConstrainedRequestOptions, QueryRequestOptions } from './-types'; import { copyForwardUrlOptions, extractCacheOptions } from './-utils'; export function query( diff --git a/packages/json-api/src/-private/builders/save-record.ts b/packages/json-api/src/-private/builders/save-record.ts new file mode 100644 index 00000000000..d3acdbe22a5 --- /dev/null +++ b/packages/json-api/src/-private/builders/save-record.ts @@ -0,0 +1,114 @@ +import { assert } from '@ember/debug'; + +import { pluralize } from 'ember-inflector'; + +import { + buildBaseURL, + type CreateRecordUrlOptions, + type DeleteRecordUrlOptions, + type UpdateRecordUrlOptions, +} from '@ember-data/request-utils'; +import { recordIdentifierFor } from '@ember-data/store'; +import type { StableExistingRecordIdentifier, StableRecordIdentifier } from '@ember-data/types/q/identifier'; +import { + ConstrainedRequestOptions, + CreateRequestOptions, + DeleteRequestOptions, + UpdateRequestOptions, +} from '@ember-data/types/request'; + +import { copyForwardUrlOptions } from './-utils'; + +function isExisting(identifier: StableRecordIdentifier): identifier is StableExistingRecordIdentifier { + return 'id' in identifier && identifier.id !== null && 'type' in identifier && identifier.type !== null; +} + +export function deleteRecord(record: unknown, options: ConstrainedRequestOptions = {}): DeleteRequestOptions { + const identifier = recordIdentifierFor(record); + assert(`Expected to be given a record instance`, identifier); + assert(`Cannot delete a record that does not have an associated type and id.`, isExisting(identifier)); + + const urlOptions: DeleteRecordUrlOptions = { + identifier: identifier, + op: 'deleteRecord', + resourcePath: pluralize(identifier.type), + }; + + copyForwardUrlOptions(urlOptions, options); + + const url = buildBaseURL(urlOptions); + const headers = new Headers(); + headers.append('Accept', 'application/vnd.api+json'); + headers.append('Content-Type', 'application/vnd.api+json'); + + return { + url, + method: 'DELETE', + headers, + op: 'deleteRecord', + data: { + record: identifier, + }, + }; +} + +export function createRecord(record: unknown, options: ConstrainedRequestOptions = {}): CreateRequestOptions { + const identifier = recordIdentifierFor(record); + assert(`Expected to be given a record instance`, identifier); + assert(`Cannot delete a record that does not have an associated type and id.`, isExisting(identifier)); + + const urlOptions: CreateRecordUrlOptions = { + identifier: identifier, + op: 'createRecord', + resourcePath: pluralize(identifier.type), + }; + + copyForwardUrlOptions(urlOptions, options); + + const url = buildBaseURL(urlOptions); + const headers = new Headers(); + headers.append('Accept', 'application/vnd.api+json'); + headers.append('Content-Type', 'application/vnd.api+json'); + + return { + url, + method: 'POST', + headers, + op: 'createRecord', + data: { + record: identifier, + }, + }; +} + +export function updateRecord( + record: unknown, + options: ConstrainedRequestOptions & { patch?: boolean } = {} +): UpdateRequestOptions { + const identifier = recordIdentifierFor(record); + assert(`Expected to be given a record instance`, identifier); + assert(`Cannot delete a record that does not have an associated type and id.`, isExisting(identifier)); + + const urlOptions: UpdateRecordUrlOptions = { + identifier: identifier, + op: 'updateRecord', + resourcePath: pluralize(identifier.type), + }; + + copyForwardUrlOptions(urlOptions, options); + + const url = buildBaseURL(urlOptions); + const headers = new Headers(); + headers.append('Accept', 'application/vnd.api+json'); + headers.append('Content-Type', 'application/vnd.api+json'); + + return { + url, + method: options.patch ? 'PATCH' : 'PUT', + headers, + op: 'updateRecord', + data: { + record: identifier, + }, + }; +} diff --git a/packages/json-api/src/request.ts b/packages/json-api/src/request.ts index 17b1cc2771b..2f4654e5eaa 100644 --- a/packages/json-api/src/request.ts +++ b/packages/json-api/src/request.ts @@ -1,2 +1,3 @@ export { findRecord } from './-private/builders/find-record'; export { query } from './-private/builders/query'; +export { deleteRecord, createRecord, updateRecord } from './-private/builders/save-record'; diff --git a/packages/request-utils/src/index.ts b/packages/request-utils/src/index.ts index 5d9ecf5afd2..6e20df7828e 100644 --- a/packages/request-utils/src/index.ts +++ b/packages/request-utils/src/index.ts @@ -1,8 +1,44 @@ import { assert } from '@ember/debug'; /** - @module @ember-data/request-utils -*/ + * Simple utility function to assist in url building, + * query params, and other common request operations. + * + * These primitives may be used directly or composed + * by request builders to provide a consistent interface + * for building requests. + * + * For instance: + * + * ```ts + * import { buildBaseURL, buildQueryParams } from '@ember-data/request-utils'; + * + * const baseURL = buildBaseURL({ + * host: 'https://api.example.com', + * namespace: 'api/v1', + * resourcePath: 'emberDevelopers', + * op: 'query', + * identifier: { type: 'ember-developer' } + * }); + * const url = `${baseURL}?${buildQueryParams({ name: 'Chris', include:['pets'] })}`; + * // => 'https://api.example.com/api/v1/emberDevelopers?include=pets&name=Chris' + * ``` + * + * This is useful, but not as useful as the REST request builder for query which is sugar + * over this (and more!): + * + * ```ts + * import { query } from '@ember-data/rest/request'; + * + * const options = query('ember-developer', { name: 'Chris', include:['pets'] }); + * // => { url: 'https://api.example.com/api/v1/emberDevelopers?include=pets&name=Chris' } + * // Note: options will also include other request options like headers, method, etc. + * ``` + * + * @module @ember-data/request-utils + * @main @ember-data/request-utils + * @public + */ // prevents the final constructed object from needing to add // host and namespace which are provided by the final consuming @@ -55,7 +91,7 @@ export interface FindRelatedCollectionUrlOptions { } export interface FindRelatedResourceUrlOptions { - op: 'findRelatedResource'; + op: 'findRelatedRecord'; identifier: { type: string; id: string }; fieldPath: string; resourcePath?: string; @@ -99,7 +135,7 @@ export type UrlOptions = const OPERATIONS_WITH_PRIMARY_RECORDS = new Set([ 'findRecord', - 'findRelatedResource', + 'findRelatedRecord', 'findRelatedCollection', 'updateRecord', 'deleteRecord', @@ -120,6 +156,47 @@ function resourcePathForType(options: UrlOptions): string { return options.op === 'findMany' ? options.identifiers[0].type : options.identifier.type; } +/** + * Builds a URL for a request based on the provided options. + * Does not include support for building query params (see `buildQueryParams`) + * so that it may be composed cleanly with other query-params strategies. + * + * Usage: + * + * ```ts + * import { buildBaseURL } from '@ember-data/request-utils'; + * + * const url = buildBaseURL({ + * host: 'https://api.example.com', + * namespace: 'api/v1', + * resourcePath: 'emberDevelopers', + * op: 'query', + * identifier: { type: 'ember-developer' } + * }); + * + * // => 'https://api.example.com/api/v1/emberDevelopers' + * ``` + * + * On the surface this may seem like a lot of work to do something simple, but + * it is designed to be composable with other utilities and interfaces that the + * average product engineer will never need to see or use. + * + * A few notes: + * + * - `resourcePath` is optional, but if it is not provided, `identifier.type` will be used. + * - `host` and `namespace` are optional, but if they are not provided, the values globally + * configured via `setBuildURLConfig` will be used. + * - `op` is required and must be one of the following: + * - 'findRecord' 'query' 'findMany' 'findRelatedCollection' 'findRelatedRecord'` 'createRecord' 'updateRecord' 'deleteRecord' + * - Depending on the value of `op`, `identifier` or `identifiers` will be required. + * + * @method buildBaseURL + * @static + * @public + * @for @ember-data/request-utils + * @param urlOptions + * @returns string + */ export function buildBaseURL(urlOptions: UrlOptions): string { const options = Object.assign( { @@ -176,7 +253,7 @@ export function buildBaseURL(urlOptions: UrlOptions): string { (options as { op: string }).op )} request to ${resourcePath} but op must be one of "${[ 'findRecord', - 'findRelatedResource', + 'findRelatedRecord', 'findRelatedCollection', 'updateRecord', 'deleteRecord', @@ -189,7 +266,7 @@ export function buildBaseURL(urlOptions: UrlOptions): string { 'query', 'findMany', 'findRelatedCollection', - 'findRelatedResource', + 'findRelatedRecord', 'createRecord', 'updateRecord', 'deleteRecord', diff --git a/packages/rest/package.json b/packages/rest/package.json index cc2df56e949..32c5b0c16f6 100644 --- a/packages/rest/package.json +++ b/packages/rest/package.json @@ -25,7 +25,8 @@ "ember-cli-babel": "^7.26.11" }, "peerDependencies": { - "ember-inflector": "^4.0.2" + "ember-inflector": "^4.0.2", + "@ember-data/store": "^4.12.0 || ^5.0.0" }, "files": [ "addon-main.js", diff --git a/packages/rest/src/-private/builders/-types.ts b/packages/rest/src/-private/builders/-types.ts deleted file mode 100644 index 1eb54bd77fb..00000000000 --- a/packages/rest/src/-private/builders/-types.ts +++ /dev/null @@ -1,39 +0,0 @@ -import { QueryParamsSerializationOptions } from '@ember-data/request-utils'; -import type { ResourceIdentifierObject } from '@ember-data/types/q/ember-data-json-api'; - -export type CacheOptions = { - key?: string; - reload?: boolean; - backgroundReload?: boolean; -}; -export type FindRecordRequestOptions = { - url: string; - method: 'GET'; - headers: Headers; - cacheOptions: CacheOptions; - op: 'findRecord'; - records: [ResourceIdentifierObject]; -}; - -export type QueryRequestOptions = { - url: string; - method: 'GET'; - headers: Headers; - cacheOptions: CacheOptions; - op: 'query'; -}; - -export type RemotelyAccessibleIdentifier = { - id: string; - type: string; - lid?: string; -}; - -export type ConstrainedRequestOptions = { - reload?: boolean; - backgroundReload?: boolean; - host?: string; - namespace?: string; - resourcePath?: string; - urlParamsSettings?: QueryParamsSerializationOptions; -}; diff --git a/packages/rest/src/-private/builders/-utils.ts b/packages/rest/src/-private/builders/-utils.ts index 4b61550b934..5acdd78588a 100644 --- a/packages/rest/src/-private/builders/-utils.ts +++ b/packages/rest/src/-private/builders/-utils.ts @@ -1,6 +1,5 @@ import { type UrlOptions } from '@ember-data/request-utils'; - -import type { CacheOptions, ConstrainedRequestOptions } from './-types'; +import type { CacheOptions, ConstrainedRequestOptions } from '@ember-data/types/request'; export function copyForwardUrlOptions(urlOptions: UrlOptions, options: ConstrainedRequestOptions): void { if ('host' in options) { diff --git a/packages/rest/src/-private/builders/find-record.ts b/packages/rest/src/-private/builders/find-record.ts index 8b1b862f10d..85ec6883120 100644 --- a/packages/rest/src/-private/builders/find-record.ts +++ b/packages/rest/src/-private/builders/find-record.ts @@ -1,16 +1,42 @@ +/** + * @module @ember-data/rest/request + */ import { camelize } from '@ember/string'; import { pluralize } from 'ember-inflector'; import { buildBaseURL, buildQueryParams, type FindRecordUrlOptions } from '@ember-data/request-utils'; +import type { + ConstrainedRequestOptions, + FindRecordRequestOptions, + RemotelyAccessibleIdentifier, +} from '@ember-data/types/request'; -import type { ConstrainedRequestOptions, FindRecordRequestOptions, RemotelyAccessibleIdentifier } from './-types'; import { copyForwardUrlOptions, extractCacheOptions } from './-utils'; type FindRecordOptions = ConstrainedRequestOptions & { include?: string | string[]; }; +/** + * Builds request options to fetch a single resource by a known id or identifier + * configured for the url and header expectations of most REST APIs. + * + * **Basic Usage** + * + * ```ts + * import { findRecord } from '@ember-data/rest/request'; + * + * const data = await store.request(findRecord({ type: 'person', id: '1' })); + * ``` + * + * @method findRecord + * @public + * @static + * @for @ember-data/rest/request + * @param identifier + * @param options + */ export function findRecord( identifier: RemotelyAccessibleIdentifier, options?: FindRecordOptions diff --git a/packages/rest/src/-private/builders/query.ts b/packages/rest/src/-private/builders/query.ts index 3a28ef2612f..dc5f9e04469 100644 --- a/packages/rest/src/-private/builders/query.ts +++ b/packages/rest/src/-private/builders/query.ts @@ -3,8 +3,8 @@ import { camelize } from '@ember/string'; import { pluralize } from 'ember-inflector'; import { buildBaseURL, buildQueryParams, QueryParamsSource, type QueryUrlOptions } from '@ember-data/request-utils'; +import type { ConstrainedRequestOptions, QueryRequestOptions } from '@ember-data/types/request'; -import type { ConstrainedRequestOptions, QueryRequestOptions } from './-types'; import { copyForwardUrlOptions, extractCacheOptions } from './-utils'; export function query( diff --git a/packages/rest/src/-private/builders/save-record.ts b/packages/rest/src/-private/builders/save-record.ts new file mode 100644 index 00000000000..94e7e645e2d --- /dev/null +++ b/packages/rest/src/-private/builders/save-record.ts @@ -0,0 +1,112 @@ +import { assert } from '@ember/debug'; +import { camelize } from '@ember/string'; + +import { pluralize } from 'ember-inflector'; + +import { + buildBaseURL, + type CreateRecordUrlOptions, + type DeleteRecordUrlOptions, + type UpdateRecordUrlOptions, +} from '@ember-data/request-utils'; +import { recordIdentifierFor } from '@ember-data/store'; +import type { StableExistingRecordIdentifier, StableRecordIdentifier } from '@ember-data/types/q/identifier'; +import { + ConstrainedRequestOptions, + CreateRequestOptions, + DeleteRequestOptions, + UpdateRequestOptions, +} from '@ember-data/types/request'; + +import { copyForwardUrlOptions } from './-utils'; + +function isExisting(identifier: StableRecordIdentifier): identifier is StableExistingRecordIdentifier { + return 'id' in identifier && identifier.id !== null && 'type' in identifier && identifier.type !== null; +} + +export function deleteRecord(record: unknown, options: ConstrainedRequestOptions = {}): DeleteRequestOptions { + const identifier = recordIdentifierFor(record); + assert(`Expected to be given a record instance`, identifier); + assert(`Cannot delete a record that does not have an associated type and id.`, isExisting(identifier)); + + const urlOptions: DeleteRecordUrlOptions = { + identifier: identifier, + op: 'deleteRecord', + resourcePath: pluralize(camelize(identifier.type)), + }; + + copyForwardUrlOptions(urlOptions, options); + + const url = buildBaseURL(urlOptions); + const headers = new Headers(); + headers.append('Content-Type', 'application/json; charset=utf-8'); + + return { + url, + method: 'DELETE', + headers, + op: 'deleteRecord', + data: { + record: identifier, + }, + }; +} + +export function createRecord(record: unknown, options: ConstrainedRequestOptions = {}): CreateRequestOptions { + const identifier = recordIdentifierFor(record); + assert(`Expected to be given a record instance`, identifier); + assert(`Cannot delete a record that does not have an associated type and id.`, isExisting(identifier)); + + const urlOptions: CreateRecordUrlOptions = { + identifier: identifier, + op: 'createRecord', + resourcePath: pluralize(camelize(identifier.type)), + }; + + copyForwardUrlOptions(urlOptions, options); + + const url = buildBaseURL(urlOptions); + const headers = new Headers(); + headers.append('Content-Type', 'application/json; charset=utf-8'); + + return { + url, + method: 'POST', + headers, + op: 'createRecord', + data: { + record: identifier, + }, + }; +} + +export function updateRecord( + record: unknown, + options: ConstrainedRequestOptions & { patch?: boolean } = {} +): UpdateRequestOptions { + const identifier = recordIdentifierFor(record); + assert(`Expected to be given a record instance`, identifier); + assert(`Cannot delete a record that does not have an associated type and id.`, isExisting(identifier)); + + const urlOptions: UpdateRecordUrlOptions = { + identifier: identifier, + op: 'updateRecord', + resourcePath: pluralize(camelize(identifier.type)), + }; + + copyForwardUrlOptions(urlOptions, options); + + const url = buildBaseURL(urlOptions); + const headers = new Headers(); + headers.append('Content-Type', 'application/json; charset=utf-8'); + + return { + url, + method: options.patch ? 'PATCH' : 'PUT', + headers, + op: 'updateRecord', + data: { + record: identifier, + }, + }; +} diff --git a/packages/rest/src/request.ts b/packages/rest/src/request.ts index 17b1cc2771b..221155e276b 100644 --- a/packages/rest/src/request.ts +++ b/packages/rest/src/request.ts @@ -1,2 +1,13 @@ +/** + * Request Builders ready to go for use with `store.request()` + * and most conventional REST APIs. + * + * Resource types are pluralized and camelized for the url. + * + * @module @ember-data/rest/request + * @main @ember-data/rest/request + * @public + */ export { findRecord } from './-private/builders/find-record'; export { query } from './-private/builders/query'; +export { deleteRecord, createRecord, updateRecord } from './-private/builders/save-record'; diff --git a/packages/store/src/-private/store-service.ts b/packages/store/src/-private/store-service.ts index 95eceaedbce..cbe0c0cfb7a 100644 --- a/packages/store/src/-private/store-service.ts +++ b/packages/store/src/-private/store-service.ts @@ -1,9 +1,10 @@ /** @module @ember-data/store */ +// this import location is deprecated but breaks in 4.8 and older +import { getOwner } from '@ember/application'; import { assert } from '@ember/debug'; import EmberObject from '@ember/object'; -import { getOwner } from '@ember/owner'; import { _backburner as emberBackburner } from '@ember/runloop'; import type { Object as JSONObject } from 'json-typescript'; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a2c6b558c18..146a42ce731 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1540,7 +1540,7 @@ importers: version: 7.22.6 '@ember-data/active-record': specifier: workspace:5.3.0-alpha.9 - version: file:packages/active-record(ember-inflector@4.0.2) + version: file:packages/active-record(@ember-data/store@packages+store)(ember-inflector@4.0.2) '@ember-data/graph': specifier: workspace:5.3.0-alpha.9 version: link:../../packages/graph @@ -1555,10 +1555,13 @@ importers: version: file:packages/request-utils '@ember-data/rest': specifier: workspace:5.3.0-alpha.9 - version: file:packages/rest(ember-inflector@4.0.2) + version: file:packages/rest(@ember-data/store@packages+store)(ember-inflector@4.0.2) '@ember-data/store': specifier: workspace:5.3.0-alpha.9 version: link:../../packages/store + '@ember-data/tracking': + specifier: workspace:5.3.0-alpha.9 + version: link:../../packages/tracking '@ember-data/unpublished-test-infra': specifier: workspace:5.3.0-alpha.9 version: file:packages/unpublished-test-infra @@ -6473,6 +6476,7 @@ packages: /asn1@0.1.11: resolution: {integrity: sha512-Fh9zh3G2mZ8qM/kwsiKwL2U2FmXxVsboP4x1mXjnhKHv3SmzaBZoYvxEQJz/YS2gnCgd8xlAVWcZnQyC9qZBsA==} engines: {node: '>=0.4.9'} + requiresBuild: true dev: true optional: true @@ -6482,6 +6486,7 @@ packages: /assert-plus@0.1.5: resolution: {integrity: sha512-brU24g7ryhRwGCI2y+1dGQmQXiZF7TtIj583S96y0jjdajIe6wn8BuXyELYhvD22dtIxDQVFk04YTJwwdwOYJw==} engines: {node: '>=0.8'} + requiresBuild: true dev: true optional: true @@ -6536,6 +6541,7 @@ packages: /async@0.9.2: resolution: {integrity: sha512-l6ToIJIotphWahxxHyzK9bnLR6kM4jJIIgLShZeqLY7iboHoGkdgFl7W2/Ivi4SkMJYGKqW8vSuk0uKUj6qsSw==} + requiresBuild: true dev: true optional: true @@ -7300,6 +7306,7 @@ packages: resolution: {integrity: sha512-OvfN8y1oAxxphzkl2SnCS+ztV/uVKTATtgLjWYg/7KwcNyf3rzpHxNQJZCKtsZd4+MteKczhWbSjtEX4bGgU9g==} engines: {node: '>=0.8.0'} deprecated: This version has been deprecated in accordance with the hapi support policy (hapi.im/support). Please upgrade to the latest version to get the best features, bug fixes, and security patches. If you are unable to upgrade at this time, paid support is available for older versions (hapi.im/commercial). + requiresBuild: true dependencies: hoek: 0.9.1 dev: true @@ -8317,6 +8324,7 @@ packages: /combined-stream@0.0.7: resolution: {integrity: sha512-qfexlmLp9MyrkajQVyjEDb0Vj+KhRgR/rxLiVhaihlT+ZkX0lReqtH6Ack40CvMDERR4b5eFp3CreskpBs1Pig==} engines: {node: '>= 0.8'} + requiresBuild: true dependencies: delayed-stream: 0.0.5 dev: true @@ -8685,6 +8693,7 @@ packages: resolution: {integrity: sha512-gvWSbgqP+569DdslUiCelxIv3IYK5Lgmq1UrRnk+s1WxQOQ16j3GPDcjdtgL5Au65DU/xQi6q3xPtf5Kta+3IQ==} engines: {node: '>=0.8.0'} deprecated: This version has been deprecated in accordance with the hapi support policy (hapi.im/support). Please upgrade to the latest version to get the best features, bug fixes, and security patches. If you are unable to upgrade at this time, paid support is available for older versions (hapi.im/commercial). + requiresBuild: true dependencies: boom: 0.4.2 dev: true @@ -8786,6 +8795,7 @@ packages: /ctype@0.5.3: resolution: {integrity: sha512-T6CEkoSV4q50zW3TlTHMbzy1E5+zlnNcY+yb7tWVYlTwPhx9LpnfAkd4wecpWknDyptp4k97LUZeInlf6jdzBg==} engines: {node: '>= 0.4'} + requiresBuild: true dev: true optional: true @@ -8986,6 +8996,7 @@ packages: /delayed-stream@0.0.5: resolution: {integrity: sha512-v+7uBd1pqe5YtgPacIIbZ8HuHeLFVNe4mUEyFDXL6KiqzEykjbw+5mXZXpGFgNVasdL4jWKgaKIXrEHiynN1LA==} engines: {node: '>=0.4.0'} + requiresBuild: true dev: true optional: true @@ -11906,6 +11917,7 @@ packages: resolution: {integrity: sha512-ZZ6eGyzGjyMTmpSPYVECXy9uNfqBR7x5CavhUaLOeD6W0vWK1mp/b7O3f86XE0Mtfo9rZ6Bh3fnuw9Xr8MF9zA==} engines: {node: '>=0.8.0'} deprecated: This version has been deprecated in accordance with the hapi support policy (hapi.im/support). Please upgrade to the latest version to get the best features, bug fixes, and security patches. If you are unable to upgrade at this time, paid support is available for older versions (hapi.im/commercial). + requiresBuild: true dev: true optional: true @@ -13373,6 +13385,7 @@ packages: /mime@1.2.11: resolution: {integrity: sha512-Ysa2F/nqTNGHhhm9MV8ure4+Hc+Y8AWiqUdHxsO7xu8zc92ND9f3kpALHjaP026Ft17UfxrMt95c50PLUeynBw==} + requiresBuild: true dev: true optional: true @@ -15246,6 +15259,7 @@ packages: resolution: {integrity: sha512-bDLrKa/ywz65gCl+LmOiIhteP1bhEsAAzhfMedPoiHP3dyYnAevlaJshdqb9Yu0sRifyP/fRqSt8t+5qGIWlGQ==} engines: {node: '>=0.8.0'} deprecated: This module moved to @hapi/sntp. Please make sure to switch over as this distribution is no longer supported and may contain bugs and critical security issues. + requiresBuild: true dependencies: hoek: 0.9.1 dev: true @@ -16941,14 +16955,16 @@ packages: - uglify-js - webpack-cli - file:packages/active-record(ember-inflector@4.0.2): + file:packages/active-record(@ember-data/store@packages+store)(ember-inflector@4.0.2): resolution: {directory: packages/active-record, type: directory} id: file:packages/active-record name: '@ember-data/active-record' engines: {node: 16.* || >= 18} peerDependencies: + '@ember-data/store': ^4.12.0 || ^5.0.0 ember-inflector: ^4.0.2 dependencies: + '@ember-data/store': link:packages/store ember-cli-babel: 7.26.11 ember-inflector: 4.0.2 transitivePeerDependencies: @@ -17399,14 +17415,16 @@ packages: - supports-color dev: true - file:packages/rest(ember-inflector@4.0.2): + file:packages/rest(@ember-data/store@packages+store)(ember-inflector@4.0.2): resolution: {directory: packages/rest, type: directory} id: file:packages/rest name: '@ember-data/rest' engines: {node: 16.* || >= 18} peerDependencies: + '@ember-data/store': ^4.12.0 || ^5.0.0 ember-inflector: ^4.0.2 dependencies: + '@ember-data/store': link:packages/store ember-cli-babel: 7.26.11 ember-inflector: 4.0.2 transitivePeerDependencies: diff --git a/tests/builders/package.json b/tests/builders/package.json index 4e18f0c30d0..2d054839955 100644 --- a/tests/builders/package.json +++ b/tests/builders/package.json @@ -45,6 +45,7 @@ "@ember-data/json-api": "workspace:5.3.0-alpha.9", "@ember-data/graph": "workspace:5.3.0-alpha.9", "@ember-data/store": "workspace:5.3.0-alpha.9", + "@ember-data/tracking": "workspace:5.3.0-alpha.9", "@ember-data/private-build-infra": "workspace:5.3.0-alpha.9", "@ember-data/request-utils": "workspace:5.3.0-alpha.9", "@ember-data/rest": "workspace:5.3.0-alpha.9", diff --git a/tests/builders/tests/test-helper.js b/tests/builders/tests/test-helper.js index 53484230469..fee5be27fc0 100644 --- a/tests/builders/tests/test-helper.js +++ b/tests/builders/tests/test-helper.js @@ -21,4 +21,10 @@ if (window.Testem) { QUnit.config.testTimeout = 2000; -start({ setupTestIsolationValidation: true }); +start({ + setupTestIsolationValidation: true, + setupTestContainer: false, + setupTestAdapter: false, + setupEmberTesting: false, + setupEmberOnerrorValidation: false, +}); diff --git a/tests/builders/tests/unit/build-base-url-test.ts b/tests/builders/tests/unit/build-base-url-test.ts index 32157f62e5e..b21d794145d 100644 --- a/tests/builders/tests/unit/build-base-url-test.ts +++ b/tests/builders/tests/unit/build-base-url-test.ts @@ -37,12 +37,12 @@ module('buildBaseURL', function (hooks) { assert.strictEqual( buildBaseURL({ - op: 'findRelatedResource', + op: 'findRelatedRecord', identifier: { type: 'user', id: '1' }, fieldPath: 'bestFriend', }), '/user/1/bestFriend', - `buildBaseURL works for findRelatedResource` + `buildBaseURL works for findRelatedRecord` ); assert.strictEqual( @@ -109,13 +109,13 @@ module('buildBaseURL', function (hooks) { assert.strictEqual( buildBaseURL({ - op: 'findRelatedResource', + op: 'findRelatedRecord', identifier: { type: 'user', id: '1' }, resourcePath: 'people', fieldPath: 'bestFriend', }), '/people/1/bestFriend', - `buildBaseURL works for findRelatedResource` + `buildBaseURL works for findRelatedRecord` ); assert.strictEqual( @@ -156,7 +156,7 @@ module('buildBaseURL', function (hooks) { test('namespace uses local when present (no global config)', function (assert) { assert.strictEqual( buildBaseURL({ - op: 'findRelatedResource', + op: 'findRelatedRecord', identifier: { type: 'user', id: '1' }, resourcePath: 'people', fieldPath: 'bestFriend', @@ -171,7 +171,7 @@ module('buildBaseURL', function (hooks) { setBuildURLConfig({ namespace: 'api/v2', host: '' }); assert.strictEqual( buildBaseURL({ - op: 'findRelatedResource', + op: 'findRelatedRecord', identifier: { type: 'user', id: '1' }, resourcePath: 'people', fieldPath: 'bestFriend', @@ -185,7 +185,7 @@ module('buildBaseURL', function (hooks) { setBuildURLConfig({ namespace: 'api/v2', host: '' }); assert.strictEqual( buildBaseURL({ - op: 'findRelatedResource', + op: 'findRelatedRecord', identifier: { type: 'user', id: '1' }, resourcePath: 'people', fieldPath: 'bestFriend', @@ -199,7 +199,7 @@ module('buildBaseURL', function (hooks) { test('host uses local when present (no global config)', function (assert) { assert.strictEqual( buildBaseURL({ - op: 'findRelatedResource', + op: 'findRelatedRecord', identifier: { type: 'user', id: '1' }, resourcePath: 'people', fieldPath: 'bestFriend', @@ -214,7 +214,7 @@ module('buildBaseURL', function (hooks) { setBuildURLConfig({ namespace: '', host: 'https://api2.example.com' }); assert.strictEqual( buildBaseURL({ - op: 'findRelatedResource', + op: 'findRelatedRecord', identifier: { type: 'user', id: '1' }, resourcePath: 'people', fieldPath: 'bestFriend', @@ -228,7 +228,7 @@ module('buildBaseURL', function (hooks) { setBuildURLConfig({ namespace: '', host: 'https://api2.example.com' }); assert.strictEqual( buildBaseURL({ - op: 'findRelatedResource', + op: 'findRelatedRecord', identifier: { type: 'user', id: '1' }, resourcePath: 'people', fieldPath: 'bestFriend', @@ -242,7 +242,7 @@ module('buildBaseURL', function (hooks) { test('host may start with a /', function (assert) { assert.strictEqual( buildBaseURL({ - op: 'findRelatedResource', + op: 'findRelatedRecord', identifier: { type: 'user', id: '1' }, resourcePath: 'people', host: '/api', diff --git a/tests/docs/fixtures/expected.js b/tests/docs/fixtures/expected.js index ed27e9440e5..9a9aa9ea5fe 100644 --- a/tests/docs/fixtures/expected.js +++ b/tests/docs/fixtures/expected.js @@ -13,7 +13,9 @@ module.exports = { '@ember-data/legacy-compat', '@ember-data/model', '@ember-data/request', + '@ember-data/request-utils', '@ember-data/request/fetch', + '@ember-data/rest/request', '@ember-data/serializer', '@ember-data/serializer/json', '@ember-data/serializer/json-api', @@ -328,6 +330,8 @@ module.exports = { '(public) @ember-data/request Future#abort', '(public) @ember-data/request Future#getStream', '(public) @ember-data/request Future#onFinalize', + '(public) @ember-data/request-utils @ember-data/request-utils#buildBaseURL', + '(public) @ember-data/rest/request @ember-data/rest/request#findRecord', '(public) @ember-data/serializer Serializer#normalize', '(public) @ember-data/serializer Serializer#normalizeResponse', '(public) @ember-data/serializer Serializer#serialize',