diff --git a/.gitignore b/.gitignore index 0061462..9cc5c84 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,3 @@ -lib/ coverage/ node_modules/ flow-typed/ @@ -6,3 +5,4 @@ flow-typed/ *.log *.swp *.rpt2* +.idea diff --git a/README.md b/README.md index 662b320..ab54d57 100644 --- a/README.md +++ b/README.md @@ -16,6 +16,7 @@ REST conventions for mobx. - [Model](#model) - [Collection](#collection) - [apiClient](#apiclient) + - [modelMapper](#modelMapper) - [Simple Example](#simple-example) - [State shape](#state-shape) - [FAQ](#faq) @@ -604,6 +605,29 @@ All options: * **headers**: Additional request headers, like `Authorization` * **tbd.** +### `modelMapper` +You may need to use different kind of models while sending request and using in mobx-rest +There are currently one implementation: + + - One using `Basic` and `Auto` mappers in the [mobx-rest-auto-mapper-adapter](https://github.com/emrahtoy/mobx-rest-auto-mapper-adapter) package. + +For example, if you're using an api takes and returns different kind of models or you are using view model different than api model, you use username for view and name for api request, it could look like this: + +``` +import modelMapper from "mobx-rest"; +import { BasicModelMapper } from "./mobx-rest-auto-mapper-adapter"; + + modelMapper(new BasicModelMapper()); + +const modelMap=[['username','name']]; + +class User extends Model {} // I assume you create proper model here. + +let user = new User({username:"Emrah TOY"},{username:"Emrah TOY"},modelMap); +user.save() // will send {name:"Emrah TOY"} to given api endpoint. username -> name +``` +Please, visit model mappers githup repo for much more complicated examples. + ## Simple Example A collection looks like this: diff --git a/__tests__/Model.spec.ts b/__tests__/Model.spec.ts index 67bc1b6..266e758 100644 --- a/__tests__/Model.spec.ts +++ b/__tests__/Model.spec.ts @@ -2,17 +2,19 @@ import Collection from '../src/Collection' import MockApi from './mocks/api' import Model from '../src/Model' import apiClient from '../src/apiClient' -import { isObservable } from 'mobx' -import { strMapToObj } from './utils' +import {isObservable} from 'mobx' +import {strMapToObj} from './utils' +import modelMapper from "../src/modelMapper"; +import BasicModelMapper from "./mocks/modelMapper"; apiClient(MockApi) class MockCollection extends Collection { - url (): string { + url(): string { return '/users' } - model (): typeof Model { + model(): typeof Model { return Model } } @@ -50,7 +52,7 @@ describe(Model, () => { const model = new Model({ firstName: 'John', lastName: 'Doe' - }, { email: null, phone: null }) + }, {email: null, phone: null}) expect(model.toJS()).toEqual({ firstName: 'John', @@ -68,11 +70,11 @@ describe(Model, () => { describe('toJS()', () => { it('returns a plain object version of the attributes', () => { - const model = new Model({ name: 'John' }) + const model = new Model({name: 'John'}) expect(isObservable(model.attributes)).toBe(true) expect(isObservable(model.toJS())).toBe(false) - expect(model.toJS()).toEqual({ name: 'John' }) + expect(model.toJS()).toEqual({name: 'John'}) }) }) @@ -87,7 +89,7 @@ describe(Model, () => { describe('if the model is not new', () => { it('returns the url root with the model id', () => { - const model = new MyModel({ id: 2 }) + const model = new MyModel({id: 2}) expect(model.url()).toBe('/resources/2') }) @@ -95,7 +97,7 @@ describe(Model, () => { describe('if the model belongs to a collection and urlRoot is not defined', () => { it('uses the collection url as root', () => { - const model = new Model({ id: 2 }) + const model = new Model({id: 2}) const collection = new MockCollection() collection.url = () => '/different-resources' @@ -107,7 +109,7 @@ describe(Model, () => { describe('if the model doesn\'t belong to a collection and urlRoot is not defined', () => { it('throws', () => { - const model = new Model({ id: 2 }) + const model = new Model({id: 2}) expect(() => model.url()).toThrow('implement `urlRoot` method or `url` on the collection') }) @@ -117,7 +119,7 @@ describe(Model, () => { describe('get(attribute)', () => { describe('if the attribute is defined', () => { it('returns its value', () => { - const model = new Model({ name: 'John' }) + const model = new Model({name: 'John'}) expect(model.get('name')).toBe('John') }) @@ -125,7 +127,7 @@ describe(Model, () => { describe('if the attribute is not defined', () => { it('throws', () => { - const model = new Model({ name: 'John' }) + const model = new Model({name: 'John'}) expect(() => model.get('email')).toThrow('Attribute "email" not found') }) @@ -135,7 +137,7 @@ describe(Model, () => { describe('has(attribute)', () => { describe('if the attribute is defined', () => { it('returns true', () => { - const model = new Model({ name: 'John' }) + const model = new Model({name: 'John'}) expect(model.has('name')).toBe(true) }) @@ -143,7 +145,7 @@ describe(Model, () => { describe('if the attribute is not defined', () => { it('returns false', () => { - const model = new Model({ name: 'John' }) + const model = new Model({name: 'John'}) expect(model.has('email')).toBe(false) }) @@ -154,7 +156,7 @@ describe(Model, () => { describe('if the primary key attribute exists', () => { describe('if the primary key attribute has a value', () => { it('returns false', () => { - const model = new Model({ id: 123 }) + const model = new Model({id: 123}) expect(model.isNew).toBe(false) }) @@ -162,8 +164,8 @@ describe(Model, () => { describe('if the primary key attribute is null or undefined', () => { it('returns true', () => { - const model1 = new Model({ id: null }) - const model2 = new Model({ id: undefined }) + const model1 = new Model({id: null}) + const model2 = new Model({id: undefined}) expect(model1.isNew).toBe(true) expect(model2.isNew).toBe(true) @@ -183,19 +185,19 @@ describe(Model, () => { describe('id', () => { describe('if the model has an id attribute', () => { it('returns its value', () => { - const model = new Model({ id: 123 }) + const model = new Model({id: 123}) expect(model.id).toBe(123) }) it('allows to customize the primary key attribute', () => { class MyModel extends Model { - get primaryKey () { + get primaryKey() { return 'someId' } } - const model = new MyModel({ someId: 123 }) + const model = new MyModel({someId: 123}) expect(model.id).toBe(123) }) @@ -243,7 +245,7 @@ describe(Model, () => { phone: '123456789' }) - model.set({ name: 'Name 2' }) + model.set({name: 'Name 2'}) expect(model.hasChanges('name')).toBe(true) }) @@ -255,7 +257,7 @@ describe(Model, () => { phone: '123456789' }) - model.set({ name: 'Name 2' }) + model.set({name: 'Name 2'}) expect(model.hasChanges('date')).toBe(false) }) @@ -268,7 +270,7 @@ describe(Model, () => { phone: '123456789' }) - model.set({ name: 'Name 2' }) + model.set({name: 'Name 2'}) expect(model.hasChanges()).toBe(true) }) @@ -337,9 +339,9 @@ describe(Model, () => { describe('commitChanges()', () => { it('accepts the current changes', () => { - const model = new Model({ phone: '1234' }) + const model = new Model({phone: '1234'}) - model.set({ phone: '5678' }) + model.set({phone: '5678'}) expect(model.hasChanges()).toBe(true) model.commitChanges() @@ -349,7 +351,7 @@ describe(Model, () => { it('makes a copy of the current attributes', () => { const model = new Model({ - nested: { phone: '1234' } + nested: {phone: '1234'} }) expect(model.attributes.get('nested')).not.toBe(model.committedAttributes.get('nested')) @@ -358,9 +360,9 @@ describe(Model, () => { describe('discardChanges()', () => { it('reverts to the last committed attributes', () => { - const model = new Model({ phone: '1234' }) + const model = new Model({phone: '1234'}) - model.set({ phone: '5678' }) + model.set({phone: '5678'}) expect(model.hasChanges()).toBe(true) model.discardChanges() @@ -374,15 +376,15 @@ describe(Model, () => { it('replaces the current attributes with the specified ones', () => { const model = new Model() - model.reset({ hi: 'bye' }) + model.reset({hi: 'bye'}) - expect(model.toJS()).toEqual({ hi: 'bye' }) + expect(model.toJS()).toEqual({hi: 'bye'}) }) it('respects the default attributes', () => { - const model = new Model({ name: 'john' }, { someAttribute: 'test' }) + const model = new Model({name: 'john'}, {someAttribute: 'test'}) - model.reset({ phone: '1234567' }) + model.reset({phone: '1234567'}) expect(model.toJS()).toEqual({ someAttribute: 'test', @@ -393,11 +395,11 @@ describe(Model, () => { describe('if attributes is not specified', () => { it('replaces the current attributes with the default ones', () => { - const model = new Model({ email: 'test@test.com' }, { someAttribute: 'test' }) + const model = new Model({email: 'test@test.com'}, {someAttribute: 'test'}) model.reset() - expect(model.toJS()).toEqual({ someAttribute: 'test' }) + expect(model.toJS()).toEqual({someAttribute: 'test'}) }) }) }) @@ -428,7 +430,7 @@ describe(Model, () => { let model beforeEach(() => { - model = new Model({ id: 2 }) + model = new Model({id: 2}) model.urlRoot = () => '/resources' spy = jest.spyOn(apiClient(), 'get') promise = model.fetch({ @@ -461,14 +463,14 @@ describe(Model, () => { describe('if the request succeeds', () => { it('merges the current data with the response', async () => { - model.set({ last_name: 'Doe' }) - MockApi.resolvePromise({ id: 2, name: 'John' }) + model.set({last_name: 'Doe'}) + MockApi.resolvePromise({id: 2, name: 'John'}) await promise - expect(model.toJS()).toEqual({ id: 2, name: 'John', last_name: 'Doe' }) + expect(model.toJS()).toEqual({id: 2, name: 'John', last_name: 'Doe'}) }) it('sets the new attributes as committed', async () => { - MockApi.resolvePromise({ id: 2, name: 'John' }) + MockApi.resolvePromise({id: 2, name: 'John'}) await promise @@ -492,7 +494,7 @@ describe(Model, () => { let model beforeEach(() => { - model = new Model({ name: 'John', email: 'john@test.com', phone: '1234' }) + model = new Model({name: 'John', email: 'john@test.com', phone: '1234'}) model.urlRoot = () => '/resources' }) @@ -508,11 +510,11 @@ describe(Model, () => { describe('and is New', () => { describe('and it succeeds saving', () => { it('adds it to the collection', async () => { - const promise = model.save({ name: 'Paco' }) + const promise = model.save({name: 'Paco'}) expect(collection.at(0).get('name')).toEqual('Paco') - MockApi.resolvePromise({ id: 999, name: 'Merlo' }) + MockApi.resolvePromise({id: 999, name: 'Merlo'}) try { await promise @@ -524,7 +526,7 @@ describe(Model, () => { describe('and it fails saving', () => { it('removes it from the collection', async () => { - const promise = model.save({ name: 'Paco' }) + const promise = model.save({name: 'Paco'}) expect(collection.at(0).get('name')).toEqual('Paco') @@ -540,15 +542,17 @@ describe(Model, () => { }) describe('and is not New', () => { - beforeEach(() => { model.set({ id: 999 }) }) + beforeEach(() => { + model.set({id: 999}) + }) describe('and it succeeds saving', () => { it('it adds it to the collection', async () => { - const promise = model.save({ name: 'Paco' }) + const promise = model.save({name: 'Paco'}) expect(collection.at(0).get('name')).toEqual('Paco') - MockApi.resolvePromise({ id: 999, name: 'Merlo' }) + MockApi.resolvePromise({id: 999, name: 'Merlo'}) try { await promise @@ -560,7 +564,7 @@ describe(Model, () => { describe('and it fails saving', () => { it('does not remove it from the collection', async () => { - const promise = model.save({ name: 'Paco' }) + const promise = model.save({name: 'Paco'}) expect(collection.at(0).get('name')).toEqual('Paco') @@ -580,12 +584,12 @@ describe(Model, () => { describe('and is New', () => { describe('and it succeeds saving', () => { it('it adds it to the collection', async () => { - const promise = model.save({ name: 'Paco' }, { optimistic: false }) + const promise = model.save({name: 'Paco'}, {optimistic: false}) expect(collection.length).toEqual(0) expect(model.get('name')).toEqual('John') - MockApi.resolvePromise({ id: 999, name: 'Merlo' }) + MockApi.resolvePromise({id: 999, name: 'Merlo'}) try { await promise @@ -597,7 +601,7 @@ describe(Model, () => { describe('and it fails saving', () => { it('removes it from the collection', async () => { - const promise = model.save({ name: 'Paco' }, { optimistic: false }) + const promise = model.save({name: 'Paco'}, {optimistic: false}) expect(collection.length).toEqual(0) expect(model.get('name')).toEqual('John') @@ -615,16 +619,18 @@ describe(Model, () => { }) describe('and is not New', () => { - beforeEach(() => { model.set({ id: 999 }) }) + beforeEach(() => { + model.set({id: 999}) + }) describe('and it succeeds saving', () => { it('it adds it to the collection', async () => { - const promise = model.save({ name: 'Paco' }, { optimistic: false }) + const promise = model.save({name: 'Paco'}, {optimistic: false}) expect(collection.length).toEqual(0) expect(model.get('name')).toEqual('John') - MockApi.resolvePromise({ id: 999, name: 'Merlo' }) + MockApi.resolvePromise({id: 999, name: 'Merlo'}) try { await promise @@ -636,7 +642,7 @@ describe(Model, () => { describe('and it fails saving', () => { it('removes it from the collection', async () => { - const promise = model.save({ name: 'Paco' }, { optimistic: false }) + const promise = model.save({name: 'Paco'}, {optimistic: false}) expect(collection.length).toEqual(0) expect(model.get('name')).toEqual('John') @@ -688,7 +694,7 @@ describe(Model, () => { describe('if attributes are specified', () => { it('sends merges the attributes with the current ones', () => { - model.save({ phone: '5678' }) + model.save({phone: '5678'}) expect(spy.mock.calls[0][1]).toEqual({ name: 'John', @@ -699,7 +705,7 @@ describe(Model, () => { describe('if optimistic', () => { it('immediately assigns the merged attributes', () => { - model.save({ phone: '5678' }, { optimistic: true }) + model.save({phone: '5678'}, {optimistic: true}) expect(model.toJS()).toEqual({ name: 'John', @@ -713,7 +719,7 @@ describe(Model, () => { describe('if is not new', () => { beforeEach(() => { - model.set({ id: 2 }) + model.set({id: 2}) model.commitChanges() }) @@ -733,8 +739,8 @@ describe(Model, () => { describe('if attributes are not specified', () => { it('sends the changes compared to the current attributes', () => { - model.set({ phone: '5678' }) - model.save(null, { patch: true }) + model.set({phone: '5678'}) + model.save(null, {patch: true}) expect(spy.mock.calls[0][1]).toEqual({ phone: '5678' @@ -744,7 +750,7 @@ describe(Model, () => { describe('if attributes are specified', () => { it('sends specified attributes', () => { - model.save({ phone: '5678' }, { patch: true }) + model.save({phone: '5678'}, {patch: true}) expect(spy.mock.calls[0][1]).toEqual({ phone: '5678' @@ -753,7 +759,7 @@ describe(Model, () => { describe('if optimistic', () => { it('immediately assigns the merged attributes', () => { - model.save({ phone: '5678' }, { optimistic: true, patch: true }) + model.save({phone: '5678'}, {optimistic: true, patch: true}) expect(model.toJS()).toEqual({ id: 2, @@ -776,13 +782,13 @@ describe(Model, () => { afterEach(() => spy.mockRestore()) it('sends a PUT request', () => { - model.save({}, { patch: false }) + model.save({}, {patch: false}) expect(spy).toHaveBeenCalled() }) describe('if attributes are not specified', () => { it('sends the current attributes', () => { - model.save({}, { patch: false }) + model.save({}, {patch: false}) expect(spy.mock.calls[0][1]).toEqual({ id: 2, @@ -795,7 +801,7 @@ describe(Model, () => { describe('if attributes are specified', () => { it('sends merges the attributes with the current ones', () => { - model.save({ phone: '5678' }, { patch: false }) + model.save({phone: '5678'}, {patch: false}) expect(spy.mock.calls[0][1]).toEqual({ id: 2, @@ -807,7 +813,7 @@ describe(Model, () => { describe('if optimistic', () => { it('immediately assigns the merged attributes', () => { - model.save({ phone: '5678' }, { optimistic: true, patch: false }) + model.save({phone: '5678'}, {optimistic: true, patch: false}) expect(model.toJS()).toEqual({ id: 2, @@ -831,7 +837,7 @@ describe(Model, () => { afterEach(() => spy.mockRestore()) it('they must be passed to the adapter', () => { - model.save(undefined, { method: 'HEAD' }) + model.save(undefined, {method: 'HEAD'}) expect(spy).toHaveBeenCalledWith(expect.anything(), expect.anything(), { method: 'HEAD', @@ -842,7 +848,7 @@ describe(Model, () => { describe('if the request succeeds', () => { it('assigns the response attributes to the model', async () => { - const promise = model.save({ phone: '5678' }, { optimistic: false }) + const promise = model.save({phone: '5678'}, {optimistic: false}) expect(model.toJS()).toEqual({ name: 'John', @@ -868,7 +874,7 @@ describe(Model, () => { }) it('sets the new attributes as committed', async () => { - const promise = model.save({ phone: '5678' }) + const promise = model.save({phone: '5678'}) expect(strMapToObj(model.committedAttributes.toJS())).toEqual({ name: 'John', @@ -896,9 +902,9 @@ describe(Model, () => { describe('if changes were made during the request', () => { describe('if keepChanges = false', () => { it('should override the changes with the response', async () => { - const promise = model.save({ phone: '5678' }, { keepChanges: false }) + const promise = model.save({phone: '5678'}, {keepChanges: false}) - model.set({ phone: '999' }) + model.set({phone: '999'}) MockApi.resolvePromise({ id: 2, @@ -920,9 +926,9 @@ describe(Model, () => { describe('if keepChanges = true', () => { it('should keep the changes', async () => { - const promise = model.save({ phone: '5678' }, { keepChanges: true }) + const promise = model.save({phone: '5678'}, {keepChanges: true}) - model.set({ phone: '999' }) + model.set({phone: '999'}) MockApi.resolvePromise({ id: 2, @@ -972,7 +978,7 @@ describe(Model, () => { number: 2222 } } - }, { keepChanges: true }) + }, {keepChanges: true}) model.get('addresses').address2.number = 4444 @@ -1028,7 +1034,7 @@ describe(Model, () => { const promise = model.save({ numbers: [3, 4, 5] - }, { keepChanges: true }) + }, {keepChanges: true}) model.get('numbers')[0] = 6 @@ -1059,7 +1065,7 @@ describe(Model, () => { describe('if the request fails', () => { describe('if optimistic', () => { it('goes back to the original attributes', async () => { - const promise = model.save({ phone: '5678' }, { optimistic: true }) + const promise = model.save({phone: '5678'}, {optimistic: true}) expect(model.toJS()).toEqual({ name: 'John', @@ -1120,7 +1126,7 @@ describe(Model, () => { describe('if is not new', () => { beforeEach(() => { - model.set({ id: 2 }) + model.set({id: 2}) model.commitChanges() }) @@ -1140,7 +1146,7 @@ describe(Model, () => { it('immediately removes itself from the collection', () => { expect(collection.length).toBe(1) - model.destroy({ optimistic: true }) + model.destroy({optimistic: true}) expect(collection.length).toBe(0) }) }) @@ -1153,7 +1159,7 @@ describe(Model, () => { model.collection = collection collection.models.push(model) - const promise = model.destroy({ optimistic: false }) + const promise = model.destroy({optimistic: false}) expect(collection.length).toBe(1) @@ -1169,7 +1175,7 @@ describe(Model, () => { model.collection = new MockCollection() model.collection.models.push(model) - const promise = model.destroy({ optimistic: true }) + const promise = model.destroy({optimistic: true}) MockApi.resolvePromise({}) @@ -1180,7 +1186,7 @@ describe(Model, () => { describe('if other options are specified', () => { it('they must be passed to the adapter', () => { - model.destroy({ method: 'OPTIONS' }) + model.destroy({method: 'OPTIONS'}) expect(spy.mock.calls[0][2]).toEqual({ method: 'OPTIONS' @@ -1196,7 +1202,7 @@ describe(Model, () => { model.collection = collection collection.models.push(model) - const promise = model.destroy({ optimistic: true }) + const promise = model.destroy({optimistic: true}) expect(collection.length).toBe(0) @@ -1215,7 +1221,7 @@ describe(Model, () => { model.collection = new MockCollection() model.collection.models.push(model) - const promise = model.destroy({ optimistic: false }) + const promise = model.destroy({optimistic: false}) MockApi.rejectPromise('Conflict') @@ -1229,4 +1235,51 @@ describe(Model, () => { }) }) }) + + describe('mapToApi test', () => { + let model + + beforeEach(() => { + model = new Model({username: "Emrah TOY"}) + model.urlRoot = () => '/mapperTest' + model.modelMap = [ + ["username", "user"] + ] + }) + + afterEach(() => { + model.modelMap = [ + ["username", "user"] + ] + modelMapper(undefined,true); + }); + + describe('when it has no mapper adapter and it should fail', () => { + it('throws error because there is no model map', () => { + expect(() => { + model.modelMap = []; + modelMapper(new BasicModelMapper()); + return model.toApiObject(true); + }).toThrow('Undefined model map'); + }) + }) + + describe('when it has no mapper adapter and it should fail', () => { + it('throws error because there is no model mapper', () => { + expect(() => { + return model.toApiObject(true); + }).toThrow('You must set an model mapper adapter first!'); + }) + }) + + describe('when model has both map and mapper and it success', () => { + it("returns mapped object", () => { + modelMapper(new BasicModelMapper()); + const mappedModel = model.toApiObject() + expect(mappedModel).toMatchObject({user: "Emrah TOY"}) + }) + }) + + }) + }) diff --git a/__tests__/mocks/modelMapper.ts b/__tests__/mocks/modelMapper.ts new file mode 100644 index 0000000..b6e692c --- /dev/null +++ b/__tests__/mocks/modelMapper.ts @@ -0,0 +1,20 @@ +import { ModelMapperAdapter } from "../../src/types"; + +export default class BasicModelMapper implements ModelMapperAdapter { + modelToApi(model: { [index: string]: any }, map: any[][]): object { + let result: { [index: string]: any } = {}; + map.forEach((value, key) => { + result[value[1]] = model[value[0]]; + }); + return result; + } + + apiToModel(apiModel: { [index: string]: any }, map: any[][], ModelClass?: { new(): T; }): T { + let model: { [index: string]: any } = {}; + map.forEach((value, key) => { + model[value[2] || value[0]] = apiModel[value[1]]; + }); + let result :any = (ModelClass) ? new ModelClass() : new Object(); + return Object.assign(result,model); + } +} diff --git a/__tests__/modeMapperAdapter.spec.ts b/__tests__/modeMapperAdapter.spec.ts new file mode 100644 index 0000000..fc91790 --- /dev/null +++ b/__tests__/modeMapperAdapter.spec.ts @@ -0,0 +1,62 @@ +import modelMapper from '../src/modelMapper' +import BasicModelMapper from './mocks/modelMapper' + +let model = { + modelFirstProperty: "mfp", + modelSecondProperty: "msp", + modelAnotherProperty: "map" +}; + +let apiModel = { + apiFirstProperty: 'afp', + apiSecondProperty: 'asp' +}; + +class ApiToModelType{ + modelFirstProperty: string; + modelSecondProperty: string; + modelAnotherProperty: string; +} + +let mapping = [ + ['modelFirstProperty', 'apiFirstProperty'], + ['modelFirstProperty', 'apiFirstProperty', 'modelAnotherProperty'], + ['modelSecondProperty', 'apiSecondProperty'] +]; + +describe(modelMapper, () => { + describe('if not initialized', () => { + it('throws', () => { + expect(() => modelMapper()).toThrow('You must set an model mapper adapter first!') + }) + }) + + describe('model to api test', () => { + test('returns', () => { + let r = modelMapper(new BasicModelMapper()).modelToApi(model, mapping); + expect(r).toMatchObject({ + apiFirstProperty: "mfp", + apiSecondProperty: "msp" + }); + }) + }) + + describe('api to model test with given type', () => { + test('returns ApiToModelType', () => { + let r: ApiToModelType; + r = modelMapper(new BasicModelMapper()).apiToModel(apiModel, mapping, ApiToModelType); + console.warn(r); + expect(r).toBeInstanceOf(ApiToModelType); + }) + }) + + describe('api to model test default Object Type', () => { + test('returns Object', () => { + let r: Object; + r = modelMapper(new BasicModelMapper()).apiToModel(apiModel, mapping); + console.warn(r); + expect(r).toBeInstanceOf(Object); + }) + }) + +}) diff --git a/lib/Base.d.ts b/lib/Base.d.ts new file mode 100644 index 0000000..3fdc531 --- /dev/null +++ b/lib/Base.d.ts @@ -0,0 +1,28 @@ +import Request from './Request'; +import { IObservableArray } from 'mobx'; +export default class Base { + request: Request | null; + requests: IObservableArray; + /** + * Returns the resource's url. + * + * @abstract + */ + url(): string; + withRequest(labels: string | Array, promise: Promise, abort: () => void | null): Request; + getRequest(label: string): Request | null; + getAllRequests(label: string): Array; + /** + * Questions whether the request exists + * and matches a certain label + */ + isRequest(label: string): boolean; + /** + * Call an RPC action for all those + * non-REST endpoints that you may have in + * your API. + */ + rpc(endpoint: string | { + rootUrl: string; + }, options?: {}, label?: string): Request; +} diff --git a/lib/Collection.d.ts b/lib/Collection.d.ts new file mode 100644 index 0000000..1128eba --- /dev/null +++ b/lib/Collection.d.ts @@ -0,0 +1,162 @@ +import Base from './Base'; +import Model from './Model'; +import Request from './Request'; +import { IObservableArray } from 'mobx'; +import { CreateOptions, SetOptions, GetOptions, FindOptions, Id } from './types'; +declare type IndexTree = Map>; +declare type Index = Map>; +export default abstract class Collection extends Base { + models: IObservableArray; + indexes: Array; + constructor(data?: Array<{ + [key: string]: any; + }>); + /** + * Define which is the primary key + * of the model's in the collection. + * + * FIXME: This contains a hack to use the `primaryKey` as + * an instance method. Ideally it should be static but that + * would not be backward compatible and Typescript sucks at + * static polymorphism (https://github.com/microsoft/TypeScript/issues/5863). + */ + readonly primaryKey: string; + /** + * Returns a hash with all the indexes for that + * collection. + * + * We keep the indexes in memory for as long as the + * collection is alive, even if no one is referencing it. + * This way we can ensure to calculate it only once. + */ + readonly index: IndexTree; + /** + * Alias for models.length + */ + readonly length: Number; + /** + * Alias for models.map + */ + map

(callback: (model: T) => P): Array

; + /** + * Alias for models.forEach + */ + forEach(callback: (model: T) => void): void; + /** + * Returns the URL where the model's resource would be located on the server. + */ + abstract url(): string; + /** + * Specifies the model class for that collection + */ + abstract model(attributes?: { + [key: string]: any; + }): new (attributes?: { + [key: string]: any; + }) => T | null; + /** + * Returns a JSON representation + * of the collection + */ + toJS(): Array<{ + [key: string]: any; + }>; + /** + * Alias of slice + */ + toArray(): Array; + /** + * Returns a defensive shallow array representation + * of the collection + */ + slice(): Array; + /** + * Wether the collection is empty + */ + readonly isEmpty: boolean; + /** + * Gets the ids of all the items in the collection + */ + private readonly _ids; + /** + * Get a resource at a given position + */ + at(index: number): T | null; + /** + * Get a resource with the given id or uuid + */ + get(id: Id, { required }?: GetOptions): T; + /** + * Get a resource with the given id or uuid or fail loudly. + */ + mustGet(id: Id): T; + /** + * Get resources matching criteria. + * + * If passing an object of key:value conditions, it will + * use the indexes to efficiently retrieve the data. + */ + filter(query: { + [key: string]: any; + } | ((T: any) => boolean)): Array; + /** + * Finds an element with the given matcher + */ + find(query: { + [key: string]: any; + } | ((T: any) => boolean), { required }?: FindOptions): T | null; + /** + * Get a resource with the given id or uuid or fails loudly. + */ + mustFind(query: { + [key: string]: any; + } | ((T: any) => boolean)): T; + /** + * Adds a model or collection of models. + */ + add(data: Array<{ + [key: string]: any; + } | T> | { + [key: string]: any; + } | T): Array; + /** + * Resets the collection of models. + */ + reset(data: Array<{ + [key: string]: any; + }>): void; + /** + * Removes the model with the given ids or uuids + */ + remove(ids: Id | T | Array): void; + /** + * Sets the resources into the collection. + * + * You can disable adding, changing or removing. + */ + set(resources: Array<{ + [key: string]: any; + } | T>, { add, change, remove }?: SetOptions): void; + /** + * Creates a new model instance with the given attributes + */ + build(attributes?: Object | T): T; + /** + * Creates the model and saves it on the backend + * + * The default behaviour is optimistic but this + * can be tuned. + */ + create(attributesOrModel: { + [key: string]: any; + } | T, { optimistic }?: CreateOptions): Request; + /** + * Fetches the models from the backend. + * + * It uses `set` internally so you can + * use the options to disable adding, changing + * or removing. + */ + fetch({ data, ...otherOptions }?: SetOptions): Request; +} +export {}; diff --git a/lib/ErrorObject.d.ts b/lib/ErrorObject.d.ts new file mode 100644 index 0000000..e053473 --- /dev/null +++ b/lib/ErrorObject.d.ts @@ -0,0 +1,9 @@ +export default class ErrorObject { + error: any; + payload: any; + requestResponse: any; + constructor(error: { + requestResponse: any; + error: any; + } | string | Error); +} diff --git a/lib/Model.d.ts b/lib/Model.d.ts new file mode 100644 index 0000000..0e2a94f --- /dev/null +++ b/lib/Model.d.ts @@ -0,0 +1,119 @@ +import { ObservableMap } from 'mobx'; +import Base from './Base'; +import Collection from './Collection'; +import Request from './Request'; +import { OptimisticId, Id, DestroyOptions, SaveOptions } from './types'; +declare type Attributes = { + [key: string]: any; +}; +export declare const DEFAULT_PRIMARY = "id"; +export default class Model extends Base { + defaultAttributes: Attributes; + attributes: ObservableMap; + committedAttributes: ObservableMap; + optimisticId: OptimisticId; + collection: Collection | null; + modelMap: any[][]; + constructor(attributes?: Attributes, defaultAttributes?: Attributes, modelMap?: any[][]); + /** + * Returns a JSON representation + * of the model + */ + toJS(): ObservableMap; + /** + * Define which is the primary + * key of the model. + */ + readonly primaryKey: string; + /** + * Return the base url used in + * the `url` method + * + * @abstract + */ + urlRoot(): string | null; + /** + * Return the url for this given REST resource + */ + url(): string; + /** + * Wether the resource is new or not + * + * We determine this asking if it contains + * the `primaryKey` attribute (set by the server). + */ + readonly isNew: boolean; + /** + * Get the attribute from the model. + * + * Since we want to be sure changes on + * the schema don't fail silently we + * throw an error if the field does not + * exist. + * + * If you want to deal with flexible schemas + * use `has` to check wether the field + * exists. + */ + get(attribute: string): any; + /** + * Returns whether the given field exists + * for the model. + */ + has(attribute: string): boolean; + /** + * Get an id from the model. It will use either + * the backend assigned one or the client. + */ + readonly id: Id; + /** + * Get an array with the attributes names that have changed. + */ + readonly changedAttributes: Array; + /** + * Gets the current changes. + */ + readonly changes: { + [key: string]: any; + }; + /** + * If an attribute is specified, returns true if it has changes. + * If no attribute is specified, returns true if any attribute has changes. + */ + hasChanges(attribute?: string): boolean; + commitChanges(): void; + discardChanges(): void; + /** + * Replace all attributes with new data + */ + reset(data?: {}): void; + /** + * Merge the given attributes with + * the current ones + */ + set(data: {}): void; + /** + * Fetches the model from the backend. + */ + fetch({ data, ...otherOptions }?: { + data?: {}; + }): Request; + /** + * Saves the resource on the backend. + * + * If the item has a `primaryKey` it updates it, + * otherwise it creates the new resource. + * + * It supports optimistic and patch updates. + */ + save(attributes?: {}, { optimistic, patch, keepChanges, ...otherOptions }?: SaveOptions): Request; + /** + * Destroys the resurce on the client and + * requests the backend to delete it there + * too + */ + destroy({ data, optimistic, ...otherOptions }?: DestroyOptions): Request; + toApiObject(throwException?: boolean): {}; + toModelObject(data: {}, throwException?: boolean): {}; +} +export {}; diff --git a/lib/Request.d.ts b/lib/Request.d.ts new file mode 100644 index 0000000..bf55d33 --- /dev/null +++ b/lib/Request.d.ts @@ -0,0 +1,10 @@ +import { RequestOptions, RequestState } from './types'; +export default class Request { + labels: Array; + abort: () => void | null; + promise: Promise; + progress: number | null; + state: RequestState; + constructor(promise: Promise, { labels, abort, progress }?: RequestOptions); + then(onFulfilled: (any: any) => Promise, onRejected?: (any: any) => Promise): Promise; +} diff --git a/lib/apiClient.d.ts b/lib/apiClient.d.ts new file mode 100644 index 0000000..19984e9 --- /dev/null +++ b/lib/apiClient.d.ts @@ -0,0 +1,7 @@ +import { Adapter } from './types'; +/** + * Sets or gets the api client instance + */ +export default function apiClient(adapter?: Adapter, options?: { + [key: string]: any; +}): Adapter; diff --git a/lib/index.d.ts b/lib/index.d.ts new file mode 100644 index 0000000..6a9f391 --- /dev/null +++ b/lib/index.d.ts @@ -0,0 +1,6 @@ +import Collection from './Collection'; +import Model from './Model'; +import Request from './Request'; +import ErrorObject from './ErrorObject'; +import apiClient from './apiClient'; +export { Collection, Model, apiClient, Request, ErrorObject }; diff --git a/lib/index.js b/lib/index.js new file mode 100644 index 0000000..89bbb23 --- /dev/null +++ b/lib/index.js @@ -0,0 +1,1012 @@ +'use strict'; + +Object.defineProperty(exports, '__esModule', { value: true }); + +function _interopDefault (ex) { return (ex && (typeof ex === 'object') && 'default' in ex) ? ex['default'] : ex; } + +var mobx = require('mobx'); +var includes = _interopDefault(require('lodash/includes')); +var isObject = _interopDefault(require('lodash/isObject')); +var debounce = _interopDefault(require('lodash/debounce')); +var isEqual = _interopDefault(require('lodash/isEqual')); +var isPlainObject = _interopDefault(require('lodash/isPlainObject')); +var union = _interopDefault(require('lodash/union')); +var uniqueId = _interopDefault(require('lodash/uniqueId')); +var deepmerge = _interopDefault(require('deepmerge')); +var difference = _interopDefault(require('lodash/difference')); +var intersection = _interopDefault(require('lodash/intersection')); +var entries = _interopDefault(require('lodash/entries')); +var compact = _interopDefault(require('lodash/compact')); + +/*! ***************************************************************************** +Copyright (c) Microsoft Corporation. All rights reserved. +Licensed under the Apache License, Version 2.0 (the "License"); you may not use +this file except in compliance with the License. You may obtain a copy of the +License at http://www.apache.org/licenses/LICENSE-2.0 + +THIS CODE IS PROVIDED ON AN *AS IS* BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +KIND, EITHER EXPRESS OR IMPLIED, INCLUDING WITHOUT LIMITATION ANY IMPLIED +WARRANTIES OR CONDITIONS OF TITLE, FITNESS FOR A PARTICULAR PURPOSE, +MERCHANTABLITY OR NON-INFRINGEMENT. + +See the Apache Version 2.0 License for specific language governing permissions +and limitations under the License. +***************************************************************************** */ +/* global Reflect, Promise */ + +var extendStatics = function(d, b) { + extendStatics = Object.setPrototypeOf || + ({ __proto__: [] } instanceof Array && function (d, b) { d.__proto__ = b; }) || + function (d, b) { for (var p in b) if (b.hasOwnProperty(p)) d[p] = b[p]; }; + return extendStatics(d, b); +}; + +function __extends(d, b) { + extendStatics(d, b); + function __() { this.constructor = d; } + d.prototype = b === null ? Object.create(b) : (__.prototype = b.prototype, new __()); +} + +var __assign = function() { + __assign = Object.assign || function __assign(t) { + for (var s, i = 1, n = arguments.length; i < n; i++) { + s = arguments[i]; + for (var p in s) if (Object.prototype.hasOwnProperty.call(s, p)) t[p] = s[p]; + } + return t; + }; + return __assign.apply(this, arguments); +}; + +function __rest(s, e) { + var t = {}; + for (var p in s) if (Object.prototype.hasOwnProperty.call(s, p) && e.indexOf(p) < 0) + t[p] = s[p]; + if (s != null && typeof Object.getOwnPropertySymbols === "function") + for (var i = 0, p = Object.getOwnPropertySymbols(s); i < p.length; i++) if (e.indexOf(p[i]) < 0) + t[p[i]] = s[p[i]]; + return t; +} + +function __decorate(decorators, target, key, desc) { + var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d; + if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc); + else for (var i = decorators.length - 1; i >= 0; i--) if (d = decorators[i]) r = (c < 3 ? d(r) : c > 3 ? d(target, key, r) : d(target, key)) || r; + return c > 3 && r && Object.defineProperty(target, key, r), r; +} + +var ErrorObject = /** @class */ (function () { + function ErrorObject(error) { + this.error = null; + this.payload = {}; + this.requestResponse = null; + if (error instanceof Error) { + console.error(error); + this.requestResponse = null; + this.error = error; + } + else if (typeof error === 'string') { + this.requestResponse = null; + this.error = error; + } + else if (error.requestResponse || error.error) { + this.requestResponse = error.requestResponse; + this.error = error.error; + } + else { + this.payload = error; + } + } + return ErrorObject; +}()); + +var Request = /** @class */ (function () { + function Request(promise, _a) { + var _this = this; + var _b = _a === void 0 ? {} : _a, labels = _b.labels, abort = _b.abort, _c = _b.progress, progress = _c === void 0 ? 0 : _c; + this.state = 'pending'; + this.labels = labels; + this.abort = abort; + this.progress = progress = 0; + this.promise = promise; + this.promise + .then(function () { _this.state = 'fulfilled'; }) + .catch(function () { _this.state = 'rejected'; }); + } + // This allows to use async/await on the request object + Request.prototype.then = function (onFulfilled, onRejected) { + return this.promise.then(function (data) { return onFulfilled(data || {}); }, onRejected); + }; + __decorate([ + mobx.observable + ], Request.prototype, "progress", void 0); + __decorate([ + mobx.observable + ], Request.prototype, "state", void 0); + return Request; +}()); + +var currentAdapter; +/** + * Sets or gets the api client instance + */ +function apiClient(adapter, options) { + if (options === void 0) { options = {}; } + if (adapter) { + currentAdapter = Object.assign({}, adapter, options); + } + if (!currentAdapter) { + throw new Error('You must set an adapter first!'); + } + return currentAdapter; +} + +var Base = /** @class */ (function () { + function Base() { + this.requests = mobx.observable.array([]); + } + /** + * Returns the resource's url. + * + * @abstract + */ + Base.prototype.url = function () { + throw new Error('You must implement this method'); + }; + Base.prototype.withRequest = function (labels, promise, abort) { + var _this = this; + if (typeof labels === 'string') { + labels = [labels]; + } + var handledPromise = promise + .then(function (response) { + if (_this.request === request) + _this.request = null; + mobx.runInAction('remove request', function () { + _this.requests.remove(request); + }); + return response; + }) + .catch(function (error) { + mobx.runInAction('remove request', function () { + _this.requests.remove(request); + }); + throw new ErrorObject(error); + }); + var request = new Request(handledPromise, { + labels: labels, + abort: abort + }); + this.request = request; + this.requests.push(request); + return request; + }; + Base.prototype.getRequest = function (label) { + return this.requests.find(function (request) { return includes(request.labels, label); }); + }; + Base.prototype.getAllRequests = function (label) { + return this.requests.filter(function (request) { return includes(request.labels, label); }); + }; + /** + * Questions whether the request exists + * and matches a certain label + */ + Base.prototype.isRequest = function (label) { + return !!this.getRequest(label); + }; + /** + * Call an RPC action for all those + * non-REST endpoints that you may have in + * your API. + */ + Base.prototype.rpc = function (endpoint, options, label) { + if (label === void 0) { label = 'fetching'; } + var url = isObject(endpoint) ? endpoint.rootUrl : this.url() + "/" + endpoint; + var _a = apiClient().post(url, options), promise = _a.promise, abort = _a.abort; + return this.withRequest(label, promise, abort); + }; + __decorate([ + mobx.observable + ], Base.prototype, "request", void 0); + __decorate([ + mobx.observable.shallow + ], Base.prototype, "requests", void 0); + __decorate([ + mobx.action + ], Base.prototype, "rpc", null); + return Base; +}()); + +var currentModelMapperAdapter; +/** + * Sets or gets the api client instance + */ +function modelMapper(adapter, clear // hack for better testing +) { + if (clear === void 0) { clear = false; } + if (clear) { + currentModelMapperAdapter = undefined; + } + if (adapter) { + currentModelMapperAdapter = adapter; + } + if (!currentModelMapperAdapter && !clear) { + throw new Error('You must set an model mapper adapter first!'); + } + return currentModelMapperAdapter; +} + +var dontMergeArrays = function (_oldArray, newArray) { return newArray; }; +var DEFAULT_PRIMARY = 'id'; +var Model = /** @class */ (function (_super) { + __extends(Model, _super); + function Model(attributes, defaultAttributes, modelMap) { + if (attributes === void 0) { attributes = {}; } + if (defaultAttributes === void 0) { defaultAttributes = {}; } + if (modelMap === void 0) { modelMap = []; } + var _this = _super.call(this) || this; + _this.defaultAttributes = {}; + _this.attributes = mobx.observable.map(); + _this.committedAttributes = mobx.observable.map(); + _this.optimisticId = uniqueId('i_'); + _this.collection = null; + _this.defaultAttributes = defaultAttributes; + var mergedAttributes = __assign({}, _this.defaultAttributes, attributes); + _this.modelMap = modelMap; + _this.attributes.replace(mergedAttributes); + _this.commitChanges(); + return _this; + } + /** + * Returns a JSON representation + * of the model + */ + Model.prototype.toJS = function () { + return mobx.toJS(this.attributes, { exportMapsAsObjects: true }); + }; + Object.defineProperty(Model.prototype, "primaryKey", { + /** + * Define which is the primary + * key of the model. + */ + get: function () { + return DEFAULT_PRIMARY; + }, + enumerable: true, + configurable: true + }); + /** + * Return the base url used in + * the `url` method + * + * @abstract + */ + Model.prototype.urlRoot = function () { + return null; + }; + /** + * Return the url for this given REST resource + */ + Model.prototype.url = function () { + var urlRoot = this.urlRoot(); + if (!urlRoot && this.collection) { + urlRoot = this.collection.url(); + } + if (!urlRoot) { + throw new Error('implement `urlRoot` method or `url` on the collection'); + } + if (this.isNew) { + return urlRoot; + } + else { + return urlRoot + "/" + this.get(this.primaryKey); + } + }; + Object.defineProperty(Model.prototype, "isNew", { + /** + * Wether the resource is new or not + * + * We determine this asking if it contains + * the `primaryKey` attribute (set by the server). + */ + get: function () { + return !this.has(this.primaryKey) || !this.get(this.primaryKey); + }, + enumerable: true, + configurable: true + }); + /** + * Get the attribute from the model. + * + * Since we want to be sure changes on + * the schema don't fail silently we + * throw an error if the field does not + * exist. + * + * If you want to deal with flexible schemas + * use `has` to check wether the field + * exists. + */ + Model.prototype.get = function (attribute) { + if (this.has(attribute)) { + return this.attributes.get(attribute); + } + throw new Error("Attribute \"" + attribute + "\" not found"); + }; + /** + * Returns whether the given field exists + * for the model. + */ + Model.prototype.has = function (attribute) { + return this.attributes.has(attribute); + }; + Object.defineProperty(Model.prototype, "id", { + /** + * Get an id from the model. It will use either + * the backend assigned one or the client. + */ + get: function () { + return this.has(this.primaryKey) + ? this.get(this.primaryKey) + : this.optimisticId; + }, + enumerable: true, + configurable: true + }); + Object.defineProperty(Model.prototype, "changedAttributes", { + /** + * Get an array with the attributes names that have changed. + */ + get: function () { + return getChangedAttributesBetween(mobx.toJS(this.committedAttributes), mobx.toJS(this.attributes)); + }, + enumerable: true, + configurable: true + }); + Object.defineProperty(Model.prototype, "changes", { + /** + * Gets the current changes. + */ + get: function () { + return getChangesBetween(mobx.toJS(this.committedAttributes), mobx.toJS(this.attributes)); + }, + enumerable: true, + configurable: true + }); + /** + * If an attribute is specified, returns true if it has changes. + * If no attribute is specified, returns true if any attribute has changes. + */ + Model.prototype.hasChanges = function (attribute) { + if (attribute) { + return includes(this.changedAttributes, attribute); + } + return this.changedAttributes.length > 0; + }; + Model.prototype.commitChanges = function () { + this.committedAttributes.replace(mobx.toJS(this.attributes)); + }; + Model.prototype.discardChanges = function () { + this.attributes.replace(mobx.toJS(this.committedAttributes)); + }; + /** + * Replace all attributes with new data + */ + Model.prototype.reset = function (data) { + this.attributes.replace(data + ? __assign({}, this.defaultAttributes, data) : this.defaultAttributes); + }; + /** + * Merge the given attributes with + * the current ones + */ + Model.prototype.set = function (data) { + this.attributes.merge(data); + }; + /** + * Fetches the model from the backend. + */ + Model.prototype.fetch = function (_a) { + var _this = this; + if (_a === void 0) { _a = {}; } + var data = _a.data, otherOptions = __rest(_a, ["data"]); + var modelMap = this.modelMap; + var _b = apiClient().get(this.url(), mapToApi(data, modelMap), otherOptions), abort = _b.abort, promise = _b.promise; // changed from const to let in order to apply chaining + promise + .then(function (data) { return mapToModel(data, modelMap); }) + .then(function (data) { + _this.set(data); + _this.commitChanges(); + }) + .catch(function (_error) { + }); // do nothing + return this.withRequest('fetching', promise, abort); + }; + /** + * Saves the resource on the backend. + * + * If the item has a `primaryKey` it updates it, + * otherwise it creates the new resource. + * + * It supports optimistic and patch updates. + */ + Model.prototype.save = function (attributes, _a) { + var _this = this; + if (_a === void 0) { _a = {}; } + var _b = _a.optimistic, optimistic = _b === void 0 ? true : _b, _c = _a.patch, patch = _c === void 0 ? true : _c, _d = _a.keepChanges, keepChanges = _d === void 0 ? false : _d, otherOptions = __rest(_a, ["optimistic", "patch", "keepChanges"]); + var currentAttributes = this.toJS(); + var label = this.isNew ? 'creating' : 'updating'; + var collection = this.collection; + var data; + var modelMap = this.modelMap; + if (patch && attributes && !this.isNew) { + data = attributes; + } + else if (patch && !this.isNew) { + data = this.changes; + } + else { + data = __assign({}, currentAttributes, attributes); + } + var method; + if (this.isNew) { + method = 'post'; + } + else if (patch) { + method = 'patch'; + } + else { + method = 'put'; + } + if (optimistic && attributes) { + this.set(patch + ? applyPatchChanges(currentAttributes, attributes) + : attributes); + } + if (optimistic && collection) + collection.add([this]); + var onProgress = debounce(function (progress) { + if (optimistic && _this.request) + _this.request.progress = progress; + }); + var _e = apiClient()[method](this.url(), mapToApi(data, modelMap), __assign({ onProgress: onProgress }, otherOptions)), promise = _e.promise, abort = _e.abort; + promise + .then(function (data) { return mapToModel(data, modelMap); }) + .then(function (data) { + var changes = getChangesBetween(currentAttributes, mobx.toJS(_this.attributes)); + mobx.runInAction('save success', function () { + _this.set(data); + _this.commitChanges(); + if (!optimistic && collection) + collection.add([_this]); + if (keepChanges) { + _this.set(applyPatchChanges(data, changes)); + } + }); + }) + .catch(function (error) { + _this.set(currentAttributes); + if (optimistic && _this.isNew && collection) { + collection.remove(_this); + } + }); + return this.withRequest(['saving', label], promise, abort); + }; + /** + * Destroys the resurce on the client and + * requests the backend to delete it there + * too + */ + Model.prototype.destroy = function (_a) { + var _this = this; + if (_a === void 0) { _a = {}; } + var data = _a.data, _b = _a.optimistic, optimistic = _b === void 0 ? true : _b, otherOptions = __rest(_a, ["data", "optimistic"]); + var collection = this.collection; + var modelMap = this.modelMap; + if (this.isNew && collection) { + collection.remove(this); + return new Request(Promise.resolve()); + } + if (this.isNew) { + return new Request(Promise.resolve()); + } + var _c = apiClient().del(this.url(), mapToApi(data, modelMap), otherOptions), promise = _c.promise, abort = _c.abort; + if (optimistic && collection) { + collection.remove(this); + } + promise + .then(function (data) { return mapToModel(data, modelMap); }) + .then(function (data) { + if (!optimistic && collection) + collection.remove(_this); + }) + .catch(function (error) { + if (optimistic && collection) + collection.add(_this); + }); + return this.withRequest('destroying', promise, abort); + }; + /* + * Helper method. + * We may need this method to use before rpc requests response + */ + Model.prototype.toApiObject = function (throwException) { + if (throwException === void 0) { throwException = false; } + return mapToApi(this.toJS(), this.modelMap, modelMapper, throwException); + }; + /* + * Helper method. + * We may need this method to use after rpc requests response + */ + Model.prototype.toModelObject = function (data, throwException) { + if (throwException === void 0) { throwException = false; } + return mapToModel(data, this.modelMap, modelMapper, throwException); + }; + __decorate([ + mobx.computed + ], Model.prototype, "isNew", null); + __decorate([ + mobx.computed + ], Model.prototype, "changedAttributes", null); + __decorate([ + mobx.computed + ], Model.prototype, "changes", null); + __decorate([ + mobx.action + ], Model.prototype, "commitChanges", null); + __decorate([ + mobx.action + ], Model.prototype, "discardChanges", null); + __decorate([ + mobx.action + ], Model.prototype, "reset", null); + __decorate([ + mobx.action + ], Model.prototype, "set", null); + __decorate([ + mobx.action + ], Model.prototype, "fetch", null); + __decorate([ + mobx.action + ], Model.prototype, "save", null); + __decorate([ + mobx.action + ], Model.prototype, "destroy", null); + return Model; +}(Base)); +/** + * Merges old attributes with new ones. + * By default it doesn't merge arrays. + */ +var applyPatchChanges = function (oldAttributes, changes) { + return deepmerge(oldAttributes, changes, { + arrayMerge: dontMergeArrays + }); +}; +var getChangedAttributesBetween = function (source, target) { + var keys = union(Object.keys(source), Object.keys(target)); + return keys.filter(function (key) { return !isEqual(source[key], target[key]); }); +}; +var getChangesBetween = function (source, target) { + var changes = {}; + getChangedAttributesBetween(source, target).forEach(function (key) { + changes[key] = isPlainObject(source[key]) && isPlainObject(target[key]) + ? getChangesBetween(source[key], target[key]) + : target[key]; + }); + return changes; +}; +/* +* Maps api response model to model. +* Default : It returns api response data as is. + */ +var mapToModel = function (data, map, mapper, throwError) { + if (mapper === void 0) { mapper = modelMapper; } + if (throwError === void 0) { throwError = false; } + try { + var adapter = mapper(); + if (map.length > 0) { // test if model has map and modelMapper has an adapter + data = adapter.apiToModel(data, map); + } + else { + if (throwError) + throw new Error("Undefined model map"); + } + } + catch (_error) { + //do nothing so we can return data as is + if (throwError) + throw new Error(_error); + } + return data; +}; +/* +* Maps model to api(request) model. +* Default : It returns data as is. + */ +var mapToApi = function (data, map, mapper, throwError) { + if (mapper === void 0) { mapper = modelMapper; } + if (throwError === void 0) { throwError = false; } + try { + var adapter = mapper(); + if (map.length > 0) { // test if model has map and modelMapper has an adapter + data = adapter.modelToApi(data, map); + } + else { + if (throwError) + throw new Error("Undefined model map"); + } + } + catch (_error) { + //do nothing so we can return data as is + if (throwError) + throw new Error(_error); + } + return data; +}; + +function getAttribute(resource, attribute) { + if (resource instanceof Model) { + return resource.has(attribute) + ? resource.get(attribute) + : null; + } + else { + return resource[attribute]; + } +} +var Collection = /** @class */ (function (_super) { + __extends(Collection, _super); + function Collection(data) { + if (data === void 0) { data = []; } + var _this = _super.call(this) || this; + _this.models = mobx.observable.array([]); + _this.indexes = []; + _this.set(data); + return _this; + } + Object.defineProperty(Collection.prototype, "primaryKey", { + /** + * Define which is the primary key + * of the model's in the collection. + * + * FIXME: This contains a hack to use the `primaryKey` as + * an instance method. Ideally it should be static but that + * would not be backward compatible and Typescript sucks at + * static polymorphism (https://github.com/microsoft/TypeScript/issues/5863). + */ + get: function () { + var ModelClass = this.model(); + if (!ModelClass) + return DEFAULT_PRIMARY; + return (new ModelClass()).primaryKey; + }, + enumerable: true, + configurable: true + }); + Object.defineProperty(Collection.prototype, "index", { + /** + * Returns a hash with all the indexes for that + * collection. + * + * We keep the indexes in memory for as long as the + * collection is alive, even if no one is referencing it. + * This way we can ensure to calculate it only once. + */ + get: function () { + var _this = this; + var indexes = this.indexes.concat([this.primaryKey]); + return indexes.reduce(function (tree, attr) { + var newIndex = _this.models.reduce(function (index, model) { + var value = model.has(attr) + ? model.get(attr) + : null; + var oldModels = index.get(value) || []; + return index.set(value, oldModels.concat(model)); + }, new Map()); + return tree.set(attr, newIndex); + }, new Map()); + }, + enumerable: true, + configurable: true + }); + Object.defineProperty(Collection.prototype, "length", { + /** + * Alias for models.length + */ + get: function () { + return this.models.length; + }, + enumerable: true, + configurable: true + }); + /** + * Alias for models.map + */ + Collection.prototype.map = function (callback) { + return this.models.map(callback); + }; + /** + * Alias for models.forEach + */ + Collection.prototype.forEach = function (callback) { + return this.models.forEach(callback); + }; + /** + * Returns a JSON representation + * of the collection + */ + Collection.prototype.toJS = function () { + return this.models.map(function (model) { return model.toJS(); }); + }; + /** + * Alias of slice + */ + Collection.prototype.toArray = function () { + return this.slice(); + }; + /** + * Returns a defensive shallow array representation + * of the collection + */ + Collection.prototype.slice = function () { + return this.models.slice(); + }; + Object.defineProperty(Collection.prototype, "isEmpty", { + /** + * Wether the collection is empty + */ + get: function () { + return this.length === 0; + }, + enumerable: true, + configurable: true + }); + Object.defineProperty(Collection.prototype, "_ids", { + /** + * Gets the ids of all the items in the collection + */ + get: function () { + return compact(Array.from(this.index.get(this.primaryKey).keys())); + }, + enumerable: true, + configurable: true + }); + /** + * Get a resource at a given position + */ + Collection.prototype.at = function (index) { + return this.models[index]; + }; + /** + * Get a resource with the given id or uuid + */ + Collection.prototype.get = function (id, _a) { + var _b = (_a === void 0 ? {} : _a).required, required = _b === void 0 ? false : _b; + var models = this.index.get(this.primaryKey).get(id); + var model = models && models[0]; + if (!model && required) { + throw new Error("Invariant: Model must be found with " + this.primaryKey + ": " + id); + } + return model; + }; + /** + * Get a resource with the given id or uuid or fail loudly. + */ + Collection.prototype.mustGet = function (id) { + return this.get(id, { required: true }); + }; + /** + * Get resources matching criteria. + * + * If passing an object of key:value conditions, it will + * use the indexes to efficiently retrieve the data. + */ + Collection.prototype.filter = function (query) { + var _this = this; + if (typeof query === 'function') { + return this.models.filter(function (model) { return query(model); }); + } + else { + // Sort the query to hit the indexes first + var optimizedQuery = entries(query).sort(function (A, B) { + return Number(_this.index.has(B[0])) - Number(_this.index.has(A[0])); + }); + return optimizedQuery.reduce(function (values, _a) { + var attr = _a[0], value = _a[1]; + // Hitting index + if (_this.index.has(attr)) { + var newValues = _this.index.get(attr).get(value) || []; + return values ? intersection(values, newValues) : newValues; + } + else { + // Either Re-filter or Full scan + var target = values || _this.models; + return target.filter(function (model) { + return model.has(attr) && model.get(attr) === value; + }); + } + }, null); + } + }; + /** + * Finds an element with the given matcher + */ + Collection.prototype.find = function (query, _a) { + var _b = (_a === void 0 ? {} : _a).required, required = _b === void 0 ? false : _b; + var model = typeof query === 'function' + ? this.models.find(function (model) { return query(model); }) + : this.filter(query)[0]; + if (!model && required) { + throw new Error("Invariant: Model must be found"); + } + return model; + }; + /** + * Get a resource with the given id or uuid or fails loudly. + */ + Collection.prototype.mustFind = function (query) { + return this.find(query, { required: true }); + }; + /** + * Adds a model or collection of models. + */ + Collection.prototype.add = function (data) { + var _this = this; + var _a; + if (!Array.isArray(data)) + data = [data]; + var models = difference(data.map(function (m) { return _this.build(m); }), this.models); + (_a = this.models).push.apply(_a, models); + return models; + }; + /** + * Resets the collection of models. + */ + Collection.prototype.reset = function (data) { + var _this = this; + this.models.replace(data.map(function (m) { return _this.build(m); })); + }; + /** + * Removes the model with the given ids or uuids + */ + Collection.prototype.remove = function (ids) { + var _this = this; + if (!Array.isArray(ids)) { + ids = [ids]; + } + ids.forEach(function (id) { + var model; + if (id instanceof Model && id.collection === _this) { + model = id; + } + else if (typeof id === 'number' || typeof id === 'string') { + model = _this.get(id); + } + if (!model) { + return console.warn(_this.constructor.name + ": Model with " + _this.primaryKey + " " + id + " not found."); + } + _this.models.splice(_this.models.indexOf(model), 1); + model.collection = undefined; + }); + }; + /** + * Sets the resources into the collection. + * + * You can disable adding, changing or removing. + */ + Collection.prototype.set = function (resources, _a) { + var _this = this; + var _b = _a === void 0 ? {} : _a, _c = _b.add, add = _c === void 0 ? true : _c, _d = _b.change, change = _d === void 0 ? true : _d, _e = _b.remove, remove = _e === void 0 ? true : _e; + if (remove) { + var ids = compact(resources.map(function (r) { + return getAttribute(r, _this.primaryKey); + })); + var toRemove = difference(this._ids, ids); + if (toRemove.length) + this.remove(toRemove); + } + resources.forEach(function (resource) { + var id = getAttribute(resource, _this.primaryKey); + var model = id ? _this.get(id) : null; + if (model && change) { + model.set(resource instanceof Model ? resource.toJS() : resource); + } + if (!model && add) + _this.add([resource]); + }); + }; + /** + * Creates a new model instance with the given attributes + */ + Collection.prototype.build = function (attributes) { + if (attributes === void 0) { attributes = {}; } + if (attributes instanceof Model) { + attributes.collection = this; + return attributes; + } + var ModelClass = this.model(attributes); + var model = new ModelClass(attributes); + model.collection = this; + return model; + }; + /** + * Creates the model and saves it on the backend + * + * The default behaviour is optimistic but this + * can be tuned. + */ + Collection.prototype.create = function (attributesOrModel, _a) { + var _this = this; + var _b = (_a === void 0 ? {} : _a).optimistic, optimistic = _b === void 0 ? true : _b; + var model = this.build(attributesOrModel); + var request = model.save({}, { optimistic: optimistic }); + this.requests.push(request); + var promise = request.promise; + promise + .then(function (_response) { + _this.requests.remove(request); + }) + .catch(function (error) { + _this.requests.remove(request); + }); + return request; + }; + /** + * Fetches the models from the backend. + * + * It uses `set` internally so you can + * use the options to disable adding, changing + * or removing. + */ + Collection.prototype.fetch = function (_a) { + var _this = this; + if (_a === void 0) { _a = {}; } + var data = _a.data, otherOptions = __rest(_a, ["data"]); + var _b = apiClient().get(this.url(), data, otherOptions), abort = _b.abort, promise = _b.promise; + promise + .then(function (data) { return _this.set(data, otherOptions); }) + .catch(function (_error) { }); // do nothing + return this.withRequest('fetching', promise, abort); + }; + __decorate([ + mobx.observable + ], Collection.prototype, "models", void 0); + __decorate([ + mobx.computed({ keepAlive: true }) + ], Collection.prototype, "index", null); + __decorate([ + mobx.computed + ], Collection.prototype, "length", null); + __decorate([ + mobx.computed + ], Collection.prototype, "isEmpty", null); + __decorate([ + mobx.computed + ], Collection.prototype, "_ids", null); + __decorate([ + mobx.action + ], Collection.prototype, "add", null); + __decorate([ + mobx.action + ], Collection.prototype, "reset", null); + __decorate([ + mobx.action + ], Collection.prototype, "remove", null); + __decorate([ + mobx.action + ], Collection.prototype, "set", null); + __decorate([ + mobx.action + ], Collection.prototype, "create", null); + __decorate([ + mobx.action + ], Collection.prototype, "fetch", null); + return Collection; +}(Base)); + +exports.Collection = Collection; +exports.ErrorObject = ErrorObject; +exports.Model = Model; +exports.Request = Request; +exports.apiClient = apiClient; diff --git a/lib/modelMapper.d.ts b/lib/modelMapper.d.ts new file mode 100644 index 0000000..b4a294c --- /dev/null +++ b/lib/modelMapper.d.ts @@ -0,0 +1,5 @@ +import { ModelMapperAdapter } from './types'; +/** + * Sets or gets the api client instance + */ +export default function modelMapper(adapter?: ModelMapperAdapter, clear?: boolean): ModelMapperAdapter; diff --git a/lib/types.d.ts b/lib/types.d.ts new file mode 100644 index 0000000..b909147 --- /dev/null +++ b/lib/types.d.ts @@ -0,0 +1,51 @@ +export declare type OptimisticId = string; +export declare type Id = number | OptimisticId; +export declare type RequestState = 'pending' | 'fulfilled' | 'rejected'; +export interface CreateOptions { + optimistic?: boolean; + onProgress?: () => any; +} +export interface DestroyOptions { + data?: {}; + optimistic?: boolean; +} +export interface SaveOptions { + optimistic?: boolean; + patch?: boolean; + onProgress?: () => any; + keepChanges?: boolean; +} +export interface Response { + abort: () => void; + promise: Promise; +} +export interface RequestOptions { + abort?: () => void | null; + progress?: number; + labels?: Array; +} +export interface SetOptions { + add?: boolean; + change?: boolean; + remove?: boolean; + data?: {}; +} +export interface GetOptions { + required?: boolean; +} +export interface FindOptions { + required?: boolean; +} +export interface Adapter { + get(path: string, data?: {}, options?: {}): Response; + patch(path: string, data?: {}, options?: {}): Response; + post(path: string, data?: {}, options?: {}): Response; + put(path: string, data?: {}, options?: {}): Response; + del(path: string, data?: {}, options?: {}): Response; +} +export interface ModelMapperAdapter { + modelToApi(model: object, map: any[][]): object; + apiToModel(apiModel: object, map: any[][], ModelClass?: { + new (): T; + }): T; +} diff --git a/package.json b/package.json index 2822be5..e3b9ba0 100644 --- a/package.json +++ b/package.json @@ -41,7 +41,7 @@ "build": "yarn build:clean && rollup --config", "build:clean": "rimraf lib", "benchmark": "yarn build && node __tests__/benchmark.js", - "jest": "NODE_PATH=src jest --no-cache", + "jest": "jest --no-cache", "lint": "eslint --ext .ts --cache src/ __tests__/", "prepublish": "yarn build", "prepush": "yarn test", diff --git a/src/Model.ts b/src/Model.ts index af96581..2523501 100644 --- a/src/Model.ts +++ b/src/Model.ts @@ -1,4 +1,4 @@ -import { ObservableMap, action, computed, observable, runInAction, toJS } from 'mobx' +import {ObservableMap, action, computed, observable, runInAction, toJS} from 'mobx' import debounce from 'lodash/debounce' import includes from 'lodash/includes' import isEqual from 'lodash/isEqual' @@ -10,7 +10,8 @@ import Base from './Base' import Collection from './Collection' import Request from './Request' import apiClient from './apiClient' -import { OptimisticId, Id, DestroyOptions, SaveOptions } from './types' +import modelMapper from './modelMapper' +import {OptimisticId, Id, DestroyOptions, SaveOptions, ModelMapperAdapter} from './types' const dontMergeArrays = (_oldArray, newArray) => newArray @@ -25,10 +26,12 @@ export default class Model extends Base { optimisticId: OptimisticId = uniqueId('i_') collection: Collection | null = null + modelMap: any[][] - constructor ( + constructor( attributes: Attributes = {}, - defaultAttributes: Attributes = {} + defaultAttributes: Attributes = {}, + modelMap: any[][] = [] ) { super() @@ -39,6 +42,7 @@ export default class Model extends Base { ...attributes } + this.modelMap = modelMap this.attributes.replace(mergedAttributes) this.commitChanges() } @@ -47,15 +51,15 @@ export default class Model extends Base { * Returns a JSON representation * of the model */ - toJS () { - return toJS(this.attributes, { exportMapsAsObjects: true }) + toJS() { + return toJS(this.attributes, {exportMapsAsObjects: true}) } /** * Define which is the primary * key of the model. */ - get primaryKey (): string { + get primaryKey(): string { return DEFAULT_PRIMARY } @@ -65,14 +69,14 @@ export default class Model extends Base { * * @abstract */ - urlRoot (): string | null { + urlRoot(): string | null { return null } /** * Return the url for this given REST resource */ - url (): string { + url(): string { let urlRoot = this.urlRoot() if (!urlRoot && this.collection) { @@ -97,7 +101,7 @@ export default class Model extends Base { * the `primaryKey` attribute (set by the server). */ @computed - get isNew (): boolean { + get isNew(): boolean { return !this.has(this.primaryKey) || !this.get(this.primaryKey) } @@ -113,7 +117,7 @@ export default class Model extends Base { * use `has` to check wether the field * exists. */ - get (attribute: string): any { + get(attribute: string): any { if (this.has(attribute)) { return this.attributes.get(attribute) } @@ -124,7 +128,7 @@ export default class Model extends Base { * Returns whether the given field exists * for the model. */ - has (attribute: string): boolean { + has(attribute: string): boolean { return this.attributes.has(attribute) } @@ -132,7 +136,7 @@ export default class Model extends Base { * Get an id from the model. It will use either * the backend assigned one or the client. */ - get id (): Id { + get id(): Id { return this.has(this.primaryKey) ? this.get(this.primaryKey) : this.optimisticId @@ -142,7 +146,7 @@ export default class Model extends Base { * Get an array with the attributes names that have changed. */ @computed - get changedAttributes (): Array { + get changedAttributes(): Array { return getChangedAttributesBetween( toJS(this.committedAttributes), toJS(this.attributes) @@ -153,7 +157,7 @@ export default class Model extends Base { * Gets the current changes. */ @computed - get changes (): { [key: string]: any } { + get changes(): { [key: string]: any } { return getChangesBetween( toJS(this.committedAttributes), toJS(this.attributes) @@ -164,7 +168,7 @@ export default class Model extends Base { * If an attribute is specified, returns true if it has changes. * If no attribute is specified, returns true if any attribute has changes. */ - hasChanges (attribute?: string): boolean { + hasChanges(attribute?: string): boolean { if (attribute) { return includes(this.changedAttributes, attribute) } @@ -173,12 +177,12 @@ export default class Model extends Base { } @action - commitChanges (): void { + commitChanges(): void { this.committedAttributes.replace(toJS(this.attributes)) } @action - discardChanges (): void { + discardChanges(): void { this.attributes.replace(toJS(this.committedAttributes)) } @@ -186,10 +190,10 @@ export default class Model extends Base { * Replace all attributes with new data */ @action - reset (data?: {}): void { + reset(data?: {}): void { this.attributes.replace( data - ? { ...this.defaultAttributes, ...data } + ? {...this.defaultAttributes, ...data} : this.defaultAttributes ) } @@ -199,7 +203,7 @@ export default class Model extends Base { * the current ones */ @action - set (data: {}): void { + set(data: {}): void { this.attributes.merge(data) } @@ -207,15 +211,18 @@ export default class Model extends Base { * Fetches the model from the backend. */ @action - fetch ({ data, ...otherOptions }: { data?: {} } = {}): Request { - const { abort, promise } = apiClient().get(this.url(), data, otherOptions) + fetch({data, ...otherOptions}: { data?: {} } = {}): Request { + const modelMap = this.modelMap; + const {abort, promise} = apiClient().get(this.url(), mapToApi(data, modelMap), otherOptions) // changed from const to let in order to apply chaining promise + .then(data => mapToModel(data, modelMap)) .then(data => { this.set(data) this.commitChanges() }) - .catch(_error => {}) // do nothing + .catch(_error => { + }) // do nothing return this.withRequest('fetching', promise, abort) } @@ -229,7 +236,7 @@ export default class Model extends Base { * It supports optimistic and patch updates. */ @action - save ( + save( attributes?: {}, { optimistic = true, @@ -242,13 +249,14 @@ export default class Model extends Base { const label = this.isNew ? 'creating' : 'updating' const collection = this.collection let data + const modelMap = this.modelMap if (patch && attributes && !this.isNew) { data = attributes } else if (patch && !this.isNew) { data = this.changes } else { - data = { ...currentAttributes, ...attributes } + data = {...currentAttributes, ...attributes} } let method @@ -274,13 +282,14 @@ export default class Model extends Base { if (optimistic && this.request) this.request.progress = progress }) - const { promise, abort } = apiClient()[method]( + const {promise, abort} = apiClient()[method]( this.url(), - data, - { onProgress, ...otherOptions } + mapToApi(data, modelMap), + {onProgress, ...otherOptions} ) promise + .then(data => mapToModel(data, modelMap)) .then(data => { const changes = getChangesBetween( currentAttributes, @@ -315,10 +324,11 @@ export default class Model extends Base { * too */ @action - destroy ( - { data, optimistic = true, ...otherOptions }: DestroyOptions = {} + destroy( + {data, optimistic = true, ...otherOptions}: DestroyOptions = {} ): Request { const collection = this.collection + const modelMap = this.modelMap if (this.isNew && collection) { collection.remove(this) @@ -329,9 +339,9 @@ export default class Model extends Base { return new Request(Promise.resolve()) } - const { promise, abort } = apiClient().del( + const {promise, abort} = apiClient().del( this.url(), - data, + mapToApi(data, modelMap), otherOptions ) @@ -340,6 +350,7 @@ export default class Model extends Base { } promise + .then(data => mapToModel(data, modelMap)) .then(data => { if (!optimistic && collection) collection.remove(this) }) @@ -349,6 +360,22 @@ export default class Model extends Base { return this.withRequest('destroying', promise, abort) } + + /* + * Helper method. + * We may need this method to use before rpc requests response + */ + toApiObject(throwException: boolean = false) { + return mapToApi(this.toJS(), this.modelMap, modelMapper, throwException) + } + + /* + * Helper method. + * We may need this method to use after rpc requests response + */ + toModelObject(data: {}, throwException: boolean = false) { + return mapToModel(data, this.modelMap, modelMapper, throwException) + } } /** @@ -381,3 +408,45 @@ const getChangesBetween = (source: {}, target: {}): { [key: string]: any } => { return changes } + +/* +* Maps api response model to model. +* Default : It returns api response data as is. + */ +const mapToModel = (data: {}, map: any[][], mapper = modelMapper, throwError: boolean = false): {} => { + try { + const adapter = mapper(); + if (map.length > 0) { // test if model has map and modelMapper has an adapter + data = adapter.apiToModel(data, map); + } else { + if (throwError) + throw new Error("Undefined model map"); + } + } catch (_error) { + //do nothing so we can return data as is + if (throwError) + throw new Error(_error); + } + return data; +}; + +/* +* Maps model to api(request) model. +* Default : It returns data as is. + */ +const mapToApi = (data: {}, map: any[][], mapper = modelMapper, throwError: boolean = false): {} => { + try { + const adapter = mapper(); + if (map.length > 0) { // test if model has map and modelMapper has an adapter + data = adapter.modelToApi(data, map); + } else { + if (throwError) + throw new Error("Undefined model map"); + } + } catch (_error) { + //do nothing so we can return data as is + if (throwError) + throw new Error(_error); + } + return data; +}; diff --git a/src/modelMapper.ts b/src/modelMapper.ts new file mode 100644 index 0000000..36a7cfc --- /dev/null +++ b/src/modelMapper.ts @@ -0,0 +1,24 @@ +import {ModelMapperAdapter} from './types' + +let currentModelMapperAdapter; + +/** + * Sets or gets the api client instance + */ +export default function modelMapper( + adapter?: ModelMapperAdapter, + clear: boolean = false // hack for better testing +): ModelMapperAdapter { + if (clear) { + currentModelMapperAdapter = undefined; + } + if (adapter) { + currentModelMapperAdapter = adapter; + } + + if (!currentModelMapperAdapter && !clear) { + throw new Error('You must set an model mapper adapter first!') + } + + return currentModelMapperAdapter +} diff --git a/src/types.ts b/src/types.ts index c02f8a4..7e68396 100644 --- a/src/types.ts +++ b/src/types.ts @@ -52,3 +52,8 @@ export interface Adapter { put(path: string, data?: {}, options?: {}): Response del(path: string, data?: {}, options?: {}): Response } + +export interface ModelMapperAdapter { + modelToApi(model: object, map: any[][]): object; + apiToModel(apiModel: object, map: any[][], ModelClass?: { new(): T; }): T +}