From e10c8cf848f28bf6ce3ff9a654138b04e4b4a2d2 Mon Sep 17 00:00:00 2001 From: Chris Thoburn Date: Tue, 24 Sep 2019 19:18:02 -0700 Subject: [PATCH] [DOC serializer] implements MinimumSerializerInterface (#6451) (#6500) --- packages/adapter/addon/-private/index.js | 1 + .../-private/utils/serialize-into-hash.js | 11 + packages/adapter/addon/json-api.js | 7 +- packages/adapter/addon/rest.js | 14 +- .../addon/-private/system/identity-map.ts | 4 +- .../-private/system/internal-model-map.ts | 8 +- .../-private/system/model/internal-model.ts | 10 +- packages/store/addon/-private/system/store.ts | 9 +- .../minimum-serializer-interface.ts | 319 ++++++++++++++++++ packages/store/addon/-private/types.ts | 3 +- 10 files changed, 359 insertions(+), 27 deletions(-) create mode 100644 packages/adapter/addon/-private/utils/serialize-into-hash.js create mode 100644 packages/store/addon/-private/ts-interfaces/minimum-serializer-interface.ts diff --git a/packages/adapter/addon/-private/index.js b/packages/adapter/addon/-private/index.js index e244a107c58..4d1d18fb8df 100644 --- a/packages/adapter/addon/-private/index.js +++ b/packages/adapter/addon/-private/index.js @@ -7,3 +7,4 @@ export { determineBodyPromise } from './utils/determine-body-promise'; export { serializeQueryParams } from './utils/serialize-query-params'; export { default as fetch } from './utils/fetch'; export { default as BuildURLMixin } from './build-url-mixin'; +export { default as serializeIntoHash } from './utils/serialize-into-hash'; diff --git a/packages/adapter/addon/-private/utils/serialize-into-hash.js b/packages/adapter/addon/-private/utils/serialize-into-hash.js new file mode 100644 index 00000000000..daa20881f70 --- /dev/null +++ b/packages/adapter/addon/-private/utils/serialize-into-hash.js @@ -0,0 +1,11 @@ +export default function serializeIntoHash(store, modelClass, snapshot, options = { includeId: true }) { + const serializer = store.serializerFor(modelClass.modelName); + + if (typeof serializer.serializeIntoHash === 'function') { + const data = {}; + serializer.serializeIntoHash(data, modelClass, snapshot, options); + return data; + } + + return serializer.serialize(snapshot, options); +} diff --git a/packages/adapter/addon/json-api.js b/packages/adapter/addon/json-api.js index e473453a413..dc3b2bfa820 100644 --- a/packages/adapter/addon/json-api.js +++ b/packages/adapter/addon/json-api.js @@ -4,6 +4,7 @@ import { dasherize } from '@ember/string'; import RESTAdapter from './rest'; import { pluralize } from 'ember-inflector'; +import { serializeIntoHash } from './-private'; /** The `JSONAPIAdapter` is the default adapter used by Ember Data. It @@ -227,12 +228,8 @@ const JSONAPIAdapter = RESTAdapter.extend({ return pluralize(dasherized); }, - // TODO: Remove this once we have a better way to override HTTP verbs. updateRecord(store, type, snapshot) { - let data = {}; - let serializer = store.serializerFor(type.modelName); - - serializer.serializeIntoHash(data, type, snapshot, { includeId: true }); + const data = serializeIntoHash(store, type, snapshot); let url = this.buildURL(type.modelName, snapshot.id, snapshot, 'updateRecord'); diff --git a/packages/adapter/addon/rest.js b/packages/adapter/addon/rest.js index 763f56a8234..f0f9c86f671 100644 --- a/packages/adapter/addon/rest.js +++ b/packages/adapter/addon/rest.js @@ -23,6 +23,7 @@ import AdapterError, { } from '@ember-data/adapter/error'; import { warn } from '@ember/debug'; import { DEBUG } from '@glimmer/env'; +import { serializeIntoHash } from './-private'; const Promise = EmberPromise; const hasJQuery = typeof jQuery !== 'undefined'; @@ -723,13 +724,11 @@ const RESTAdapter = Adapter.extend(BuildURLMixin, { @return {Promise} promise */ createRecord(store, type, snapshot) { - let data = {}; - let serializer = store.serializerFor(type.modelName); let url = this.buildURL(type.modelName, null, snapshot, 'createRecord'); - serializer.serializeIntoHash(data, type, snapshot, { includeId: true }); + const data = serializeIntoHash(store, type, snapshot); - return this.ajax(url, 'POST', { data: data }); + return this.ajax(url, 'POST', { data }); }, /** @@ -749,15 +748,12 @@ const RESTAdapter = Adapter.extend(BuildURLMixin, { @return {Promise} promise */ updateRecord(store, type, snapshot) { - let data = {}; - let serializer = store.serializerFor(type.modelName); - - serializer.serializeIntoHash(data, type, snapshot); + const data = serializeIntoHash(store, type, snapshot, {}); let id = snapshot.id; let url = this.buildURL(type.modelName, id, snapshot, 'updateRecord'); - return this.ajax(url, 'PUT', { data: data }); + return this.ajax(url, 'PUT', { data }); }, /** diff --git a/packages/store/addon/-private/system/identity-map.ts b/packages/store/addon/-private/system/identity-map.ts index a0a85a94244..01063192552 100644 --- a/packages/store/addon/-private/system/identity-map.ts +++ b/packages/store/addon/-private/system/identity-map.ts @@ -1,5 +1,5 @@ import InternalModelMap from './internal-model-map'; -import { Dict } from '../types'; +import { ConfidentDict } from '../ts-interfaces/utils'; /** @module @ember-data/store @@ -13,7 +13,7 @@ import { Dict } from '../types'; @private */ export default class IdentityMap { - private _map: Dict = Object.create(null); + private _map: ConfidentDict = Object.create(null); /** Retrieves the `InternalModelMap` for a given modelName, diff --git a/packages/store/addon/-private/system/internal-model-map.ts b/packages/store/addon/-private/system/internal-model-map.ts index edd34f5f430..3d0c48dfcf6 100644 --- a/packages/store/addon/-private/system/internal-model-map.ts +++ b/packages/store/addon/-private/system/internal-model-map.ts @@ -1,6 +1,6 @@ import { assert } from '@ember/debug'; import InternalModel from './model/internal-model'; -import { Dict } from '../types'; +import { ConfidentDict } from '../ts-interfaces/utils'; /** @module @ember-data/store @@ -17,9 +17,9 @@ import { Dict } from '../types'; @private */ export default class InternalModelMap { - private _idToModel: Dict = Object.create(null); + private _idToModel: ConfidentDict = Object.create(null); private _models: InternalModel[] = []; - private _metadata: Dict | null = null; + private _metadata: ConfidentDict | null = null; constructor(public modelName: string) {} @@ -104,7 +104,7 @@ export default class InternalModelMap { * @property metadata * @type Object */ - get metadata(): Dict { + get metadata(): ConfidentDict { return this._metadata || (this._metadata = Object.create(null)); } diff --git a/packages/store/addon/-private/system/model/internal-model.ts b/packages/store/addon/-private/system/model/internal-model.ts index 01b39059bdd..c7e80121fd8 100644 --- a/packages/store/addon/-private/system/model/internal-model.ts +++ b/packages/store/addon/-private/system/model/internal-model.ts @@ -23,7 +23,7 @@ import { default as recordDataFor, relationshipStateFor } from '../record-data-f import RecordData from '../../ts-interfaces/record-data'; import { JsonApiResource, JsonApiValidationError } from '../../ts-interfaces/record-data-json-api'; import { Record } from '../../ts-interfaces/record'; -import { Dict } from '../../types'; +import { ConfidentDict } from '../../types'; import { RECORD_DATA_ERRORS, RECORD_DATA_STATE } from '@ember-data/canary-features'; import { internalModelFactoryFor } from '../store/internal-model-factory'; import coerceId from '../coerce-id'; @@ -122,14 +122,14 @@ export default class InternalModel { __recordArrays: any; _references: any; _recordReference: any; - _manyArrayCache: Dict = Object.create(null); + _manyArrayCache: ConfidentDict = Object.create(null); // The previous ManyArrays for this relationship which will be destroyed when // we create a new ManyArray, but in the interim the retained version will be // updated if inverse internal models are unloaded. - _retainedManyArrayCache: Dict = Object.create(null); - _relationshipPromisesCache: Dict> = Object.create(null); - _relationshipProxyCache: Dict = Object.create(null); + _retainedManyArrayCache: ConfidentDict = Object.create(null); + _relationshipPromisesCache: ConfidentDict> = Object.create(null); + _relationshipProxyCache: ConfidentDict = Object.create(null); currentState: any; error: any; diff --git a/packages/store/addon/-private/system/store.ts b/packages/store/addon/-private/system/store.ts index 880f13f7ab4..4a30044ea45 100644 --- a/packages/store/addon/-private/system/store.ts +++ b/packages/store/addon/-private/system/store.ts @@ -3047,7 +3047,14 @@ function _commit(adapter, store, operation, snapshot) { }, function(error) { if (error instanceof InvalidError) { - let parsedErrors = serializer.extractErrors(store, modelClass, error, snapshot.id); + let parsedErrors; + + if (typeof serializer.extractErrors === 'function') { + parsedErrors = serializer.extractErrors(store, modelClass, error, snapshot.id); + } else { + parsedErrors = errorsArrayToHash(error.errors); + } + store.recordWasInvalid(internalModel, parsedErrors, error); } else { store.recordWasError(internalModel, error); diff --git a/packages/store/addon/-private/ts-interfaces/minimum-serializer-interface.ts b/packages/store/addon/-private/ts-interfaces/minimum-serializer-interface.ts new file mode 100644 index 00000000000..f6819d9c2d7 --- /dev/null +++ b/packages/store/addon/-private/ts-interfaces/minimum-serializer-interface.ts @@ -0,0 +1,319 @@ +/** + ## Overview + + In order to properly manage and present your data, `EmberData` + needs to understand the structure of data it receives. + + `Serializers` convert data between the server's API format and + the format `EmberData` understands. + + Data received from an API response is `"normalized"` into + [JSON:API](https://jsonapi.org/) (the format used internally + by `EmberData`), while data sent to an API is `"serialized"` + into the format the API expects. + + ### Implementing a Serializer + + There are only two required serializer methods, one for + normalizing data from the server API format into `JSON:API`, and + another for serializing records via `Snapshot`s into the expected + server API format. + + To implement a serializer, export a class that conforms to the structure + described by the [MinimumSerializerInterface](MinimumSerializerInterface) + from the `app/serializers/` directory. An example is below. + + ```ts + import EmberObject from '@ember/object'; + + export default class ApplicationSerializer extends EmberObject { + normalizeResponse(store, schema, rawPayload) { + return rawPayload; + } + + serialize(snapshot, options) { + const serializedResource = { + id: snapshot.id(), + type: snapshot.modelName, + attributes: snapshot.attributes() + }; + + return serializedResource; + } + } + ``` + + + #### Serializer Resolution + + `store.serializerFor(name)` will lookup serializers defined in + `app/serializers` and return an instance. If no serializer is found, an + error will be thrown. + + `serializerFor` first attempts to find a serializer with an exact match on `name`, + then falls back to checking for the presence of a serializer named `application`. + + ```ts + store.serializerFor('author'); + + // lookup paths (in order) => + // app/serializers/author.js + // app/serializers/application.js + ``` + + Most requests in `ember-data` are made with respect to a particular `type` (or `modelName`) + (e.g., "get me the full collection of **books**" or "get me the **employee** whose id is 37"). We + refer to this as the *"primary"* resource `type`. + + Typically `serializerFor` will be used to find a serializer with a name matching that of the primary + resource `type` for the request, falling back to the `application` serializer for those types that + do not have a defined serializer. This is often described as a `per-model` or `per-type` strategy + for defining serializers. However, because APIs rarely format payloads per-type but rather + per-API-version, this may not be a desired strategy. + + It is recommended that applications define only a single `application` adapter and serializer + where possible. + + If you have multiple API formats and the per-type strategy is not viable, one strategy is to + write an `application` adapter and serializer that make use of `options` to specify the desired + format when making a request. + + ### Using a Serializer + + Any serializer in `app/serializers` can be looked up by `name` using `store.serializerFor(name)`. + + ### Default Serializers + + For applications whose APIs are *very close to* or *exactly* the `REST` or `JSON:API` + format the `@ember-data/serializer` package contains implementations these applications can + extend. It also contains a simple `JSONSerializer` for serializing to/from very basic JSON objects. + + Many applications will find writing their own serializer to be more performant and less + complex than extending these classes even when their API format is very close to that expected + by these serializers. + + It is recommended that apps write their own serializer to best suit the needs of their API and + application. + + @module @ember-data/serializer + @main @ember-data/serializer + @class MinimumSerializerInterface + @public +*/ + +import { Object as JSONObject } from 'json-typescript'; +import Store from '../system/core-store'; +import { JsonApiDocument, SingleResourceDocument } from './ember-data-json-api'; +import Snapshot from '../system/snapshot'; +import ShimModelClass from '../system/model/shim-model-class'; +import { Dict } from './utils'; + +type OptionsHash = Dict; + +interface Serializer { + /** + * This method is responsible for normalizing the value resolved from the promise returned + * by an Adapter request into the format expected by the `Store`. + * + * The output should be a [JSON:API Document](https://jsonapi.org/format/#document-structure) + * with the following additional restrictions: + * + * - `type` should be formatted in the `singular` `dasherized` `lowercase` form + * - `members` (the property names of attributes and relationships) should be formatted + * to match their definition in the corresponding `Model` definition. Typically this + * will be `camelCase`. + * - [`lid`](https://github.com/emberjs/rfcs/blob/master/text/0403-ember-data-identifiers.md) is + * a valid optional sibling to `id` and `type` in both [Resources](https://jsonapi.org/format/#document-resource-objects) + * and [Resource Identifier Objects](https://jsonapi.org/format/#document-resource-identifier-objects) + * + * @method normalizeResponse + * @public + * @param {Store} store - the store service that initiated the request being normalized + * @param {ShimModelClass} schema - An object with methods for accessing information about + * the type, attributes and relationships of the primary type associated with the request. + * @param {JSONObject} rawPayload - The raw JSON response data returned from an API request. + * This correlates to the value the promise returned by the adapter method that performed + * the request resolved to. + * @param {string|null} id - For a `findRecord` request, this is the `id` initially provided + * in the call to `store.findRecord`. Else this value is `null`. + * @param {'findRecord' | 'queryRecord' | 'findAll' | 'findBelongsTo' | 'findHasMany' | 'findMany' | 'query' | 'createRecord' | 'deleteRecord' | 'updateRecord'} requestType - The + * type of request the Adapter had been asked to perform. + * + * @returns {JsonApiDocument} - a document following the structure of a [JSON:API Document](https://jsonapi.org/format/#document-structure). + */ + normalizeResponse( + store: Store, + schema: ShimModelClass, + rawPayload: JSONObject, + id: string | null, + requestType: + | 'findRecord' + | 'queryRecord' + | 'findAll' + | 'findBelongsTo' + | 'findHasMany' + | 'findMany' + | 'query' + | 'createRecord' + | 'deleteRecord' + | 'updateRecord' + ): JsonApiDocument; + + /** + * This method is responsible for serializing an individual record + * via a [Snapshot](Snapshot) into the format expected by the API. + * + * This method is called by `snapshot.serialize()`. + * + * When using `Model`, this method is called by `record.serialize()`. + * + * When using `JSONAPIAdapter` or `RESTAdapter` this method is called + * by `updateRecord` and `createRecord` if `Serializer.serializeIntoHash` + * is not implemented. + * + * @method serialize + * @public + * @param {Snapshot} snapshot - A Snapshot for the record to serialize + * @param {object} [options] + */ + serialize(snapshot: Snapshot, options?: OptionsHash): JSONObject; + + /** + * This method is intended to normalize data into a [JSON:API Document](https://jsonapi.org/format/#document-structure) + * with a data member containing a single [Resource](https://jsonapi.org/format/#document-resource-objects). + * + * - `type` should be formatted in the `singular` `dasherized` `lowercase` form + * - `members` (the property names of attributes and relationships) should be formatted + * to match their definition in the corresponding `Model` definition. Typically this + * will be `camelCase`. + * - [`lid`](https://github.com/emberjs/rfcs/blob/master/text/0403-ember-data-identifiers.md) is + * a valid optional sibling to `id` and `type` in both [Resources](https://jsonapi.org/format/#document-resource-objects) + * and [Resource Identifier Objects](https://jsonapi.org/format/#document-resource-identifier-objects) + * + * This method is called by the `Store` when `store.normalize(modelName, payload)` is + * called. It is recommended to use `store.serializerFor(modelName).normalizeResponse` + * over `store.normalize`. + * + * This method may be called when also using the `RESTSerializer` + * when `serializer.pushPayload` is called by `store.pushPayload`. + * It is recommended to use `store.push` over `store.pushPayload` after normalizing + * the payload directly. + * + * Example: + * ```js + * function pushPayload(store, modelName, rawPayload) { + * const ModelClass = store.modelFor(modelName); + * const serializer = store.serializerFor(modelName); + * const jsonApiPayload = serializer.normalizeResponse(store, ModelClass, rawPayload, null, 'query'); + * + * return store.push(jsonApiPayload); + * } + * ``` + * + * This method may be called when also using the `JSONAPISerializer` + * when normalizing included records. If mixing serializer usage in this way + * we recommend implementing this method, but caution that it may lead + * to unexpected mixing of formats. + * + * This method may also be called when normalizing embedded relationships when + * using the `EmbeddedRecordsMixin`. If using this mixin in a serializer in + * your application we recommend implementing this method, but caution that + * it may lead to unexpected mixing of formats. + * + * @method normalize [OPTIONAL] + * @public + * @optional + * @param {ShimModelClass} schema - An object with methods for accessing information about + * the type, attributes and relationships of the primary type associated with the request. + * @param {JSONObject} rawPayload - Some raw JSON data to be normalized into a [JSON:API Resource](https://jsonapi.org/format/#document-resource-objects). + * @param {string} [prop] - When called by the `EmbeddedRecordsMixin` this param will be the + * property at which the object provided as rawPayload was found. + * @returns {SingleResourceDocument} - A [JSON:API Document](https://jsonapi.org/format/#document-structure) + * containing a single [JSON:API Resource](https://jsonapi.org/format/#document-resource-objects) + * as its primary data. + */ + normalize?(schema: ShimModelClass, rawPayload: JSONObject, prop?: string): SingleResourceDocument; + + /** + * When using `JSONAPIAdapter` or `RESTAdapter` this method is called + * by `adapter.updateRecord` and `adapter.createRecord` if `Serializer.serializeIntoHash` + * is not implemented. + * + * You can use this method to customize the root keys serialized into the payload. + * The hash property should be modified by reference. + * + * For instance, your API may expect resources to be keyed by underscored type in the payload: + * + * ```js + * { + * _user: { + * type: 'user', + * id: '1' + * } + * } + * ``` + * + * Which when using these adapters can be achieved by implementing this method similar + * to the following: + * + * ```js + * serializeIntoHash(hash, ModelClass, snapshot, options) { + * hash[`_${snapshot.modelName}`] = this.serialize(snapshot, options).data; + * } + * ``` + * + * @method serializeIntoHash [OPTIONAL] + * @public + * @optional + * @param hash - a top most object of the request payload onto + * which to append the serialized record + * @param {ShimModelClass} schema - An object with methods for accessing information about + * the type, attributes and relationships of the primary type associated with the request. + * @param {Snapshot} snapshot - A Snapshot for the record to serialize + * @param [options] + * @returns {void} + */ + serializeIntoHash?(hash: object, schema: ShimModelClass, snapshot: Snapshot, options?: OptionsHash): void; + + /** + * This method allows for normalization of data when `store.pushPayload` is called + * and should be implemented if you want to use that method. + * + * The output should be a [JSON:API Document](https://jsonapi.org/format/#document-structure) + * with the following additional restrictions: + * + * - `type` should be formatted in the `singular` `dasherized` `lowercase` form + * - `members` (the property names of attributes and relationships) should be formatted + * to match their definition in the corresponding `Model` definition. Typically this + * will be `camelCase`. + * - [`lid`](https://github.com/emberjs/rfcs/blob/master/text/0403-ember-data-identifiers.md) is + * a valid optional sibling to `id` and `type` in both [Resources](https://jsonapi.org/format/#document-resource-objects) + * and [Resource Identifier Objects](https://jsonapi.org/format/#document-resource-identifier-objects) + * + * If you need better control over normalization or want access to the records being added or updated + * in the store, we recommended using `store.push` over `store.pushPayload` after normalizing + * the payload directly. This can even take advantage of an existing serializer for the format + * the data is in, for example: + * + * ```js + * function pushPayload(store, modelName, rawPayload) { + * const ModelClass = store.modelFor(modelName); + * const serializer = store.serializerFor(modelName); + * const jsonApiPayload = serializer.normalizeResponse(store, ModelClass, rawPayload, null, 'query'); + * + * return store.push(jsonApiPayload); + * } + * ``` + * + * @method pushPayload [OPTIONAL] + * @public + * @optional + * @param {Store} store - the store service that initiated the request being normalized + * @param {JSONObject} rawPayload - The raw JSON response data returned from an API request. + * This JSON should be in the API format expected by the serializer. + * @returns {JsonApiDocument} - a document following the structure of a [JSON:API Document](https://jsonapi.org/format/#document-structure) + */ + pushPayload?(store: Store, rawPayload: JSONObject): JsonApiDocument; +} + +export default Serializer; diff --git a/packages/store/addon/-private/types.ts b/packages/store/addon/-private/types.ts index 3c6af61e96a..43d92465412 100644 --- a/packages/store/addon/-private/types.ts +++ b/packages/store/addon/-private/types.ts @@ -2,4 +2,5 @@ @module @ember-data/store */ -export type Dict = { [KK in K]: V }; +export type ConfidentDict = { [key: string]: V }; +export type Dict = { [key: string]: V | undefined };