diff --git a/package.json b/package.json index bbe30abc9b3..59afcbc2640 100644 --- a/package.json +++ b/package.json @@ -19,7 +19,7 @@ "test": "pnpm turbo test --concurrency=1", "test:production": "pnpm turbo test:production --concurrency=1", "test:try-one": "pnpm --filter main-test-app run test:try-one", - "test:docs": "pnpm build:docs && pnpm run -r --workspace-concurrency=-1 --if-present test:blueprints", + "test:docs": "pnpm build:docs && pnpm run -r --workspace-concurrency=-1 --if-present test:docs", "test:blueprints": "pnpm run -r --workspace-concurrency=-1 --if-present test:blueprints", "test:fastboot": "pnpm run -r --workspace-concurrency=-1 --if-present test:fastboot", "test:embroider": "pnpm run -r ---workspace-concurrency=-1 --if-present test:embroider", diff --git a/packages/-ember-data/addon/store.ts b/packages/-ember-data/addon/store.ts index 4236139c53a..320780c8a6c 100644 --- a/packages/-ember-data/addon/store.ts +++ b/packages/-ember-data/addon/store.ts @@ -21,13 +21,20 @@ import type { Cache } from '@warp-drive/core-types/cache'; import type { CacheCapabilitiesManager } from '@ember-data/store/-types/q/cache-store-wrapper'; import type { ModelSchema } from '@ember-data/store/-types/q/ds-model'; +function hasRequestManager(store: BaseStore): boolean { + return 'requestManager' in store; +} + export default class Store extends BaseStore { declare _fetchManager: FetchManager; constructor(args?: Record) { super(args); - this.requestManager = new RequestManager(); - this.requestManager.use([LegacyNetworkHandler, Fetch]); + + if (!hasRequestManager(this)) { + this.requestManager = new RequestManager(); + this.requestManager.use([LegacyNetworkHandler, Fetch]); + } this.requestManager.useCache(CacheHandler); this.registerSchema(buildSchema(this)); } diff --git a/packages/json-api/src/-private/builders/-utils.ts b/packages/json-api/src/-private/builders/-utils.ts index 9cae4d3439b..7e1488bbd99 100644 --- a/packages/json-api/src/-private/builders/-utils.ts +++ b/packages/json-api/src/-private/builders/-utils.ts @@ -1,6 +1,90 @@ +/** + * @module @ember-data/json-api/request + */ +import { BuildURLConfig, setBuildURLConfig as setConfig } from '@ember-data/request-utils'; import { type UrlOptions } from '@ember-data/request-utils'; import type { CacheOptions, ConstrainedRequestOptions } from '@warp-drive/core-types/request'; +export interface JSONAPIConfig extends BuildURLConfig { + profiles?: { + pagination?: string; + [key: string]: string | undefined; + }; + extensions?: { + atomic?: string; + [key: string]: string | undefined; + }; +} + +const JsonApiAccept = 'application/vnd.api+json'; +const DEFAULT_CONFIG: JSONAPIConfig = { host: '', namespace: '' }; +export let CONFIG: JSONAPIConfig = DEFAULT_CONFIG; +export 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 + * @returns void + */ +export function setBuildURLConfig(config: JSONAPIConfig): void { + 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; + } + + setConfig(config); +} + export function copyForwardUrlOptions(urlOptions: UrlOptions, options: ConstrainedRequestOptions): void { if ('host' in options) { urlOptions.host = options.host; diff --git a/packages/json-api/src/-private/builders/find-record.ts b/packages/json-api/src/-private/builders/find-record.ts index 02b87a878db..6f8c075c242 100644 --- a/packages/json-api/src/-private/builders/find-record.ts +++ b/packages/json-api/src/-private/builders/find-record.ts @@ -10,7 +10,7 @@ import type { RemotelyAccessibleIdentifier, } from '@warp-drive/core-types/request'; -import { copyForwardUrlOptions, extractCacheOptions } from './-utils'; +import { ACCEPT_HEADER_VALUE, copyForwardUrlOptions, extractCacheOptions } from './-utils'; /** * Builds request options to fetch a single resource by a known id or identifier @@ -93,7 +93,7 @@ export function findRecord( const url = buildBaseURL(urlOptions); const headers = new Headers(); - headers.append('Accept', 'application/vnd.api+json'); + headers.append('Accept', ACCEPT_HEADER_VALUE); return { url: options.include?.length diff --git a/packages/json-api/src/-private/builders/query.ts b/packages/json-api/src/-private/builders/query.ts index 234fe64cdbe..04221d2eccf 100644 --- a/packages/json-api/src/-private/builders/query.ts +++ b/packages/json-api/src/-private/builders/query.ts @@ -12,7 +12,7 @@ import type { QueryRequestOptions, } from '@warp-drive/core-types/request'; -import { copyForwardUrlOptions, extractCacheOptions } from './-utils'; +import { ACCEPT_HEADER_VALUE, copyForwardUrlOptions, extractCacheOptions } from './-utils'; /** * Builds request options to query for resources, usually by a primary @@ -85,7 +85,7 @@ export function query( const url = buildBaseURL(urlOptions); const headers = new Headers(); - headers.append('Accept', 'application/vnd.api+json'); + headers.append('Accept', ACCEPT_HEADER_VALUE); return { url: `${url}?${buildQueryParams(query, options.urlParamsSettings)}`, @@ -160,7 +160,7 @@ export function postQuery( const url = buildBaseURL(urlOptions); const headers = new Headers(); - headers.append('Accept', 'application/vnd.api+json'); + headers.append('Accept', ACCEPT_HEADER_VALUE); const queryData = structuredClone(query); cacheOptions.key = cacheOptions.key ?? `${url}?${buildQueryParams(queryData, options.urlParamsSettings)}`; diff --git a/packages/json-api/src/-private/builders/save-record.ts b/packages/json-api/src/-private/builders/save-record.ts index 1f91b816f7e..5b4073e9be7 100644 --- a/packages/json-api/src/-private/builders/save-record.ts +++ b/packages/json-api/src/-private/builders/save-record.ts @@ -17,7 +17,7 @@ import { UpdateRequestOptions, } from '@warp-drive/core-types/request'; -import { copyForwardUrlOptions } from './-utils'; +import { ACCEPT_HEADER_VALUE, copyForwardUrlOptions } from './-utils'; function isExisting(identifier: StableRecordIdentifier): identifier is StableExistingRecordIdentifier { return 'id' in identifier && identifier.id !== null && 'type' in identifier && identifier.type !== null; @@ -90,7 +90,7 @@ export function deleteRecord(record: unknown, options: ConstrainedRequestOptions const url = buildBaseURL(urlOptions); const headers = new Headers(); - headers.append('Accept', 'application/vnd.api+json'); + headers.append('Accept', ACCEPT_HEADER_VALUE); return { url, @@ -159,7 +159,7 @@ export function createRecord(record: unknown, options: ConstrainedRequestOptions const url = buildBaseURL(urlOptions); const headers = new Headers(); - headers.append('Accept', 'application/vnd.api+json'); + headers.append('Accept', ACCEPT_HEADER_VALUE); return { url, @@ -235,7 +235,7 @@ export function updateRecord( const url = buildBaseURL(urlOptions); const headers = new Headers(); - headers.append('Accept', 'application/vnd.api+json'); + headers.append('Accept', ACCEPT_HEADER_VALUE); return { url, diff --git a/packages/json-api/src/request.ts b/packages/json-api/src/request.ts index b664706957e..29bc57b79a8 100644 --- a/packages/json-api/src/request.ts +++ b/packages/json-api/src/request.ts @@ -67,3 +67,4 @@ export { findRecord } from './-private/builders/find-record'; export { query, postQuery } from './-private/builders/query'; export { deleteRecord, createRecord, updateRecord } from './-private/builders/save-record'; export { serializeResources, serializePatch } from './-private/serialize'; +export { setBuildURLConfig } from './-private/builders/-utils'; diff --git a/packages/request-utils/src/index.ts b/packages/request-utils/src/index.ts index 0a18cb127f8..609db561e51 100644 --- a/packages/request-utils/src/index.ts +++ b/packages/request-utils/src/index.ts @@ -52,7 +52,7 @@ type Store = { // host and namespace which are provided by the final consuming // class to the prototype which can result in overwrite errors -interface BuildURLConfig { +export interface BuildURLConfig { host: string | null; namespace: string | null; } diff --git a/tests/docs/fixtures/expected.js b/tests/docs/fixtures/expected.js index 86dd5dd55a7..29b28cf9f60 100644 --- a/tests/docs/fixtures/expected.js +++ b/tests/docs/fixtures/expected.js @@ -260,6 +260,7 @@ module.exports = { '(public) @ember-data/json-api/request @ember-data/json-api/request#updateRecord', '(public) @ember-data/json-api/request @ember-data/json-api/request#serializePatch', '(public) @ember-data/json-api/request @ember-data/json-api/request#serializeResources', + '(public) @ember-data/json-api/request @ember-data/json-api/request#setBuildURLConfig', '(public) @ember-data/legacy-compat SnapshotRecordArray#adapterOptions', '(public) @ember-data/legacy-compat SnapshotRecordArray#include', '(public) @ember-data/legacy-compat SnapshotRecordArray#length', diff --git a/tests/main/tests/integration/store-extension-test.ts b/tests/main/tests/integration/store-extension-test.ts new file mode 100644 index 00000000000..75610eb0735 --- /dev/null +++ b/tests/main/tests/integration/store-extension-test.ts @@ -0,0 +1,48 @@ +import { module, test } from 'qunit'; +import { setupTest } from 'ember-qunit'; +import Store from 'ember-data/store'; +import RequestManager from '@ember-data/request'; +import { inject as service } from '@ember/service'; + +module('Integration | Store Extension', function (hooks) { + setupTest(hooks); + + test('We can create a store ', function (assert) { + const { owner } = this; + class CustomStore extends Store {} + owner.register('service:store', CustomStore); + const store = owner.lookup('service:store'); + + assert.true( + store.requestManager instanceof RequestManager, + 'We create a request manager for the store automatically' + ); + }); + + test('We can create a store with a custom request manager injected as a service', function (assert) { + const { owner } = this; + class CustomStore extends Store { + @service requestManager!: RequestManager; + } + + owner.register('service:store', CustomStore); + owner.register('service:request-manager', RequestManager); + const requestManager = owner.lookup('service:request-manager'); + const store = owner.lookup('service:store'); + + assert.true(store.requestManager === requestManager, 'We can inject a custom request manager into the store'); + }); + + test('We can create a store with a custom request manager initialized as a field', function (assert) { + const { owner } = this; + const requestManager = new RequestManager(); + class CustomStore extends Store { + requestManager = requestManager; + } + + owner.register('service:store', CustomStore); + const store = owner.lookup('service:store'); + + assert.true(store.requestManager === requestManager, 'We can inject a custom request manager into the store'); + }); +});