diff --git a/src/patch/createOperation.ts b/src/patch/createOperation.ts index d1a95d0..cf80fb7 100644 --- a/src/patch/createOperation.ts +++ b/src/patch/createOperation.ts @@ -20,6 +20,10 @@ export interface Add extends Operation { value: any; } +export function isAdd(operation: Operation): operation is Add { + return operation.op === 'add'; +} + function navigatePath(target: any, path: JsonPointer) { let currentPath = ''; let lastSegment = ''; @@ -101,23 +105,43 @@ function test(this: Test, target: any) { export interface Remove extends Operation {} +export function isRemove(operation: Operation): operation is Remove { + return operation.op === 'remove'; +} + export interface Replace extends Operation { value: any; oldValue: any; } +export function isReplace(operation: Operation): operation is Replace { + return operation.op === 'replace'; +} + export interface Move extends Operation { from: JsonPointer; } +export function isMove(operation: Operation): operation is Move { + return operation.op === 'move'; +} + export interface Copy extends Operation { from: JsonPointer; } +export function isCopy(operation: Operation): operation is Copy { + return operation.op === 'copy'; +} + export interface Test extends Operation { value: any; } +export function isTest(operation: Operation): operation is Test { + return operation.op === 'test'; +} + function getPath(path: JsonPointer | string[]) { if (Array.isArray(path)) { return createJsonPointer(...path); diff --git a/src/patch/createPatch.ts b/src/patch/createPatch.ts index 21bbcf8..872c88e 100644 --- a/src/patch/createPatch.ts +++ b/src/patch/createPatch.ts @@ -1,10 +1,11 @@ import { shouldRecurseInto, isEqual } from '../utils'; -import createOperation, { Operation, OperationType } from './createOperation'; +import createOperation, {Operation, OperationType, isAdd, isReplace, isRemove} from './createOperation'; import createJsonPointer, { JsonPointer } from './createJsonPointer'; export interface Patch { operations: Operation[]; apply(target: T): U; - toString(): String; + toString(): string; + toMergeString(): string; } export type PatchMapEntry = { id: string; patch: Patch }; @@ -62,6 +63,34 @@ function createPatch(operations: Operation[]) { return next.toString(); } }, '') + ']'; + }, + toMergeString(this: Patch) { + const mergeObject: any = {}; + + this.operations.forEach((operation) => { + if (!isAdd(operation) && !isReplace(operation) && !isRemove(operation)) { + console.warn('Patch contains unsupported operation for JSON Merge serialization'); + } + else { + const segments = operation.path.segments(); + let currentObject: any = mergeObject; + let nextProperty = segments.shift(); + while (segments.length && nextProperty) { + currentObject = currentObject[nextProperty] = currentObject[nextProperty] || {}; + nextProperty = segments.shift(); + } + if (nextProperty) { + if (isAdd(operation) || isReplace(operation)) { + currentObject[nextProperty] = operation.value; + } + else { + currentObject[nextProperty] = null; + } + } + } + }); + + return JSON.stringify(mergeObject); } }; } diff --git a/src/storage/createBaseStorage.ts b/src/storage/createBaseStorage.ts new file mode 100644 index 0000000..e0eee56 --- /dev/null +++ b/src/storage/createBaseStorage.ts @@ -0,0 +1,68 @@ +import WeakMap from 'dojo-shim/WeakMap'; +import Promise from 'dojo-shim/Promise'; +import { Patch, PatchMapEntry } from '../patch/createPatch'; +import { Query } from '../query/interfaces'; +import { StoreOperation, CrudOptions, StoreOptions } from '../store/createStore'; +import compose, { ComposeFactory } from 'dojo-compose/compose'; + +export type IdObject = { [ index: string ]: string; id: string }; + +export type CrudArgument = T | string | PatchMapEntry; + +export interface UpdateResults { + currentItems?: T[]; + failedData?: CrudArgument[]; + successfulData: T[] | string[]; + type: StoreOperation; +} + +export interface BaseStorage { + identify(items: T[]|T): string[]; +} + +export interface Storage extends BaseStorage { + createId(): Promise; + fetch(query?: Query): Promise; + get(ids: string[]): Promise; + put(items: T[], options?: O): Promise>; + add(items: T[], options?: O): Promise>; + delete(ids: string[]): Promise>; + patch(updates: { id: string; patch: Patch }[], options?: O): Promise>; + isUpdate(item: T): Promise<{ isUpdate: boolean; item: T, id: string }>; +} + +export interface BaseStorageState { + idProperty?: string; + idFunction?: (item: T) => string; +} + +export interface StorageFactory extends ComposeFactory, StoreOptions<{}, CrudOptions>> { + (options?: O): Storage; +} + +const instanceStateMap = new WeakMap, BaseStorageState<{}>>(); + +const createBaseStorage = compose, StoreOptions<{}, CrudOptions>>({ + identify(this: Storage<{}, {}>, items: IdObject[]| IdObject): string[] { + const state = instanceStateMap.get(this); + const itemArray = Array.isArray(items) ? items : [ items ]; + if (state.idProperty) { + return itemArray.map((item) => item[state.idProperty!]); + } + else if (state.idFunction) { + return itemArray.map(state.idFunction); + } + else { + return itemArray.map((item) => item.id); + } + } + +}, (instance: Storage, options?: StoreOptions) => { + options = options || {}; + instanceStateMap.set(instance, { + idProperty: options.idProperty, + idFunction: options.idFunction + }); +}); + +export default createBaseStorage; diff --git a/src/storage/createInMemoryStorage.ts b/src/storage/createInMemoryStorage.ts index ba78a67..d145737 100644 --- a/src/storage/createInMemoryStorage.ts +++ b/src/storage/createInMemoryStorage.ts @@ -1,24 +1,12 @@ import { Query } from '../query/interfaces'; import { StoreOperation, CrudOptions, StoreOptions, UpdateResults } from '../store/createStore'; -import compose, { ComposeFactory } from 'dojo-compose/compose'; +import createBaseStorage, { Storage, StorageFactory } from './createBaseStorage'; import Promise from 'dojo-shim/Promise'; -import WeakMap from 'dojo-shim/WeakMap'; -import Map from 'dojo-shim/Map'; -import { Patch } from '../patch/createPatch'; + import WeakMap from 'dojo-shim/WeakMap'; + import Map from 'dojo-shim/Map'; + import { Patch } from '../patch/createPatch'; import { duplicate } from 'dojo-core/lang'; -export interface Storage { - identify(items: T[]|T): string[]; - createId(): Promise; - fetch(query?: Query): Promise; - get(ids: string[]): Promise; - put(items: T[], options?: O): Promise>; - add(items: T[], options?: O): Promise>; - delete(ids: string[]): Promise>; - patch(updates: { id: string; patch: Patch }[], options?: O): Promise>; - isUpdate(item: T): Promise<{ isUpdate: boolean; item: T, id: string }>; -} - export interface InMemoryStorageState { idProperty?: string; idFunction?: (item: T) => string; @@ -30,16 +18,10 @@ export interface InMemoryStorageState { const instanceStateMap = new WeakMap, InMemoryStorageState<{}>>(); -export interface StorageFactory extends ComposeFactory, StoreOptions<{}, CrudOptions>> { - (options?: O): Storage; -} - export interface InMemoryStorageFactory extends StorageFactory { (options?: StoreOptions): Storage; } -type IdObject = { [ index: string ]: string; id: string }; - function putSync(instance: Storage<{}, {}>, items: {}[], options?: CrudOptions) { const state = instanceStateMap.get(instance); const ids = instance.identify(items); @@ -77,173 +59,154 @@ function putSync(instance: Storage<{}, {}>, items: {}[], options?: CrudOptions) }; } -const createInMemoryStorage: InMemoryStorageFactory = compose, StoreOptions<{}, CrudOptions>>({ - identify(this: Storage<{}, {}>, items: IdObject[]| IdObject): string[] { - const state = instanceStateMap.get(this); - const itemArray = Array.isArray(items) ? items : [ items ]; - if (state.idProperty) { - const idProperty: string = state.idProperty; - return itemArray.map((item) => { - return item[idProperty]; - }); - } - else if (state.idFunction) { - return itemArray.map(state.idFunction); - } - else { - return itemArray.map(function(item) { - return item.id; - }); - } - }, - - createId(this: Storage<{}, {}>): Promise { - const state = instanceStateMap.get(this); - return Promise.resolve(String(state.nextId++)); - }, - - fetch(this: Storage<{}, {}>, query?: Query<{}>): Promise<{}[]> { - const state = instanceStateMap.get(this); - const fullData = state.data; - const data = (query ? query.apply(fullData) : fullData).slice(); - const returnPromise = state.returnsPromise.then(() => data); - state.returnsPromise = returnPromise; - return returnPromise; - }, - - get(this: Storage<{}, {}>, ids: string[]): Promise<{}[]> { - const state = instanceStateMap.get(this); - const data = state.data; - const objects: {}[] = []; - return Promise.resolve(ids.reduce(function(prev, next) { - return state.index.has(next) ? prev.concat( data[state.index.get(next)!] ) : prev; - }, objects)); - }, - - put(this: Storage<{}, {}>, items: {}[], options?: CrudOptions): Promise> { - const state = instanceStateMap.get(this); - try { - const result = putSync(this, items, options); - // Don't control the order operations are executed in, but make sure that the results - // resolve in the order they were actually executed in. - const returnPromise = state.returnsPromise.then(() => result); +const createInMemoryStorage: InMemoryStorageFactory = createBaseStorage.mixin({ + mixin: { + createId(this: Storage<{}, {}>): Promise { + const state = instanceStateMap.get(this); + return Promise.resolve(String(state.nextId++)); + }, + + fetch(this: Storage<{}, {}>, query?: Query<{}>): Promise<{}[]> { + const state = instanceStateMap.get(this); + const fullData = state.data; + const data = (query ? query.apply(fullData) : fullData).slice(); + const returnPromise = state.returnsPromise.then(() => data); state.returnsPromise = returnPromise; return returnPromise; - } catch (error) { - return Promise.reject(error); - } - }, + }, + + get(this: Storage<{}, {}>, ids: string[]): Promise<{}[]> { + const state = instanceStateMap.get(this); + const data = state.data; + const objects: {}[] = []; + return Promise.resolve(ids.reduce(function(prev, next) { + return state.index.has(next) ? prev.concat( data[state.index.get(next)!] ) : prev; + }, objects)); + }, + + put(this: Storage<{}, {}>, items: {}[], options?: CrudOptions): Promise> { + const state = instanceStateMap.get(this); + try { + const result = putSync(this, items, options); + // Don't control the order operations are executed in, but make sure that the results + // resolve in the order they were actually executed in. + const returnPromise = state.returnsPromise.then(() => result); + state.returnsPromise = returnPromise; + return returnPromise; + } catch (error) { + return Promise.reject(error); + } + }, + + add(this: Storage<{}, {}>, items: {}[], options?: CrudOptions): Promise> { + options = options || {}; + const state = instanceStateMap.get(this); + if (typeof options.rejectOverwrite === 'undefined') { + options.rejectOverwrite = true; + } + + try { + const result = putSync(this, items, options); + // Don't control the order operations are executed in, but make sure that the results + // resolve in the order they were actually executed in. + const returnPromise = state.returnsPromise.then(() => { + result.type = StoreOperation.Add; + return result; + }); + state.returnsPromise = returnPromise; + return returnPromise; + } catch (error) { + return Promise.reject(error); + } + }, + + delete(this: Storage<{}, {}>, ids: string[]): Promise> { + const state = instanceStateMap.get(this); + const data = state.data; + const idsToRemove = ids.filter(function(id) { + return state.index.has(id); + }); - add(this: Storage<{}, {}>, items: {}[], options?: CrudOptions): Promise> { - options = options || {}; - const state = instanceStateMap.get(this); - if (typeof options.rejectOverwrite === 'undefined') { - options.rejectOverwrite = true; - } + const indices: number[] = idsToRemove + .map(function(id) { + return state.index.get(id)!; + }) + .sort(); + + idsToRemove.forEach(function(id) { + state.index.delete(id); + }); + indices.forEach(function(index, indexArrayIndex) { + return data.splice(index - indexArrayIndex, 1); + }); + if (indices.length) { + const firstInvalidIndex = indices[0]; + const updateIndexForIds = this.identify(data.slice(firstInvalidIndex)); + updateIndexForIds.forEach(function(id, index) { + state.index.set(id, index + firstInvalidIndex); + }); + } - try { - const result = putSync(this, items, options); // Don't control the order operations are executed in, but make sure that the results // resolve in the order they were actually executed in. - const returnPromise = state.returnsPromise.then(() => { - result.type = StoreOperation.Add; - return result; - }); + const returnPromise = state.returnsPromise.then(() => ({ + successfulData: idsToRemove, + type: StoreOperation.Delete + })); state.returnsPromise = returnPromise; return returnPromise; - } catch (error) { - return Promise.reject(error); - } - }, + }, - delete(this: Storage<{}, {}>, ids: string[]): Promise> { - const state = instanceStateMap.get(this); - const data = state.data; - const idsToRemove = ids.filter(function(id) { - return state.index.has(id); - }); + patch(this: Storage<{}, {}>, updates: { id: string; patch: Patch<{}, {}> }[]): Promise> { + const state = instanceStateMap.get(this); + const data = state.data; - const indices: number[] = idsToRemove - .map(function(id) { - return state.index.get(id)!; - }) - .sort(); - - idsToRemove.forEach(function(id) { - state.index.delete(id); - }); - indices.forEach(function(index, indexArrayIndex) { - return data.splice(index - indexArrayIndex, 1); - }); - if (indices.length) { - const firstInvalidIndex = indices[0]; - const updateIndexForIds = this.identify(data.slice(firstInvalidIndex)); - updateIndexForIds.forEach(function(id, index) { - state.index.set(id, index + firstInvalidIndex); + const filteredUpdates = updates.filter(function(update) { + return state.index.has(update.id); + }); + const oldIndices = filteredUpdates.map(function(update) { + return state.index.get(update.id)!; }); - } - - // Don't control the order operations are executed in, but make sure that the results - // resolve in the order they were actually executed in. - const returnPromise = state.returnsPromise.then(() => ({ - successfulData: idsToRemove, - type: StoreOperation.Delete - })); - state.returnsPromise = returnPromise; - return returnPromise; - }, - - patch(this: Storage<{}, {}>, updates: { id: string; patch: Patch<{}, {}> }[]): Promise> { - const state = instanceStateMap.get(this); - const data = state.data; - - const filteredUpdates = updates.filter(function(update) { - return state.index.has(update.id); - }); - const oldIndices = filteredUpdates.map(function(update) { - return state.index.get(update.id)!; - }); - try { - const updatedItems = filteredUpdates.map(function(update, index) { - const item = duplicate(data[oldIndices[index]]); - const updatedItem = update.patch.apply(item); - data[oldIndices[index]] = updatedItem; - return updatedItem; + try { + const updatedItems = filteredUpdates.map(function(update, index) { + const item = duplicate(data[oldIndices[index]]); + const updatedItem = update.patch.apply(item); + data[oldIndices[index]] = updatedItem; + return updatedItem; + }); + // Don't control the order operations are executed in, but make sure that the results + // resolve in the order they were actually executed in. + const returnsPromise = state.returnsPromise.then(() => ({ + successfulData: updatedItems, + type: StoreOperation.Patch + })); + state.returnsPromise = returnsPromise; + return returnsPromise; + } catch (error) { + return Promise.reject(error); + } + }, + + isUpdate(this: Storage<{}, {}>, item: {}): Promise<{ isUpdate: boolean; item: {}; id: string }> { + const state = instanceStateMap.get(this); + const id = this.identify([ item ])[0]; + const isUpdate = state.index.has(id); + return Promise.resolve({ + id: id, + item: item, + isUpdate: isUpdate }); - // Don't control the order operations are executed in, but make sure that the results - // resolve in the order they were actually executed in. - const returnsPromise = state.returnsPromise.then(() => ({ - successfulData: updatedItems, - type: StoreOperation.Patch - })); - state.returnsPromise = returnsPromise; - return returnsPromise; - } catch (error) { - return Promise.reject(error); } - }, - - isUpdate(this: Storage<{}, {}>, item: {}): Promise<{ isUpdate: boolean; item: {}; id: string }> { - const state = instanceStateMap.get(this); - const id = this.identify([ item ])[0]; - const isUpdate = state.index.has(id); - return Promise.resolve({ - id: id, - item: item, - isUpdate: isUpdate + }, initialize(instance: Storage, options?: StoreOptions) { + options = options || {}; + instanceStateMap.set(instance, { + data: [], + nextId: 1, + index: new Map(), + returnsPromise: Promise.resolve() }); } -}, (instance: Storage, options?: StoreOptions) => { - options = options || {}; - instanceStateMap.set(instance, { - data: [], - nextId: 1, - index: new Map(), - idProperty: options.idProperty, - idFunction: options.idFunction, - returnsPromise: Promise.resolve() - }); }); export default createInMemoryStorage; diff --git a/src/storage/createRestStorage.ts b/src/storage/createRestStorage.ts new file mode 100644 index 0000000..af70e07 --- /dev/null +++ b/src/storage/createRestStorage.ts @@ -0,0 +1,285 @@ +import createBaseStorage, { Storage, StorageFactory, UpdateResults } from './createBaseStorage'; +import { StoreOptions, StoreOperation, CrudOptions } from '../store/createStore'; +import Promise from 'dojo-shim/Promise'; +import { Query } from '../query/interfaces'; +import { Patch } from '../patch/createPatch'; +import request, { ResponsePromise } from 'dojo-core/request'; +import WeakMap from 'dojo-shim/WeakMap'; +import { find } from 'dojo-shim/array'; + +export interface RestOptions extends StoreOptions { + target: string; + createIdTarget?: string; + idSerializer?: (ids: string[]) => string; + requestSerializer?: (items: T | T[]) => string; + responseDeserializer?: (response: string) => T[]; + querySerializer?: (query: Query) => string; + acceptPatch?: false | string; + contentType?: string; + customPutPostRequest?: boolean; +} + +function getRequest(uri: string, state: RestState) { + return toPromise(request.get(uri, { + headers: { + 'Accept': state.contentType + } + })).then((data) => state.responseDeserializer(data)); +} + +function toPromise(responsePromise: ResponsePromise, defaultValue?: T): Promise { + return new Promise((resolve, reject) => responsePromise.then( + (response) => { + if (response && response.data) { + resolve(response.data); + } + else { + resolve(defaultValue); + } + }, + (error) => { + reject(error); + } + )); +} +export interface RestStorageFactory extends StorageFactory { + (options: RestOptions): Storage; +} + +export interface RestState { + target: string; + createIdTarget?: string; + idSerializer?: (ids: string[]) => string; + requestSerializer: (items: T | T[]) => string; + responseDeserializer: (response: string) => T[]; + querySerializer?: (query: Query) => string; + acceptPatch: false | string; + contentType: string; + customPutPostRequest: boolean; + initPromise: Promise; +} + +function defaultRequestSerializer(items: T | T[]): string { + return JSON.stringify(items); +} + +function defaultResponseDeserializer(response?: string): T[] { + return JSON.parse(response || 'null'); +} + +const instanceStateMap = new WeakMap, RestState<{}>>(); + +const JsonContentType = 'application/json'; +const JsonPatchContentType = 'application/json-patch+json'; +const MergePatchContentType = 'application/merge-patch+json'; + +/* + Return the patch serializer for the first supported content type in 'Accept-Patch' header or user option. + */ +function getPatchSerializer(acceptPatch: string | false): ((patch: Patch<{}, {}>) => string) | undefined { + if (!acceptPatch || acceptPatch.trim().length === 0) { + return undefined; + } + + for (let contentType of acceptPatch.split(',')) { + if (JsonPatchContentType === contentType.trim()) { + return (patch: Patch<{}, {}>) => patch.toString(); + } + else if (MergePatchContentType === contentType.trim()) { + return (patch: Patch<{}, {}>) => patch.toMergeString(); + } + } + return undefined; +} + +const createRestStorage: RestStorageFactory = createBaseStorage.mixin({ + mixin: { + createId(this: Storage<{}, {}>): Promise { + const state = instanceStateMap.get(this); + if (state.createIdTarget) { + return toPromise(request.get(state.createIdTarget!)); + } + else { + return Promise.reject(new Error('createId is not supported in a rest store.')); + } + }, + + fetch(this: Storage<{}, {}>, query?: Query<{}>): Promise<{}[]> { + const state = instanceStateMap.get(this); + let uri = state.target; + if (query) { + uri += '?' + query.toString(state.querySerializer); + } + return getRequest(uri, state); + }, + + get(this: Storage<{}, {}>, ids: string[]): Promise<{}[]> { + if (!ids || ids.length === 0) { + return Promise.reject(new Error('ids can\'t be empty.')); + } + const state = instanceStateMap.get(this); + + if (ids.length === 1 || !state.idSerializer) { + return Promise.all(ids.map((id) => getRequest(state.target + `/${id}`, state))); + } + else { + return getRequest(state.target + `?${state.idSerializer(ids)}`, state); + } + }, + + put(this: Storage<{}, {}>, items: {}[], options?: CrudOptions): Promise> { + if (!items || items.length === 0) { + return Promise.reject(new Error('items can\'t be empty.')); + } + + const state = instanceStateMap.get(this); + + const putRequest = (items: {}[]): Promise<{}> => { + const ids = this.identify(items).filter((id) => id !== null && id !== undefined); + const method = ids.length < items.length ? 'POST' : 'PUT'; + const uri = state.target + (ids.length === 1 ? `/${ids[0]}` : ''); + const data = state.requestSerializer(items.length === 1 ? items[0] : items); + return toPromise(request(uri, { + method, data, + headers: { + 'Accept': state.contentType, + 'Content-Type': state.contentType + } + })); + }; + + let promise: Promise<{}>; + if (items.length === 1 || !state.customPutPostRequest) { + promise = Promise.all(items.map((item) => putRequest([item]) )); + } + else { + promise = putRequest(items); + } + + return promise.then(() => ({ + successfulData: items, + type: StoreOperation.Put + })); + }, + + add(this: Storage<{}, {}>, items: {}[], options?: CrudOptions): Promise> { + return this.put(items, options).then((result) => { + result.type = StoreOperation.Add; + return result; + }); + }, + + delete(this: Storage<{}, {}>, ids: string[]): Promise> { + if (!ids || ids.length === 0) { + return Promise.reject(new Error('ids can\'t be empty.')); + } + const state = instanceStateMap.get(this); + + let promise: Promise<{}>; + + if (ids.length === 1 || !state.idSerializer) { + promise = Promise.all(ids.map((id) => request.delete(state.target + `/${id}`) )); + } + else { + promise = toPromise(request.delete(state.target + `?${state.idSerializer(ids)}`)); + } + + return promise.then(() => ({ + successfulData: ids, + type: StoreOperation.Delete + })); + }, + + patch(this: Storage<{}, {}>, updates: { id: string; patch: Patch<{}, {}> }[]): Promise> { + if (!updates || updates.length === 0) { + return Promise.reject(new Error('updates can\'t be empty.')); + } + const state = instanceStateMap.get(this); + + const patchRequest = (id: string, data: string) => { + return request(state.target + '/' + id, { + data: data, + method: 'PATCH' + }).then(() => { + return this.get([id]); + }).then((items) => ({ + successfulData: items, + type: StoreOperation.Patch + })); + }; + + const getNPutRequest = () => { + return this.get(updates.map(({id}) => id )).then((items) => { + const updatedItems = items.map((item) => { + const _id = this.identify(item)[0]; + const patch = find(updates, ({id}) => id === _id).patch; + return patch.apply(item); + }); + return this.put(updatedItems); + }).then((result) => { + result.type = StoreOperation.Patch; + return result; + }); + + }; + if (updates.length === 1) { + // user option takes precedence + const patchSerializer = getPatchSerializer(state.acceptPatch); + if (patchSerializer) { + return > patchRequest(updates[0].id, patchSerializer(updates[0].patch)); + } + + return > state.initPromise.then( + (acceptPatch: string) => getPatchSerializer(acceptPatch), + () => null + ).then((patchSerializer) => { + if (patchSerializer) { + return patchRequest(updates[0].id, patchSerializer(updates[0].patch)); + } + else { + return > getNPutRequest(); + } + }); + } + else { + return getNPutRequest(); + } + }, + + isUpdate(this: Storage<{}, {}>, item: {}): Promise<{ isUpdate: boolean; item: {}; id: string }> { + const id = this.identify(item)[0]; + return this.get([id]).then((items) => ({ + id: id, + item: item, + isUpdate: items.length > 0 + })); + } + }, + initialize(instance: Storage, { target, + createIdTarget, + idSerializer, + requestSerializer = defaultRequestSerializer, + responseDeserializer = defaultResponseDeserializer, + querySerializer, + contentType = JsonContentType, + customPutPostRequest = false, + acceptPatch = false }: RestOptions) { + const initPromise = request(target, { + method: 'OPTION' + }).then((response) => response && response.getHeader('Accept-Patch')); + instanceStateMap.set(instance, { + target, + createIdTarget, + idSerializer, + requestSerializer, + responseDeserializer, + querySerializer, + acceptPatch, + contentType, + customPutPostRequest, + initPromise + }); + } +}); + +export default createRestStorage; diff --git a/src/store/createStore.ts b/src/store/createStore.ts index d224580..accc08f 100644 --- a/src/store/createStore.ts +++ b/src/store/createStore.ts @@ -7,7 +7,8 @@ import compose, { ComposeFactory } from 'dojo-compose/compose'; import { Observer, Observable } from 'dojo-core/Observable'; import { diff, Patch, PatchMapEntry } from '../patch/createPatch'; import _createStoreObservable, { StoreObservable } from './createStoreObservable'; -import createInMemoryStorage, { Storage } from '../storage/createInMemoryStorage'; +import createInMemoryStorage from '../storage/createInMemoryStorage'; +import { Storage } from '../storage/createBaseStorage'; export const enum StoreOperation { Add, diff --git a/tests/unit/all.ts b/tests/unit/all.ts index 74e9bf6..1de419c 100644 --- a/tests/unit/all.ts +++ b/tests/unit/all.ts @@ -6,6 +6,7 @@ import './query/createSort'; import './query/createStoreRange'; import './query/createCompoundQuery'; import './storage/createInMemoryStorage'; +import './storage/createRestStorage'; import './store/createStore'; import './store/mixins/createObservableStoreMixin'; import './store/mixins/createTransactionMixin'; diff --git a/tests/unit/patch/createOperation.ts b/tests/unit/patch/createOperation.ts index e7ac5e1..d70e817 100644 --- a/tests/unit/patch/createOperation.ts +++ b/tests/unit/patch/createOperation.ts @@ -1,6 +1,9 @@ import * as registerSuite from 'intern!object'; import * as assert from 'intern/chai!assert'; -import createOperation, { OperationType } from '../../../src/patch/createOperation'; +import createOperation, { + OperationType, isMove, isCopy, isReplace, isRemove, + isAdd, isTest +} from '../../../src/patch/createOperation'; registerSuite({ name: 'Operation', @@ -142,5 +145,20 @@ registerSuite({ const operation = createOperation(OperationType.Copy, ['prop1'], null, ['prop2']); assert.strictEqual(operation.toString(), '{"op":"copy","path":"prop1","from":"prop2"}'); + }, + 'Should identify operation type'(this: any) { + const move = createOperation(OperationType.Move, ['prop2'], null, ['prop1']); + const copy = createOperation(OperationType.Copy, ['prop2'], null, ['prop1']); + const replace = createOperation(OperationType.Replace, ['prop1'], 2); + const remove = createOperation(OperationType.Remove, ['prop1']); + const add = createOperation(OperationType.Add, ['prop1'], 1); + const test = createOperation(OperationType.Test, ['prop1'], 2); + + assert.isTrue(isMove(move), 'Didn\'t properly identify move operation'); + assert.isTrue(isCopy(copy), 'Didn\'t properly identify copy operation'); + assert.isTrue(isReplace(replace), 'Didn\'t properly identify replace operation'); + assert.isTrue(isRemove(remove), 'Didn\'t properly identify remove operation'); + assert.isTrue(isAdd(add), 'Didn\'t properly identify add operation'); + assert.isTrue(isTest(test), 'Didn\'t properly identify test operation'); } }); diff --git a/tests/unit/patch/createPatch.ts b/tests/unit/patch/createPatch.ts index 0c77266..403cb3e 100644 --- a/tests/unit/patch/createPatch.ts +++ b/tests/unit/patch/createPatch.ts @@ -1,12 +1,15 @@ import * as registerSuite from 'intern!object'; import * as assert from 'intern/chai!assert'; -import { diff } from '../../../src/patch/createPatch'; +import * as sinon from 'sinon'; +import createPatch, { diff } from '../../../src/patch/createPatch'; import { createData } from '../support/createData'; +import createOperation from '../../../src/patch/createOperation'; +import {OperationType} from '../../../src/patch/createOperation'; registerSuite({ name: 'Patch', - 'Should only works with objects'(this: any) { + 'Should only work with objects'(this: any) { const data = createData(); const patch = diff(data[1].id, data[0].id); assert.isTrue(patch.operations.length === 0, 'operations should not be created.'); @@ -90,5 +93,73 @@ registerSuite({ const result = patch.apply({}); assert.deepEqual(result, to, 'Should have made the object identical to the passed object'); + }, + + 'Should be serializable to JSON Merge Patch': { + 'from an empty object'(this: any) { + const data = createData(); + const to = data[0]; + const patch = diff(to); + + assert.strictEqual( + patch.toMergeString(), + '{"id":"1","value":1,"nestedProperty":{"value":3}}', + 'Didn\'t properly serialize to JSON Merge Patch format' + ); + }, + + 'from one object to another'(this: any) { + const data = createData(); + const to = data[0]; + const from = data[1]; + const patch = diff(to, from); + + assert.strictEqual( + patch.toMergeString(), + '{"id":"1","value":1,"nestedProperty":{"value":3}}', + 'Didn\'t properly serialize to JSON Merge Patch format' + ); + }, + + 'with removals'(this: any) { + const data = createData(); + const to = data[0]; + const from = { + 'removeThis': 'any value' + }; + const patch = diff(to, from); + + assert.strictEqual( + patch.toMergeString(), + '{"removeThis":null,"id":"1","value":1,"nestedProperty":{"value":3}}', + 'Didn\'t properly serialize to JSON Merge Patch format' + ); + }, + + 'with unsupported operations'(this: any) { + const patch = createPatch([ + createOperation(OperationType.Move, [ 'any' ], undefined, [ 'any' ]), + createOperation(OperationType.Copy, [ 'any' ], undefined, [ 'any' ]), + createOperation(OperationType.Test, [ 'any' ], undefined) + ]); + + const spy = sinon.stub(console, 'warn'); + + assert.strictEqual(patch.toMergeString(), '{}', 'Should have ignored unsupported operations'); + assert.isTrue( + spy.calledThrice, 'Should have warned on serialization of unsupported operations to JSON Merge Patch format' + ); + spy.restore(); + }, + + 'with operations with no path'(this: any) { + const patch = createPatch([ + createOperation(OperationType.Add, []), + createOperation(OperationType.Replace, []), + createOperation(OperationType.Remove, []) + ]); + + assert.strictEqual(patch.toMergeString(), '{}', 'Should have ignored unsupported operations'); + } } }); diff --git a/tests/unit/storage/createRestStorage.ts b/tests/unit/storage/createRestStorage.ts new file mode 100644 index 0000000..2df1c57 --- /dev/null +++ b/tests/unit/storage/createRestStorage.ts @@ -0,0 +1,825 @@ +import * as registerSuite from 'intern!object'; +import * as assert from 'intern/chai!assert'; +import Task from 'dojo-core/async/Task'; +import { Handle } from 'dojo-interfaces/core'; +import Map from 'dojo-shim/Map'; +import {providerRegistry, RequestOptions, Response, ResponsePromise } from 'dojo-core/request'; +import { StoreOperation } from '../../../src/store/createStore'; +import createRestStorage from '../../../src/storage/createRestStorage'; +import { Storage } from '../../../src/storage/createBaseStorage'; +import { createData, patches, patchedItems } from '../support/createData'; +import createSort, { Sort } from '../../../src/query/createSort'; +import createFilter, { Filter } from '../../../src/query/createFilter'; +import createRange, { StoreRange } from '../../../src/query/createStoreRange'; +import { Query } from '../../../src/query/interfaces'; +import createCompoundQuery from '../../../src/query/createCompoundQuery'; + +const handles: Handle[] = []; +const responses: Response[] = []; + +interface MockResponse { + response?: string; + headers?: { [index: string]: string }; +} +const emptyResponse: MockResponse = { response: undefined, headers: {} }; + +function registerMockProvider(pattern: string | RegExp, mockResponses: Map = new Map() ) { + return providerRegistry.register(pattern, function(url: string, options: RequestOptions): ResponsePromise { + const mockResponse = mockResponses.get(options.method || '') || emptyResponse; + if (mockResponse instanceof Error) { + return Task.reject(mockResponse); + } + else { + const response: Response = { + data: mockResponse.response, + nativeResponse: null, + requestOptions: options, + statusCode: 200, + statusText: 'mock status', + url, + getHeader(name: string) { + return (mockResponse && mockResponse.headers && mockResponse.headers[name]) || null; + } + }; + responses.push(response); + return > Task.resolve(response); + + } + }); +} + +let storage: Storage<{}, {}>; + +function clear() { + handles.forEach(function(handle) { + if (handle) { + handle.destroy(); + } + }); + handles.length = 0; + responses.length = 0; +} + +registerSuite({ + name: 'createRestStorage', + + afterEach: clear, + + beforeEach() { + handles.push(registerMockProvider(/\/mockItems.*$/)); + storage = createRestStorage({ + target: '/mockItems' + }); + }, + + 'GET': { + 'Should throw error when get called without ids'() { + return storage.get([]).catch(function(error) { + assert.include(error.message, 'ids can\'t be empty'); + }); + }, + 'method should be GET'() { + return storage.get(['1']).then(function() { + assert.strictEqual(responses[1].requestOptions.method, 'GET'); + }); + }, + 'get a single item should always follow REST style uri': { + + 'without idSerializer (default)'() { + return storage.get(['1']).then(function() { + assert.strictEqual(responses[1].url, '/mockItems/1'); + }); + }, + 'with idSerializer'() { + clear(); + handles.push(registerMockProvider(/\/mockItems.*$/)); + + const storage = createRestStorage({ + target: '/mockItems', + idSerializer: (ids) => `ids=${ids.join(',')}` + }); + + return storage.get(['1']).then(function() { + assert.strictEqual(responses[1].url, '/mockItems/1'); + }); + } + }, + 'get a single item should return an array'() { + clear(); + handles.push(registerMockProvider(/\/mockItems.*$/, new Map([ + ['GET', { response: JSON.stringify(createData()[0]) }] + ]))); + const storage = createRestStorage({ + target: '/mockItems' + }); + + return storage.get(['1']).then(function(items) { + assert.deepEqual(items, [createData()[0]]); + }); + }, + 'get multiple items without an idSerializer specified should send multiple standard requests'() { + return storage.get(['1', '2', '3']).then(function() { + assert.lengthOf(responses, 4); // 1 OPTION + 3 GET + assert.strictEqual(responses[1].url, '/mockItems/1'); + assert.strictEqual(responses[2].url, '/mockItems/2'); + assert.strictEqual(responses[3].url, '/mockItems/3'); + }); + }, + 'get multiple items with an idSerializer specified should send a single custom request'() { + clear(); + handles.push(registerMockProvider(/\/mockItems.*$/)); + const storage = createRestStorage({ + target: '/mockItems', + idSerializer: (ids) => `ids=${ids.join(',')}` + }); + + return storage.get(['1', '2', '3']).then(function() { + assert.lengthOf(responses, 2); // 1 OPTION + GET + assert.strictEqual(responses[1].url, '/mockItems?ids=1,2,3'); + }); + }, + + '"Accept" header in the request should be "application/json" by default'() { + return storage.get(['1', '2', '3']).then(function() { + assert.strictEqual(responses[1].requestOptions.headers!['Accept'], 'application/json'); + }); + }, + '"Accept" header in the request should be customizable'() { + clear(); + handles.push(registerMockProvider(/\/mockItems.*$/)); + const storage = createRestStorage({ + target: '/mockItems', + contentType: 'application/xml' + }); + + return storage.get(['1', '2', '3']).then(function() { + assert.strictEqual(responses[1].requestOptions.headers!['Accept'], 'application/xml'); + }); + }, + 'response should be deserialized by built in json deserializer by default'() { + clear(); + handles.push(registerMockProvider(/\/mockItems.*$/, new Map([ + ['GET', { + response: JSON.stringify(createData()) + }] + ]))); + const storage = createRestStorage({ + target: '/mockItems', + idSerializer: (ids) => `ids=${ids.join(',')}` + }); + + return storage.get(['1', '2', '3']).then(function(items) { + assert.deepEqual(items, createData()); + }); + } + }, + + 'PUT': { + 'Should throw error when get called without ids'() { + return storage.put([]).catch(function(error: any) { + assert.include(error.message, 'items can\'t be empty'); + }); + }, + 'method should be PUT when updating existing items(ids available)'() { + return storage.put([{ id: '1', foo: 'bar' }]).then(function() { + assert.strictEqual(responses[1].requestOptions.method, 'PUT'); + }); + }, + 'put a single item should always follow REST style uri.': { + + 'customPutPostRequest=false(default)'() { + return storage.put([createData()[0]]).then(function() { + assert.strictEqual(responses[1].url, '/mockItems/1'); + }); + }, + 'customPutPostRequest=true'() { + clear(); + handles.push(registerMockProvider(/\/mockItems.*$/)); + + const storage = createRestStorage({ + target: '/mockItems', + customPutPostRequest: true + }); + + return storage.put([createData()[0]]).then(function() { + assert.strictEqual(responses[1].url, '/mockItems/1'); + }); + } + }, + + 'put multiple items with `customPutPostRequest=true` should send a single custom request'() { + clear(); + handles.push(registerMockProvider(/\/mockItems.*$/)); + + const storage = createRestStorage({ + target: '/mockItems', + customPutPostRequest: true + }); + + return storage.put(createData()).then(function() { + assert.lengthOf(responses, 2); // OPTION, PUT(custom) + assert.strictEqual(responses[1].url, '/mockItems'); + }); + }, + 'put multiple items without `customPutPostRequest=true` should send multiple standard requests'() { + return storage.put(createData()).then(function() { + assert.lengthOf(responses, 4); // OPTION, 3 PUT + assert.strictEqual(responses[1].url, '/mockItems/1'); + assert.strictEqual(responses[2].url, '/mockItems/2'); + assert.strictEqual(responses[3].url, '/mockItems/3'); + }); + }, + 'return object should be of type PUT and have items updated'() { + return storage.put(createData()).then(function(result) { + assert.strictEqual(result.type, StoreOperation.Put); + assert.deepEqual(result.successfulData, createData()); + }); + }, + '"Accept" and "Content-Type" headers in the request should be "application/json" by default'() { + return storage.put(createData()).then(function() { + assert.strictEqual(responses[1].requestOptions.headers!['Accept'], 'application/json'); + assert.strictEqual(responses[1].requestOptions.headers!['Content-Type'], 'application/json'); + }); + }, + '"Accept" and "Content-Type" headers in the request should be customizable'() { + clear(); + handles.push(registerMockProvider(/\/mockItems.*$/)); + + const storage = createRestStorage({ + target: '/mockItems', + contentType: 'application/xml' + }); + + return storage.put(createData()).then(function() { + assert.strictEqual(responses[1].requestOptions.headers!['Accept'], 'application/xml'); + assert.strictEqual(responses[1].requestOptions.headers!['Content-Type'], 'application/xml'); + }); + }, + + 'request should be serialized by built in json serializer by default': { + 'single item should be serialized as an object'() { + return storage.put([createData()[0]]).then(function() { + assert.strictEqual(responses[1].requestOptions.data, JSON.stringify(createData()[0])); + }); + }, + 'multiple items with `customPutPostRequest=true` should be serialized as array'() { + clear(); + handles.push(registerMockProvider(/\/mockItems.*$/)); + + const storage = createRestStorage({ + target: '/mockItems', + customPutPostRequest: true + }); + + return storage.put(createData()).then(function() { + assert.strictEqual(responses[1].requestOptions.data, JSON.stringify(createData())); + }); + } + }, + 'response should be serialized by custom serializer if available'() { + clear(); + handles.push(registerMockProvider(/\/mockItems.*$/)); + + const storage = createRestStorage({ + target: '/mockItems', + customPutPostRequest: true, + requestSerializer: (items: {}[]) => JSON.stringify(items[0]) + }); + + return storage.put(createData()).then(function() { + assert.strictEqual(responses[1].requestOptions.data, JSON.stringify(createData()[0])); + }); + } + }, + + 'add(POST)': { + 'Should throw error when get called without ids'() { + return storage.add([]).catch(function(error: any) { + assert.include(error.message, 'items can\'t be empty'); + }); + }, + 'method should be POST when adding new items(ids not available)'() { + return storage.add([{ foo: 'bar'}]).then(function() { + assert.strictEqual(responses[1].requestOptions.method, 'POST'); + }); + }, + 'add a single item should not append ids in the uri'() { + return storage.add([{foo: 'bar'}]).then(function() { + assert.strictEqual(responses[1].url, '/mockItems'); + }); + }, + + 'add multiple items should not append ids in the uri'() { + const items = [{foo: 'bar1'}, {foo: 'bar2'}]; + + return storage.add(items).then(function() { + assert.strictEqual(responses[1].url, '/mockItems'); + }); + }, + 'return object should be of type ADD and have items added'() { + const items = [{foo: 'bar1'}, {foo: 'bar2'}]; + + return storage.add(items).then(function(result) { + assert.strictEqual(result.type, StoreOperation.Add); + assert.deepEqual(result.successfulData, items); + }); + } + }, + + 'DELETE': { + 'Should throw error when get called without ids'() { + return storage.delete([]).catch(function(error: any) { + assert.include(error.message, 'ids can\'t be empty'); + }); + }, + 'method should be DELETE'() { + return storage.delete(['1']).then(function() { + assert.strictEqual(responses[1].requestOptions.method, 'DELETE'); + }); + }, + 'delete a single item should follow REST style uri'() { + return storage.delete(['1']).then(function() { + assert.strictEqual(responses[1].url, '/mockItems/1'); + }); + }, + 'delete a single item should always follow REST style uri': { + + 'without idSerializer (default)'() { + return storage.delete(['1']).then(function() { + assert.strictEqual(responses[1].url, '/mockItems/1'); + }); + }, + 'with idSerializer'() { + clear(); + handles.push(registerMockProvider(/\/mockItems.*$/)); + + const storage = createRestStorage({ + target: '/mockItems', + idSerializer: (ids) => `ids=${ids.join(',')}` + }); + + return storage.delete(['1']).then(function() { + assert.strictEqual(responses[1].url, '/mockItems/1'); + }); + } + }, + 'delete multiple items without an idSerializer specified should send multiple standard requests'() { + return storage.delete(['1', '2', '3']).then(function() { + assert.lengthOf(responses, 4); // 1 OPTION + 3 DELETE + assert.strictEqual(responses[1].url, '/mockItems/1'); + assert.strictEqual(responses[2].url, '/mockItems/2'); + assert.strictEqual(responses[3].url, '/mockItems/3'); + }); + }, + 'delete multiple items with an idSerializer specified should send a single custom request'() { + clear(); + handles.push(registerMockProvider(/\/mockItems.*$/)); + const storage = createRestStorage({ + target: '/mockItems', + idSerializer: (ids) => `ids=${ids.join(',')}` + }); + + return storage.delete(['1', '2', '3']).then(function() { + assert.lengthOf(responses, 2); // 1 OPTION + DELETE + assert.strictEqual(responses[1].url, '/mockItems?ids=1,2,3'); + }); + }, + 'return object should be of type DELETE and have items ids to be deleted'() { + return storage.delete(['1', '2', '3']).then(function(result) { + assert.strictEqual(result.type, StoreOperation.Delete); + assert.deepEqual(result.successfulData, ['1', '2', '3']); + }); + } + }, + + 'PATCH': { + 'Should throw error when get called without ids'() { + return storage.patch([]).catch(function(error: any) { + assert.include(error.message, 'updates can\'t be empty'); + }); + }, + 'Native Patch(remote operation, for single item only)': { + 'should use JSON Patch if it is specified in the options'() { + clear(); + handles.push(registerMockProvider(/\/mockItems.*$/)); + + const storage = createRestStorage({ + target: '/mockItems', + acceptPatch: 'application/json-patch+json' + }); + + return storage.patch([patches[0]]).then(function() { + assert.strictEqual(responses[1].requestOptions.data, patches[0].patch.toString()); + }); + }, + 'method should be PATCH for JSON Patch'() { + clear(); + handles.push(registerMockProvider(/\/mockItems.*$/)); + + const storage = createRestStorage({ + target: '/mockItems', + acceptPatch: 'application/json-patch+json' + }); + + return storage.patch([patches[0]]).then(function() { + assert.strictEqual(responses[1].requestOptions.method, 'PATCH'); + }); + }, + 'uri should be REST style for JSON Patch'() { + clear(); + handles.push(registerMockProvider(/\/mockItems.*$/)); + + const storage = createRestStorage({ + target: '/mockItems', + acceptPatch: 'application/json-patch+json' + }); + + return storage.patch([patches[0]]).then(function() { + assert.strictEqual(responses[1].url, '/mockItems/1'); + }); + }, + 'should use JSON Merge Patch if it is specified in the options'(this: any) { + clear(); + handles.push(registerMockProvider(/\/mockItems.*$/)); + + const storage = createRestStorage({ + target: '/mockItems', + acceptPatch: 'application/merge-patch+json' + }); + + return storage.patch([patches[0]]).then(function() { + assert.strictEqual(responses[1].requestOptions.data, patches[0].patch.toMergeString()); + }); + }, + 'method should be PATCH for JSON Merge Patch'(this: any) { + clear(); + handles.push(registerMockProvider(/\/mockItems.*$/)); + + const storage = createRestStorage({ + target: '/mockItems', + acceptPatch: 'application/merge-patch+json' + }); + + return storage.patch([patches[0]]).then(function() { + assert.strictEqual(responses[1].requestOptions.method, 'PATCH'); + }); + }, + 'uri should be REST style for JSON Merge Patch'(this: any) { + clear(); + handles.push(registerMockProvider(/\/mockItems.*$/)); + + const storage = createRestStorage({ + target: '/mockItems', + acceptPatch: 'application/merge-patch+json' + }); + + return storage.patch([patches[0]]).then(function() { + assert.strictEqual(responses[1].url, '/mockItems/1'); + }); + }, + 'should take the first supported contentType in the options if there are more than one'(this: any) { + clear(); + handles.push(registerMockProvider(/\/mockItems.*$/)); + + const storage = createRestStorage({ + target: '/mockItems', + acceptPatch: 'text/xml, application/json-patch+json, application/merge-patch+json' + }); + + return storage.patch([patches[0]]).then(function() { + assert.strictEqual(responses[1].requestOptions.data, patches[0].patch.toString(), 'should have taken Json Patch'); + }); + }, + 'should take the first supported contentType in the "Accept-Patch" header if there are more than one'(this: any) { + clear(); + handles.push(registerMockProvider(/\/mockItems.*$/, new Map([ + ['OPTION', { headers: { 'Accept-Patch': 'text/xml, application/json-patch+json, application/merge-patch+json' } } ] + ]))); + + const storage = createRestStorage({ + target: '/mockItems' + }); + + return storage.patch([patches[0]]).then(function() { + assert.strictEqual(responses[1].requestOptions.data, patches[0].patch.toString(), 'should have taken Json Patch'); + }); + }, + 'acceptPatch in the options should take precedence over the one in the "Accept-Patch" header if both are available'() { + clear(); + handles.push(registerMockProvider(/\/mockItems.*$/, new Map([ + ['OPTION', { headers: { 'Accept-Patch': 'application/merge-patch+json' } } ] + ]))); + const storage = createRestStorage({ + target: '/mockItems', + acceptPatch: 'application/json-patch+json' + }); + + return storage.patch([patches[0]]).then(function() { + assert.strictEqual(responses[1].requestOptions.data, patches[0].patch.toString(), 'should have taken Json Patch'); + }); + }, + 'should make a GET request to retreive patched item'() { + clear(); + handles.push(registerMockProvider(/\/mockItems.*$/, new Map([ + ['GET', { response: JSON.stringify(patchedItems[0] ) }] + ]))); + + const storage = createRestStorage({ + target: '/mockItems', + acceptPatch: 'application/json-patch+json' + }); + + return storage.patch([patches[0]]).then(function() { + assert.lengthOf(responses, 3); // OPTION, PATCH, GET + assert.strictEqual(responses[2].requestOptions.method, 'GET'); + }); + }, + 'return object should be of type PATCH and have items(array) patched'() { + clear(); + handles.push(registerMockProvider(/\/mockItems.*$/, new Map([ + ['GET', { response: JSON.stringify(patchedItems[0] ) }] + ]))); + + const storage = createRestStorage({ + target: '/mockItems', + acceptPatch: 'application/json-patch+json' + }); + + return storage.patch([patches[0]]).then(function(result) { + assert.strictEqual(result.type, StoreOperation.Patch); + assert.deepEqual(result.successfulData, [patchedItems[0]]); + }); + } + }, + 'Local Patch(GET + PUT)': { + 'patch a single item should fall back to local patch approach when acceptPatch is not found in either options or headers'() { + clear(); + handles.push(registerMockProvider(/\/mockItems.*$/, new Map([ + ['GET', { response: JSON.stringify(createData()[0]) } ] + ]))); + const storage = createRestStorage({ + target: '/mockItems', + acceptPatch: 'application/non-supported-format' + }); + + return storage.patch([patches[0]]).then(function(result) { + assert.lengthOf(responses, 3); // OPTION, GET, PUT + assert.strictEqual(responses[1].requestOptions.method, 'GET'); + assert.strictEqual(responses[2].requestOptions.method, 'PUT'); + assert.strictEqual(result.type, StoreOperation.Patch); + assert.deepEqual(result.successfulData, [patchedItems[0]]); + }); + }, + + 'patch a single item should fall back to local patch approach when acceptPatch is not found in options and initPromise errors out'() { + clear(); + handles.push(registerMockProvider(/\/mockItems.*$/, new Map([ + ['OPTION', new Error('')], + ['GET', { response: JSON.stringify(createData()[0]) } ] + ]))); + const storage = createRestStorage({ + target: '/mockItems' + }); + + return storage.patch([patches[0]]).then(function(result) { + assert.lengthOf(responses, 2); // GET, PUT, (the inital OPTION errors out) + assert.strictEqual(responses[0].requestOptions.method, 'GET'); + assert.strictEqual(responses[1].requestOptions.method, 'PUT'); + assert.strictEqual(result.type, StoreOperation.Patch); + assert.deepEqual(result.successfulData, [patchedItems[0]]); + }); + }, + 'patch multiple items should use local patch approach regardless of acceptPatch'() { + clear(); + handles.push(registerMockProvider(/\/mockItems.*$/, new Map([ + ['GET', { response: JSON.stringify(createData()) } ] + ]))); + const storage = createRestStorage({ + target: '/mockItems', + acceptPatch: 'application/json-patch+json', + idSerializer: (ids) => `ids=${ids.join(',')}`, + customPutPostRequest: true + }); + + return storage.patch(patches).then(function(result) { + assert.lengthOf(responses, 3); // OPTION, GET, PUT + assert.strictEqual(responses[1].requestOptions.method, 'GET'); + assert.strictEqual(responses[2].requestOptions.method, 'PUT'); + assert.strictEqual(result.type, StoreOperation.Patch); + assert.deepEqual(result.successfulData, patchedItems); + }); + } + } + }, + + 'fetch': { + 'without a query': { + 'method should be GET'() { + return storage.fetch().then(function() { + assert.strictEqual(responses[1].requestOptions.method, 'GET'); + }); + }, + 'fetch without a query should not append to the uri'() { + return storage.fetch().then(function() { + assert.strictEqual(responses[1].url, '/mockItems'); + }); + }, + + '"Accept" header in the request should be "application/json" by default'() { + return storage.fetch().then(function() { + assert.strictEqual(responses[1].requestOptions.headers!['Accept'], 'application/json'); + }); + }, + '"Accept" header in the request should be customizable'() { + clear(); + handles.push(registerMockProvider(/\/mockItems.*$/)); + const storage = createRestStorage({ + target: '/mockItems', + contentType: 'application/xml' + }); + + return storage.fetch().then(function() { + assert.strictEqual(responses[1].requestOptions.headers!['Accept'], 'application/xml'); + }); + }, + 'response should be deserialized by built in json deserializer by default'() { + clear(); + handles.push(registerMockProvider(/\/mockItems.*$/, new Map([ + ['GET', { + response: JSON.stringify(createData()) + }] + ]))); + const storage = createRestStorage({ + target: '/mockItems' + }); + + return storage.fetch().then(function(items) { + assert.deepEqual(items, createData()); + }); + }, + 'response should be deserialized by custom deserializer if available'() { + clear(); + handles.push(registerMockProvider(/\/mockItems.*$/, new Map([ + ['GET', { + response: JSON.stringify(createData()) + }] + ]))); + const storage = createRestStorage({ + target: '/mockItems', + responseDeserializer: (response) => JSON.parse(response)[0] + }); + + return storage.fetch().then(function(items) { + assert.deepEqual(items, createData()[0]); + }); + } + }, + 'with queries': { + 'sort should be serialized by built in RQL serializer by default'() { + const sort = createSort('key1'); + return storage.fetch(sort).then(function() { + assert.strictEqual(responses[1].url, '/mockItems?Sort(key1, +)'); + }); + }, + 'sort should be serialized by custom serializer if available'() { + clear(); + handles.push(registerMockProvider(/\/mockItems.*$/)); + const storage = createRestStorage({ + target: '/mockItems', + querySerializer: (sort: Sort<{}>) => `sortParam(${sort.comparatorOrProperty}, ${sort.descending ? 'desc' : 'asc'})` + }); + + const sort = createSort('key1'); + return storage.fetch(sort).then(function() { + assert.strictEqual(responses[1].url, '/mockItems?sortParam(key1, asc)'); + }); + }, + 'filter should be serialized by built in RQL serializer by default'() { + const filter = createFilter().lessThan('key1', 5); + return storage.fetch(filter).then(function() { + assert.strictEqual(responses[1].url, '/mockItems?lt(key1, 5)'); + }); + }, + 'filter should be serialized by custom serializer if available'() { + clear(); + handles.push(registerMockProvider(/\/mockItems.*$/)); + const storage = createRestStorage({ + target: '/mockItems', + querySerializer: (filter: Filter<{}>) => 'fooFilter' + }); + + const filter = createFilter().lessThan('key1', 5); + return storage.fetch(filter).then(function() { + assert.strictEqual(responses[1].url, '/mockItems?fooFilter'); + }); + }, + 'range should be serialized by built in RQL serializer by default'() { + const range = createRange(1, 2); + return storage.fetch(range).then(function() { + assert.strictEqual(responses[1].url, '/mockItems?range(1, 2)'); + }); + }, + 'range should be serialized by custom serializer if available'() { + clear(); + handles.push(registerMockProvider(/\/mockItems.*$/)); + const storage = createRestStorage({ + target: '/mockItems', + querySerializer: (range: StoreRange<{}>) => `rangeParam(${range.start}, ${range.start + range.count})` + }); + + const range = createRange(1, 2); + return storage.fetch(range).then(function() { + assert.strictEqual(responses[1].url, '/mockItems?rangeParam(1, 3)'); + }); + }, + 'compound query should be serialized by built in RQL serializer by default'() { + const query = createCompoundQuery({ + query: createFilter().lessThan('key1', 3) + }) + .withQuery( createSort('key1', true) ) + .withQuery( createRange(1, 2) ); + + return storage.fetch(query).then(function() { + assert.strictEqual(responses[1].url, '/mockItems?lt(key1, 3)&Sort(key1, -)&range(1, 2)'); + }); + }, + 'compound query should be serialized by custom serializer if available'() { + clear(); + handles.push(registerMockProvider(/\/mockItems.*$/)); + const storage = createRestStorage({ + target: '/mockItems', + querySerializer: (query: Query<{}>) => 'fooQuery' + }); + + const query = createCompoundQuery({ + query: createFilter().lessThan('key1', 3) + }) + .withQuery( createSort('key1', true) ) + .withQuery( createRange(1, 2) ); + + return storage.fetch(query).then(function() { + assert.strictEqual(responses[1].url, '/mockItems?fooQuery'); + }); + } + } + }, + + 'Miscellaneous': { + 'createId should not be available by default'() { + return storage.createId().catch(function(error) { + assert.include(error.message, 'createId is not supported'); + }); + }, + 'createId should be available through a separate endpoint if provided'() { + clear(); + handles.push(registerMockProvider(/\/mockItems.*$/)); + handles.push(registerMockProvider('/mockIds', new Map([ + ['GET', { response: '2'}] + ]))); + + const storage = createRestStorage({ + target: '/mockItems', + createIdTarget: '/mockIds' + }); + + const promise = storage.createId().then(function(id) { + assert.strictEqual(responses[1].requestOptions.method, 'GET'); + assert.strictEqual(id, '2'); + }); + + return promise; + }, + 'isUpdate should be false when item is not found'() { + storage.isUpdate(createData()[0]).then(function(result) { + assert.isFalse(result.isUpdate); + }); + }, + 'isUpdate should be true when item is found'() { + clear(); + handles.push(registerMockProvider(/\/mockItems.*$/, new Map([ + ['GET', { response: JSON.stringify([createData()[0]]) }] + ]))); + const storage = createRestStorage({ + target: '/mockItems' + }); + storage.isUpdate(createData()[0]).then(function(result) { + assert.isTrue(result.isUpdate); + }); + }, + + 'should propagate error to storage promise'(this: any) { + const dfd = this.async(); + clear(); + handles.push(registerMockProvider(/\/mockItems.*$/, new Map([ + ['GET', Error('Get request error')] + ]))); + + const storage = createRestStorage({ + target: '/mockItems' + }); + return storage.get(['any']).then(dfd.reject, (error) => { + assert.strictEqual(error.message, 'Get request error', 'Didn\'t properly propagate request error'); + }); + } + } +});