From 3b4ebdb9e782af274ad134cbf8b1f34d550fe1c4 Mon Sep 17 00:00:00 2001 From: Chris Thoburn Date: Sun, 29 Oct 2023 17:03:20 -0700 Subject: [PATCH] feat: Improve extensibility (#9069) * feat: improve configurability of json-api builder and request-manager * fix types * add tests * fix prettier * fix forgottend debugger * fixup lint * fix lint --- package.json | 10 +-- packages/-ember-data/addon/store.ts | 11 ++- .../json-api/src/-private/builders/-utils.ts | 84 +++++++++++++++++++ .../src/-private/builders/find-record.ts | 4 +- .../json-api/src/-private/builders/query.ts | 6 +- .../src/-private/builders/save-record.ts | 8 +- packages/json-api/src/request.ts | 1 + packages/request-utils/src/index.ts | 2 +- tests/docs/fixtures/expected.js | 1 + .../tests/integration/store-extension-test.ts | 48 +++++++++++ 10 files changed, 158 insertions(+), 17 deletions(-) create mode 100644 tests/main/tests/integration/store-extension-test.ts diff --git a/package.json b/package.json index 73ee2b6f296..78dff717819 100644 --- a/package.json +++ b/package.json @@ -14,11 +14,11 @@ "test": "pnpm --filter main-test-app --filter graph-test-app --filter json-api-test-app --filter request-test-app --filter builders-test-app run test", "test:production": "pnpm --filter main-test-app --filter graph-test-app --filter json-api-test-app run test -e production", "test:try-one": "pnpm --filter main-test-app run test:try-one", - "test:docs": "pnpm build:docs && pnpm --filter docs-tests test", - "test:encapsulation": "pnpm --filter '*-encapsulation-test-app' run test", - "test:fastboot": "pnpm --filter fastboot-test-app test", - "test:embroider": "pnpm --filter embroider-basic-compat test", - "test:infra": "pnpm --filter @ember-data/unpublished-test-infra test", + "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", + "test:infra": "pnpm run -r --workspace-concurrency=-1 --if-present test:infra", "test-external:ember-m3": "node ./scripts/test-external-partner-project.js ember-m3 https://github.com/hjdivad/ember-m3.git", "test-external:ember-data-change-tracker": "node ./scripts/test-external-partner-project.js ember-data-change-tracker https://github.com/danielspaniel/ember-data-change-tracker.git", "test-external:model-fragments": "node ./scripts/test-external-partner-project.js model-fragments https://github.com/lytics/ember-data-model-fragments.git", diff --git a/packages/-ember-data/addon/store.ts b/packages/-ember-data/addon/store.ts index f2b6e28ee45..94154fc5f72 100644 --- a/packages/-ember-data/addon/store.ts +++ b/packages/-ember-data/addon/store.ts @@ -12,11 +12,18 @@ import type { CacheCapabilitiesManager } from '@ember-data/types/q/cache-store-w import type { ModelSchema } from '@ember-data/types/q/ds-model'; import type { StableRecordIdentifier } from '@ember-data/types/q/identifier'; +function hasRequestManager(store: BaseStore): boolean { + return 'requestManager' in store; +} + export default class Store extends BaseStore { 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 5acdd78588a..a8da0b9604f 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 '@ember-data/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 48935ba4a73..f3a43882999 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 '@ember-data/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 420cf88407a..26404b0a5ed 100644 --- a/packages/json-api/src/-private/builders/query.ts +++ b/packages/json-api/src/-private/builders/query.ts @@ -11,7 +11,7 @@ import type { QueryRequestOptions, } from '@ember-data/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 @@ -80,7 +80,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)}`, @@ -148,7 +148,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 17c10a3f0c8..0123f08d21c 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 '@ember-data/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 7243565325b..6ebffb379c7 100644 --- a/packages/request-utils/src/index.ts +++ b/packages/request-utils/src/index.ts @@ -47,7 +47,7 @@ import { StableDocumentIdentifier } from '@ember-data/types/cache/identifier'; // 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 496908f7aa2..4a167536b67 100644 --- a/tests/docs/fixtures/expected.js +++ b/tests/docs/fixtures/expected.js @@ -261,6 +261,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'); + }); +});