From 83d31a00ca0fa30ee292854838e871d43475b2f6 Mon Sep 17 00:00:00 2001 From: Liang Zhou Date: Wed, 9 Nov 2016 09:55:26 -0600 Subject: [PATCH 1/5] Refactor createInMemoryStorage. * Split a separate BaseStorage * Move non InMemory specific interfaces into BaseStorage for reuse. Initial version of Rest Storage. Implement patch APIs. Implement fetch API. Add unit tests for most rest operations. Fix issues found. Add unit tests for fetch and patch. Clean up and refactoring. Add initPromise errors out scenario, bring coverages to 100%. Add missing WeakMap and switch `dojo-shim/array#find` Feedback changes. - Make `idSerializer` not default to a built in one, and add the ability to send multiple GET/DELETE requests when it is not in the option. - Add the option `customPutPostRequest` with the ability to send multiple PUT/POST requests. - Update/Add unit tests accordingly. Feedback changes. Add needed export for dist and use mixin object structure instead of compsoe factories --- src/patch/createPatch.ts | 2 +- src/storage/createBaseStorage.ts | 69 ++ src/storage/createInMemoryStorage.ts | 309 ++++----- src/storage/createRestStorage.ts | 301 +++++++++ src/store/createStore.ts | 1 + tests/unit/all.ts | 1 + tests/unit/storage/createRestStorage.ts | 813 ++++++++++++++++++++++++ 7 files changed, 1322 insertions(+), 174 deletions(-) create mode 100644 src/storage/createBaseStorage.ts create mode 100644 src/storage/createRestStorage.ts create mode 100644 tests/unit/storage/createRestStorage.ts diff --git a/src/patch/createPatch.ts b/src/patch/createPatch.ts index 21bbcf8..a150b14 100644 --- a/src/patch/createPatch.ts +++ b/src/patch/createPatch.ts @@ -4,7 +4,7 @@ import createJsonPointer, { JsonPointer } from './createJsonPointer'; export interface Patch { operations: Operation[]; apply(target: T): U; - toString(): String; + toString(): string; } export type PatchMapEntry = { id: string; patch: Patch }; diff --git a/src/storage/createBaseStorage.ts b/src/storage/createBaseStorage.ts new file mode 100644 index 0000000..1e1d3c2 --- /dev/null +++ b/src/storage/createBaseStorage.ts @@ -0,0 +1,69 @@ +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(): 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..d513cc9 --- /dev/null +++ b/src/storage/createRestStorage.ts @@ -0,0 +1,301 @@ +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<{}, T>) => string; + acceptPatch?: false | string; + contentType?: string; + customPutPostRequest?: boolean; +} + +function getRequest(uri: string, contentType: string, responseDeserializer: (items: string) => T[]) { + return toPromise(request.get(uri, { + headers: { + 'Accept': contentType + } + })).then(responseDeserializer); +} + +function toPromise(responsePromise: ResponsePromise, defaultValue?: T): Promise { + return new Promise((resolve, reject) => responsePromise.then( + (response) => { + if (response && response.data) { + resolve(response.data); + } + else if (defaultValue) { + resolve(defaultValue); + } + else { + reject(Error('No value returned')); + } + }, + (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<{}, T>) => 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); +} + +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()) { + // TODO: dependency - merge patch is not implemented yet + // 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 new Promise((resolve, reject) => request.get(state.createIdTarget!).then( + (response) => (response && response.data) || '', (error) => { + reject(error); + return ''; + } + )); + } + else { + return Promise.reject(new Error('createId is not supported in a rest store.')); + } + }, + + fetch(this: Storage<{}, {}>, query?: Query<{}, V>): Promise { + const state = instanceStateMap.get(this); + let uri = state.target; + if (query) { + uri += '?' + query.toString(state.querySerializer); + } + return getRequest(uri, state.contentType, state.responseDeserializer); + }, + + 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.contentType, + state.responseDeserializer + ))); + } + else { + return getRequest( + state.target + `?${state.idSerializer(ids)}`, + state.contentType, state.responseDeserializer + ); + } + }, + + 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..d73219c 100644 --- a/src/store/createStore.ts +++ b/src/store/createStore.ts @@ -8,6 +8,7 @@ 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 { 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/storage/createRestStorage.ts b/tests/unit/storage/createRestStorage.ts new file mode 100644 index 0000000..05bb1ae --- /dev/null +++ b/tests/unit/storage/createRestStorage.ts @@ -0,0 +1,813 @@ +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) { + this.skip('Json Merge Patch is not implemented in "patch/Patch" yet'); + 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.toString()); + }); + }, + 'method should be PATCH for JSON Merge Patch'(this: any) { + this.skip('Json Merge Patch is not implemented in "patch/Patch" yet'); + 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) { + this.skip('Json Merge Patch is not implemented in "patch/Patch" yet'); + 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); + }); + } + } +}); From b16c7f762dea71590100d47301b4dd6bf283b2d0 Mon Sep 17 00:00:00 2001 From: Bradley Maier Date: Wed, 7 Dec 2016 01:57:38 -0500 Subject: [PATCH 2/5] Fix errors caused by rebase and improve test coverage --- src/storage/createBaseStorage.ts | 3 +- src/storage/createRestStorage.ts | 43 ++++++++----------------- src/store/createStore.ts | 2 +- tests/unit/storage/createRestStorage.ts | 17 +++++++++- 4 files changed, 32 insertions(+), 33 deletions(-) diff --git a/src/storage/createBaseStorage.ts b/src/storage/createBaseStorage.ts index 1e1d3c2..84bb4c1 100644 --- a/src/storage/createBaseStorage.ts +++ b/src/storage/createBaseStorage.ts @@ -22,8 +22,7 @@ export interface BaseStorage { export interface Storage extends BaseStorage { createId(): Promise; - fetch(): Promise; - fetch(query?: Query): Promise; + fetch(query?: Query): Promise; get(ids: string[]): Promise; put(items: T[], options?: O): Promise>; add(items: T[], options?: O): Promise>; diff --git a/src/storage/createRestStorage.ts b/src/storage/createRestStorage.ts index d513cc9..ca283b1 100644 --- a/src/storage/createRestStorage.ts +++ b/src/storage/createRestStorage.ts @@ -11,20 +11,20 @@ export interface RestOptions extends StoreOptions string; - requestSerializer?: (items: T| T[]) => string; + requestSerializer?: (items: T | T[]) => string; responseDeserializer?: (response: string) => T[]; - querySerializer?: (query: Query<{}, T>) => string; + querySerializer?: (query: Query) => string; acceptPatch?: false | string; contentType?: string; customPutPostRequest?: boolean; } -function getRequest(uri: string, contentType: string, responseDeserializer: (items: string) => T[]) { +function getRequest(uri: string, state: RestState) { return toPromise(request.get(uri, { headers: { - 'Accept': contentType + 'Accept': state.contentType } - })).then(responseDeserializer); + })).then((data) => state.responseDeserializer(data)); } function toPromise(responsePromise: ResponsePromise, defaultValue?: T): Promise { @@ -33,11 +33,8 @@ function toPromise(responsePromise: ResponsePromise, defaultValue?: T): Pr if (response && response.data) { resolve(response.data); } - else if (defaultValue) { - resolve(defaultValue); - } else { - reject(Error('No value returned')); + resolve(defaultValue); } }, (error) => { @@ -55,7 +52,7 @@ export interface RestState { idSerializer?: (ids: string[]) => string; requestSerializer: (items: T | T[]) => string; responseDeserializer: (response: string) => T[]; - querySerializer?: (query: Query<{}, T>) => string; + querySerializer?: (query: Query) => string; acceptPatch: false | string; contentType: string; customPutPostRequest: boolean; @@ -66,8 +63,8 @@ function defaultRequestSerializer(items: T | T[]): string { return JSON.stringify(items); } -function defaultResponseDeserializer(response: string): T[] { - return JSON.parse(response); +function defaultResponseDeserializer(response?: string): T[] { + return JSON.parse(response || 'null'); } const instanceStateMap = new WeakMap, RestState<{}>>(); @@ -101,25 +98,20 @@ const createRestStorage: RestStorageFactory = createBaseStorage.mixin({ createId(this: Storage<{}, {}>): Promise { const state = instanceStateMap.get(this); if (state.createIdTarget) { - return new Promise((resolve, reject) => request.get(state.createIdTarget!).then( - (response) => (response && response.data) || '', (error) => { - reject(error); - return ''; - } - )); + 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<{}, V>): Promise { + 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.contentType, state.responseDeserializer); + return getRequest(uri, state); }, get(this: Storage<{}, {}>, ids: string[]): Promise<{}[]> { @@ -129,17 +121,10 @@ const createRestStorage: RestStorageFactory = createBaseStorage.mixin({ const state = instanceStateMap.get(this); if (ids.length === 1 || !state.idSerializer) { - return Promise.all(ids.map((id) => getRequest( - state.target + `/${id}`, - state.contentType, - state.responseDeserializer - ))); + return Promise.all(ids.map((id) => getRequest(state.target + `/${id}`, state))); } else { - return getRequest( - state.target + `?${state.idSerializer(ids)}`, - state.contentType, state.responseDeserializer - ); + return getRequest(state.target + `?${state.idSerializer(ids)}`, state); } }, diff --git a/src/store/createStore.ts b/src/store/createStore.ts index d73219c..accc08f 100644 --- a/src/store/createStore.ts +++ b/src/store/createStore.ts @@ -7,7 +7,7 @@ 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 { diff --git a/tests/unit/storage/createRestStorage.ts b/tests/unit/storage/createRestStorage.ts index 05bb1ae..82529b8 100644 --- a/tests/unit/storage/createRestStorage.ts +++ b/tests/unit/storage/createRestStorage.ts @@ -751,7 +751,7 @@ registerSuite({ handles.push(registerMockProvider(/\/mockItems.*$/)); const storage = createRestStorage({ target: '/mockItems', - querySerializer: (query: Query<{}, {}>) => 'fooQuery' + querySerializer: (query: Query<{}>) => 'fooQuery' }); const query = createCompoundQuery({ @@ -808,6 +808,21 @@ registerSuite({ 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'); + }); } } }); From d75923a25709b057f075dbc20cedba761dceadaf Mon Sep 17 00:00:00 2001 From: Bradley Maier Date: Mon, 12 Dec 2016 14:03:05 -0500 Subject: [PATCH 3/5] Remove spaces inside of function invocation --- src/storage/createBaseStorage.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/storage/createBaseStorage.ts b/src/storage/createBaseStorage.ts index 84bb4c1..e0eee56 100644 --- a/src/storage/createBaseStorage.ts +++ b/src/storage/createBaseStorage.ts @@ -47,13 +47,13 @@ const createBaseStorage = compose, StoreOptions<{}, CrudOp const state = instanceStateMap.get(this); const itemArray = Array.isArray(items) ? items : [ items ]; if (state.idProperty) { - return itemArray.map( (item) => item[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 ); + return itemArray.map((item) => item.id); } } From c77f093ac6c6dc4317eb72c175481f0c704ebc76 Mon Sep 17 00:00:00 2001 From: Bradley Maier Date: Wed, 14 Dec 2016 02:57:35 -0500 Subject: [PATCH 4/5] Add JSON Merge Patch support --- src/patch/createOperation.ts | 24 ++++++++ src/patch/createPatch.ts | 31 +++++++++- src/storage/createRestStorage.ts | 3 +- tests/unit/patch/createPatch.ts | 75 ++++++++++++++++++++++++- tests/unit/storage/createRestStorage.ts | 5 +- 5 files changed, 129 insertions(+), 9 deletions(-) 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 a150b14..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; + 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/createRestStorage.ts b/src/storage/createRestStorage.ts index ca283b1..af70e07 100644 --- a/src/storage/createRestStorage.ts +++ b/src/storage/createRestStorage.ts @@ -86,8 +86,7 @@ function getPatchSerializer(acceptPatch: string | false): ((patch: Patch<{}, {}> return (patch: Patch<{}, {}>) => patch.toString(); } else if (MergePatchContentType === contentType.trim()) { - // TODO: dependency - merge patch is not implemented yet - // return (patch: Patch<{}, {}>) => patch.toMergeString(); + return (patch: Patch<{}, {}>) => patch.toMergeString(); } } return undefined; 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 index 82529b8..2df1c57 100644 --- a/tests/unit/storage/createRestStorage.ts +++ b/tests/unit/storage/createRestStorage.ts @@ -439,7 +439,6 @@ registerSuite({ }); }, 'should use JSON Merge Patch if it is specified in the options'(this: any) { - this.skip('Json Merge Patch is not implemented in "patch/Patch" yet'); clear(); handles.push(registerMockProvider(/\/mockItems.*$/)); @@ -449,11 +448,10 @@ registerSuite({ }); return storage.patch([patches[0]]).then(function() { - assert.strictEqual(responses[1].requestOptions.data, patches[0].patch.toString()); + assert.strictEqual(responses[1].requestOptions.data, patches[0].patch.toMergeString()); }); }, 'method should be PATCH for JSON Merge Patch'(this: any) { - this.skip('Json Merge Patch is not implemented in "patch/Patch" yet'); clear(); handles.push(registerMockProvider(/\/mockItems.*$/)); @@ -467,7 +465,6 @@ registerSuite({ }); }, 'uri should be REST style for JSON Merge Patch'(this: any) { - this.skip('Json Merge Patch is not implemented in "patch/Patch" yet'); clear(); handles.push(registerMockProvider(/\/mockItems.*$/)); From 176d5d529a5dece52430d1f63460299b2bc49259 Mon Sep 17 00:00:00 2001 From: Bradley Maier Date: Wed, 14 Dec 2016 12:00:40 -0500 Subject: [PATCH 5/5] Test operation identification functions --- tests/unit/patch/createOperation.ts | 20 +++++++++++++++++++- 1 file changed, 19 insertions(+), 1 deletion(-) 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'); } });