UNPKG

@orbit/jsonapi

Version:

JSON:API support for Orbit.

406 lines (363 loc) 11.2 kB
import { Orbit } from '@orbit/core'; import { requestOptionsForSource } from '@orbit/data'; import { RecordKeyMap, InitializedRecord, RecordSchema, RecordQueryExpression, RecordTransform, RecordQuery } from '@orbit/records'; import { Dict } from '@orbit/utils'; import { NetworkError, InvalidServerResponse, ClientError, ServerError } from './lib/exceptions'; import { RecordTransformRequest } from './lib/transform-requests'; import { RecordQueryRequest } from './lib/query-requests'; import { deepMerge, toArray } from '@orbit/utils'; import { ResourceDocument } from './resource-document'; import { RecordDocument } from './record-document'; import { JSONAPIRequestOptions } from './lib/jsonapi-request-options'; import { JSONAPIURLBuilder, JSONAPIURLBuilderSettings } from './jsonapi-url-builder'; import { JSONAPISerializer, JSONAPISerializerSettings } from './jsonapi-serializer'; import { SerializerForFn, SerializerClassForFn, SerializerSettingsForFn } from '@orbit/serializers'; import { buildJSONAPISerializerFor } from './serializers/jsonapi-serializer-builder'; import { JSONAPISerializers } from './serializers/jsonapi-serializers'; import { RecordOperation } from '@orbit/records'; import { JSONAPIResponse } from './jsonapi-response'; const { assert, deprecate } = Orbit; export interface FetchSettings { headers?: Dict<any>; method?: string; json?: Dict<any>; body?: string; params?: Dict<any>; timeout?: number; credentials?: string; cache?: string; redirect?: string; referrer?: string; referrerPolicy?: string; integrity?: string; } export interface JSONAPIRequestProcessorSettings { sourceName: string; serializerFor?: SerializerForFn; serializerClassFor?: SerializerClassForFn; serializerSettingsFor?: SerializerSettingsForFn; SerializerClass?: new ( settings: JSONAPISerializerSettings ) => JSONAPISerializer; URLBuilderClass?: new ( settings: JSONAPIURLBuilderSettings ) => JSONAPIURLBuilder; namespace?: string; host?: string; defaultFetchSettings?: FetchSettings; allowedContentTypes?: string[]; schema: RecordSchema; keyMap?: RecordKeyMap; } export class JSONAPIRequestProcessor { sourceName: string; urlBuilder: JSONAPIURLBuilder; allowedContentTypes: string[]; defaultFetchSettings!: FetchSettings; schema: RecordSchema; keyMap?: RecordKeyMap; protected _serializer?: JSONAPISerializer; protected _serializerFor: SerializerForFn; constructor(settings: JSONAPIRequestProcessorSettings) { let { sourceName, allowedContentTypes, schema, keyMap, SerializerClass, serializerFor, serializerClassFor, serializerSettingsFor } = settings; this.sourceName = sourceName; this.allowedContentTypes = allowedContentTypes || [ 'application/vnd.api+json', 'application/json' ]; this.schema = schema; this.keyMap = keyMap; if (SerializerClass) { deprecate( "The 'SerializerClass' setting for 'JSONAPIRequestProcessor' has been deprecated. Pass 'serializerFor', 'serializerClassFor', and/or 'serializerSettingsFor' instead." ); this._serializer = new SerializerClass({ schema, keyMap }); } this._serializerFor = buildJSONAPISerializerFor({ schema, keyMap, serializerFor, serializerClassFor, serializerSettingsFor }); const URLBuilderClass = settings.URLBuilderClass || JSONAPIURLBuilder; const urlBuilderOptions: JSONAPIURLBuilderSettings = { host: settings.host, namespace: settings.namespace, keyMap: settings.keyMap, serializer: this._serializer, serializerFor: this._serializerFor }; this.urlBuilder = new URLBuilderClass(urlBuilderOptions); this.initDefaultFetchSettings(settings); } /** * @deprecated since v0.17, use `serializerFor` instead */ get serializer(): JSONAPISerializer { deprecate( "'JSONAPIRequestProcessor#serializer' has been deprecated. Use 'serializerFor' instead." ); if (this._serializer) { return this._serializer; } else { return this._serializerFor( JSONAPISerializers.ResourceDocument ) as JSONAPISerializer; } } get serializerFor(): SerializerForFn { return this._serializerFor; } fetch(url: string, customSettings?: FetchSettings): Promise<JSONAPIResponse> { let settings = this.initFetchSettings(customSettings); let fullUrl = url; if (settings.params) { fullUrl = this.urlBuilder.appendQueryParams(fullUrl, settings.params); delete settings.params; } let fetchFn = (Orbit as any).fetch || Orbit.globals.fetch; // console.log('fetch', fullUrl, settings, 'polyfill', fetchFn.polyfill); if (settings.timeout !== undefined && settings.timeout > 0) { let timeout = settings.timeout; delete settings.timeout; return new Promise((resolve, reject) => { let timedOut: boolean; let timer = Orbit.globals.setTimeout(() => { timedOut = true; reject(new NetworkError(`No fetch response within ${timeout}ms.`)); }, timeout); fetchFn(fullUrl, settings) .catch((e: Error) => { Orbit.globals.clearTimeout(timer); if (!timedOut) { return this.handleFetchError(e); } }) .then((response: any) => { Orbit.globals.clearTimeout(timer); if (!timedOut) { return this.handleFetchResponse(response); } }) .then(resolve, reject); }); } else { return fetchFn(fullUrl, settings) .catch((e: Error) => this.handleFetchError(e)) .then((response: any) => this.handleFetchResponse(response)); } } initFetchSettings(customSettings: FetchSettings = {}): FetchSettings { let settings: FetchSettings = deepMerge( {}, this.defaultFetchSettings, customSettings ); if (settings.json) { assert( "`json` and `body` can't both be set for fetch requests.", !settings.body ); settings.body = JSON.stringify(settings.json); delete settings.json; } if (settings.headers && !settings.body) { delete settings.headers['Content-Type']; } return settings; } operationsFromDeserializedDocument( deserialized: RecordDocument ): RecordOperation[] { const records: InitializedRecord[] = []; Array.prototype.push.apply(records, toArray(deserialized.data)); if (deserialized.included) { Array.prototype.push.apply(records, deserialized.included); } return records.map((record) => { return { op: 'updateRecord', record }; }); } buildFetchSettings( request: RecordQueryRequest | RecordTransformRequest ): FetchSettings { const settings = { params: {}, ...request.options?.settings }; if (request.options) { const { filter, sort, page, include, fields } = request.options; if (filter) { settings.params.filter = this.urlBuilder.buildFilterParam( filter, request ); } if (sort) { settings.params.sort = this.urlBuilder.buildSortParam(sort, request); } if (page) { settings.params.page = this.urlBuilder.buildPageParam(page, request); } if (include) { settings.params.include = this.urlBuilder.buildIncludeParam( include, request ); } if (fields) { settings.params.fields = this.urlBuilder.buildFieldsParam( fields, request ); } } return settings; } mergeRequestOptions( options: | JSONAPIRequestOptions | undefined | (JSONAPIRequestOptions | undefined)[] ): JSONAPIRequestOptions | undefined { return requestOptionsForSource<JSONAPIRequestOptions>( options, this.sourceName ); } /** * @deprecated since v0.17, use `mergeRequestOptions` instead */ customRequestOptions( queryOrTransform: RecordQuery | RecordTransform, queryExpressionOrOperation: RecordQueryExpression | RecordOperation ): JSONAPIRequestOptions | undefined { deprecate( "'JSONAPIRequestProcessor#customRequestOptions' has been deprecated. Use 'mergeRequestOptions' instead." ); return this.mergeRequestOptions([ queryOrTransform.options, queryExpressionOrOperation.options ]); } /* eslint-disable @typescript-eslint/no-unused-vars */ preprocessResponseDocument( document: ResourceDocument | undefined, request: RecordQueryRequest | RecordTransformRequest ): void {} /* eslint-enable @typescript-eslint/no-unused-vars */ protected responseHasContent( response: Response, ignoreUnrecognizedContent?: boolean ): boolean { let contentType = response.headers.get('Content-Type'); if (contentType) { for (let allowedContentType of this.allowedContentTypes) { if (contentType.indexOf(allowedContentType) > -1) { return true; } } if (!ignoreUnrecognizedContent) { throw new InvalidServerResponse( `The server responded with the content type '${contentType}', which is not allowed. Allowed content types include: '${this.allowedContentTypes.join( "', '" )}'.` ); } } return false; } protected initDefaultFetchSettings( settings: JSONAPIRequestProcessorSettings ): void { this.defaultFetchSettings = { headers: { Accept: 'application/vnd.api+json', 'Content-Type': 'application/vnd.api+json' }, timeout: 5000 }; if (settings.defaultFetchSettings) { deepMerge(this.defaultFetchSettings, settings.defaultFetchSettings); } } protected async handleFetchResponse( response: Response ): Promise<JSONAPIResponse> { const responseDetail: JSONAPIResponse = { response }; if (response.status >= 200 && response.status < 300) { if (response.status !== 204 && this.responseHasContent(response)) { responseDetail.document = await response.json(); } } else if (response.status !== 304 && response.status !== 404) { if (this.responseHasContent(response, true)) { const document = await response.json(); await this.handleFetchResponseError(response, document); } else { await this.handleFetchResponseError(response); } } return responseDetail; } protected async handleFetchResponseError( response: Response, data?: unknown ): Promise<Error> { let error: any; if (response.status >= 400 && response.status < 500) { error = new ClientError(response.statusText); } else { error = new ServerError(response.statusText); } error.response = response; error.data = data; throw error; } protected async handleFetchError(e: Error | string): Promise<Error> { if (typeof e === 'string') { throw new NetworkError(e); } else { throw e; } } }