diff --git a/README.MD b/README.MD index c14d788d..e8f51330 100644 --- a/README.MD +++ b/README.MD @@ -88,21 +88,23 @@ export class AppModule { } Firstly, create your `Datastore` service: - Extend the `JsonApiDatastore` class -- Decorate it with `@JsonApiDatastoreConfig`, set the `baseUrl` for your APIs and map your models +- Decorate it with `@JsonApiDatastoreConfig`, set the `baseUrl` for your APIs and map your models (Optional: you can set `apiVersion`, `baseUrl` will be suffixed with it) - Pass the `Http` depencency to the parent constructor. ```typescript -import { JsonApiDatastoreConfig, JsonApiDatastore } from 'angular2-jsonapi'; +import { JsonApiDatastoreConfig, JsonApiDatastore, DatastoreConfig } from 'angular2-jsonapi'; -@Injectable() -@JsonApiDatastoreConfig({ +const config: DatastoreConfig = { baseUrl: 'http://localhost:8000/v1/', models: { posts: Post, comments: Comment, users: User } -}) +} + +@Injectable() +@JsonApiDatastoreConfig(config) export class Datastore extends JsonApiDatastore { constructor(http: Http) { @@ -405,6 +407,45 @@ export class JsonApiMetaModel { } ``` +### Datastore config + +Datastore config can be specified through the `JsonApiDatastoreConfig` decorator and/or by setting a `config` variable of the `Datastore` class. If an option is specified in both objects, a value from `config` variable will be taken into account. + +```typescript +@JsonApiDatastoreConfig(config: DatastoreConfig) +export class Datastore extends JsonApiDatastore { + private customConfig: DatastoreConfig = { + baseUrl: 'http://something.com' + } + + constructor() { + this.config = this.customConfig; + } +} +``` + +`config`: + +* `models` - all the models which will be stored in the datastore +* `baseUrl` - base API URL +* `apiVersion` - optional, a string which will be appended to the baseUrl + + +### Model config + +```typescript +@JsonApiModelConfig(options: ModelOptions) +export class Post extends JsonApiModel { } +``` + +`options`: + +* `type` +* `baseUrl` - if not specified, the global `baseUrl` will be used +* `apiVersion` - if not specified, the global `apiVersion` will be used +* `modelEndpointUrl` - if not specified, `type` will be used instead +* `meta` - optional, metadata model + ### Custom Headers diff --git a/src/index.ts b/src/index.ts index fb692f21..20e75758 100644 --- a/src/index.ts +++ b/src/index.ts @@ -9,6 +9,9 @@ export * from './models/json-api.model'; export * from './models/error-response.model'; export * from './models/json-api-query-data'; +export * from './interfaces/datastore-config.interface'; +export * from './interfaces/model-config.interface'; + export * from './providers'; export * from './module'; diff --git a/src/interfaces/datastore-config.interface.ts b/src/interfaces/datastore-config.interface.ts new file mode 100644 index 00000000..cbe413a9 --- /dev/null +++ b/src/interfaces/datastore-config.interface.ts @@ -0,0 +1,5 @@ +export interface DatastoreConfig { + apiVersion?: string; + baseUrl?: string; + models?: Object; +} diff --git a/src/interfaces/model-config.interface.ts b/src/interfaces/model-config.interface.ts new file mode 100644 index 00000000..8c0da702 --- /dev/null +++ b/src/interfaces/model-config.interface.ts @@ -0,0 +1,9 @@ +import {JsonApiMetaModel} from '../models/json-api-meta.model'; + +export interface ModelConfig { + type: string; + apiVersion?: string; + baseUrl?: string; + modelEndpointUrl?: string; + meta?: JsonApiMetaModel; +} diff --git a/src/models/json-api.model.ts b/src/models/json-api.model.ts index 35972344..d91f82cc 100644 --- a/src/models/json-api.model.ts +++ b/src/models/json-api.model.ts @@ -2,9 +2,9 @@ import { Headers } from '@angular/http'; import find from 'lodash-es/find'; import { Observable } from 'rxjs/Observable'; import { JsonApiDatastore, ModelType } from '../services/json-api-datastore.service'; +import { ModelConfig } from '../interfaces/model-config.interface'; export class JsonApiModel { - id: string; [key: string]: any; @@ -61,6 +61,10 @@ export class JsonApiModel { Reflect.defineMetadata('Attribute', attributesMetadata, this); } + get modelConfig(): ModelConfig { + return Reflect.getMetadata('JsonApiModelConfig', this.constructor); + } + private parseHasMany(data: any, included: any, level: number): void { let hasMany: any = Reflect.getMetadata('HasMany', this); if (hasMany) { @@ -144,5 +148,4 @@ export class JsonApiModel { this._datastore.addToStore(newObject); return newObject; } - } diff --git a/src/services/json-api-datastore.service.spec.ts b/src/services/json-api-datastore.service.spec.ts index 0df707a8..e95b914d 100644 --- a/src/services/json-api-datastore.service.spec.ts +++ b/src/services/json-api-datastore.service.spec.ts @@ -1,6 +1,7 @@ import {TestBed} from '@angular/core/testing'; import * as dateParse from 'date-fns/parse'; import {Author} from '../../test/models/author.model'; +import {CustomAuthor, AUTHOR_API_VERSION, AUTHOR_MODEL_ENDPOINT_URL} from '../../test/models/custom-author.model'; import {AUTHOR_BIRTH, AUTHOR_ID, AUTHOR_NAME, BOOK_TITLE, getAuthorData} from '../../test/fixtures/author.fixture'; import { BaseRequestOptions, @@ -12,13 +13,16 @@ import { ResponseOptions } from '@angular/http'; import {MockBackend, MockConnection} from '@angular/http/testing'; -import {BASE_URL, Datastore} from '../../test/datastore.service'; +import {BASE_URL, API_VERSION, Datastore} from '../../test/datastore.service'; +import {BASE_URL_FROM_CONFIG, API_VERSION_FROM_CONFIG, DatastoreWithConfig} from '../../test/datastore-with-config.service'; import {ErrorResponse} from '../models/error-response.model'; import {getSampleBook} from '../../test/fixtures/book.fixture'; import {Book} from '../../test/models/book.model'; +import { ModelConfig } from '../index'; let datastore: Datastore; +let datastoreWithConfig: Datastore; let backend: MockBackend; // workaround, see https://github.com/angular/angular/pull/8961 @@ -28,9 +32,7 @@ class MockError extends Response implements Error { } describe('JsonApiDatastore', () => { - beforeEach(() => { - TestBed.configureTestingModule({ providers: [ { @@ -42,30 +44,54 @@ describe('JsonApiDatastore', () => { }, MockBackend, BaseRequestOptions, - Datastore + Datastore, + DatastoreWithConfig ] }); datastore = TestBed.get(Datastore); + datastoreWithConfig = TestBed.get(DatastoreWithConfig); backend = TestBed.get(MockBackend); - }); describe('query', () => { + it('should build basic url from the data from datastore decorator', () => { + const authorModelConfig: ModelConfig = Reflect.getMetadata('JsonApiModelConfig', Author); - it('should build basic url', () => { backend.connections.subscribe((c: MockConnection) => { - - expect(c.request.url).toEqual(BASE_URL + 'authors'); + expect(c.request.url).toEqual(`${BASE_URL}/${API_VERSION}/${authorModelConfig.type}`); expect(c.request.method).toEqual(RequestMethod.Get); }); + datastore.query(Author).subscribe(); }); + it('should build basic url and apiVersion from the config variable if exists', () => { + const authorModelConfig: ModelConfig = Reflect.getMetadata('JsonApiModelConfig', Author); + + backend.connections.subscribe((c: MockConnection) => { + expect(c.request.url).toEqual(`${BASE_URL_FROM_CONFIG}/${API_VERSION_FROM_CONFIG}/${authorModelConfig.type}`); + expect(c.request.method).toEqual(RequestMethod.Get); + }); + + datastoreWithConfig.query(Author).subscribe(); + }); + + it('should use apiVersion and modelEnpointUrl from the model instead of datastore if model has apiVersion and/or modelEndpointUrl specified', () => { + const authorModelConfig: ModelConfig = Reflect.getMetadata('JsonApiModelConfig', CustomAuthor); + + backend.connections.subscribe((c: MockConnection) => { + expect(c.request.url).toEqual(`${BASE_URL_FROM_CONFIG}/${AUTHOR_API_VERSION}/${AUTHOR_MODEL_ENDPOINT_URL}`); + expect(c.request.method).toEqual(RequestMethod.Get); + }); + + datastoreWithConfig.query(CustomAuthor).subscribe(); + }); + it('should set JSON API headers', () => { backend.connections.subscribe((c: MockConnection) => { - expect(c.request.url).toEqual(BASE_URL + 'authors'); + expect(c.request.url).toEqual(`${BASE_URL}/${API_VERSION}/authors`); expect(c.request.method).toEqual(RequestMethod.Get); expect(c.request.headers.get('Content-Type')).toEqual('application/vnd.api+json'); expect(c.request.headers.get('Accept')).toEqual('application/vnd.api+json'); @@ -75,8 +101,8 @@ describe('JsonApiDatastore', () => { it('should build url with nested params', () => { backend.connections.subscribe((c: MockConnection) => { - expect(c.request.url).not.toEqual(BASE_URL); - expect(c.request.url).toEqual(BASE_URL + 'authors?' + + expect(c.request.url).not.toEqual(`${BASE_URL}/${API_VERSION}`); + expect(c.request.url).toEqual(`${BASE_URL}/${API_VERSION}/` + 'authors?' + encodeURIComponent('page[size]') + '=10&' + encodeURIComponent('page[number]') + '=1&' + encodeURIComponent('include') + '=comments&' + @@ -96,7 +122,7 @@ describe('JsonApiDatastore', () => { it('should have custom headers', () => { backend.connections.subscribe((c: MockConnection) => { - expect(c.request.url).toEqual(BASE_URL + 'authors'); + expect(c.request.url).toEqual(`${BASE_URL}/${API_VERSION}/authors`); expect(c.request.method).toEqual(RequestMethod.Get); expect(c.request.headers.has('Authorization')).toBeTruthy(); expect(c.request.headers.get('Authorization')).toBe('Bearer'); @@ -107,7 +133,7 @@ describe('JsonApiDatastore', () => { it('should override base headers', () => { backend.connections.subscribe((c: MockConnection) => { - expect(c.request.url).toEqual(BASE_URL + 'authors'); + expect(c.request.url).toEqual(`${BASE_URL}/${API_VERSION}/authors`); expect(c.request.method).toEqual(RequestMethod.Get); expect(c.request.headers.has('Authorization')).toBeTruthy(); expect(c.request.headers.get('Authorization')).toBe('Basic'); @@ -262,8 +288,8 @@ describe('JsonApiDatastore', () => { describe('saveRecord', () => { it('should create new author', () => { backend.connections.subscribe((c: MockConnection) => { - expect(c.request.url).not.toEqual(BASE_URL); - expect(c.request.url).toEqual(BASE_URL + 'authors'); + expect(c.request.url).not.toEqual(`${BASE_URL}/${API_VERSION}`); + expect(c.request.url).toEqual(`${BASE_URL}/${API_VERSION}/authors`); expect(c.request.method).toEqual(RequestMethod.Post); let obj = c.request.json().data; expect(obj.attributes.name).toEqual(AUTHOR_NAME); @@ -339,8 +365,8 @@ describe('JsonApiDatastore', () => { describe('updateRecord', () => { it('should update author', () => { backend.connections.subscribe((c: MockConnection) => { - expect(c.request.url).not.toEqual(BASE_URL); - expect(c.request.url).toEqual(BASE_URL + 'authors/1'); + expect(c.request.url).not.toEqual(`${BASE_URL}/${API_VERSION}/authors`); + expect(c.request.url).toEqual(`${BASE_URL}/${API_VERSION}/authors/1`); expect(c.request.method).toEqual(RequestMethod.Patch); let obj = c.request.json().data; expect(obj.attributes.name).toEqual(AUTHOR_NAME); diff --git a/src/services/json-api-datastore.service.ts b/src/services/json-api-datastore.service.ts index 16c52597..632a2b8c 100644 --- a/src/services/json-api-datastore.service.ts +++ b/src/services/json-api-datastore.service.ts @@ -10,38 +10,40 @@ import {JsonApiModel} from '../models/json-api.model'; import {ErrorResponse} from '../models/error-response.model'; import {JsonApiQueryData} from '../models/json-api-query-data'; import * as qs from 'qs'; +import {DatastoreConfig} from '../interfaces/datastore-config.interface'; +import {ModelConfig} from '../interfaces/model-config.interface'; export type ModelType = { new(datastore: JsonApiDatastore, data: any): T; }; @Injectable() export class JsonApiDatastore { - private _headers: Headers; private _store: {[type: string]: {[id: string]: JsonApiModel}} = {}; - constructor(private http: Http) { - } + protected config: DatastoreConfig; + + constructor(private http: Http) {} /** @deprecated - use findAll method to take all models **/ - query(modelType: ModelType, params?: any, headers?: Headers): Observable { + query(modelType: ModelType, params?: any, headers?: Headers, customUrl?: string): Observable { let options: RequestOptions = this.getOptions(headers); - let url: string = this.buildUrl(modelType, params); + let url: string = this.buildUrl(modelType, params, null, customUrl); return this.http.get(url, options) .map((res: any) => this.extractQueryData(res, modelType)) .catch((res: any) => this.handleError(res)); } - findAll(modelType: ModelType, params?: any, headers?: Headers): Observable> { + findAll(modelType: ModelType, params?: any, headers?: Headers, customUrl?: string): Observable> { let options: RequestOptions = this.getOptions(headers); - let url: string = this.buildUrl(modelType, params); + let url: string = this.buildUrl(modelType, params, null, customUrl); return this.http.get(url, options) .map((res: any) => this.extractQueryData(res, modelType, true)) .catch((res: any) => this.handleError(res)); } - findRecord(modelType: ModelType, id: string, params?: any, headers?: Headers): Observable { + findRecord(modelType: ModelType, id: string, params?: any, headers?: Headers, customUrl?: string): Observable { let options: RequestOptions = this.getOptions(headers); - let url: string = this.buildUrl(modelType, params, id); + let url: string = this.buildUrl(modelType, params, id, customUrl); return this.http.get(url, options) .map((res: any) => this.extractRecordData(res, modelType)) .catch((res: any) => this.handleError(res)); @@ -64,12 +66,13 @@ export class JsonApiDatastore { return dirtyData; } - saveRecord(attributesMetadata: any, model?: T, params?: any, headers?: Headers): Observable { + saveRecord(attributesMetadata: any, model?: T, params?: any, headers?: Headers, customUrl?: string): Observable { let modelType = >model.constructor; - let typeName: string = Reflect.getMetadata('JsonApiModelConfig', modelType).type; + const modelConfig: ModelConfig = model.modelConfig; + let typeName: string = modelConfig.type; let options: RequestOptions = this.getOptions(headers); let relationships: any = this.getRelationships(model); - let url: string = this.buildUrl(modelType, params, model.id); + let url: string = this.buildUrl(modelType, params, model.id, customUrl); let httpCall: Observable; let body: any = { @@ -92,9 +95,9 @@ export class JsonApiDatastore { .catch((res: any) => this.handleError(res)); } - deleteRecord(modelType: ModelType, id: string, headers?: Headers): Observable { + deleteRecord(modelType: ModelType, id: string, headers?: Headers, customUrl?: string): Observable { let options: RequestOptions = this.getOptions(headers); - let url: string = this.buildUrl(modelType, null, id); + let url: string = this.buildUrl(modelType, null, id, customUrl); return this.http.delete(url, options) .catch((res: any) => this.handleError(res)); } @@ -114,11 +117,22 @@ export class JsonApiDatastore { this._headers = headers; } - private buildUrl(modelType: ModelType, params?: any, id?: string): string { - let typeName: string = Reflect.getMetadata('JsonApiModelConfig', modelType).type; - let baseUrl: string = Reflect.getMetadata('JsonApiDatastoreConfig', this.constructor).baseUrl; - let idToken: string = id ? `/${id}` : null; - return [baseUrl, typeName, idToken, (params ? '?' : ''), this.toQueryString(params)].join(''); + private buildUrl(modelType: ModelType, params?: any, id?: string, customUrl?: string): string { + const queryParams: string = this.toQueryString(params); + + if (customUrl) { + return queryParams ? `${customUrl}?${queryParams}` : customUrl; + } + + const modelConfig: ModelConfig = Reflect.getMetadata('JsonApiModelConfig', modelType); + + const baseUrl: string = modelConfig.baseUrl || this.datastoreConfig.baseUrl; + const apiVersion: string = modelConfig.apiVersion || this.datastoreConfig.apiVersion; + const modelEndpointUrl: string = modelConfig.modelEndpointUrl || modelConfig.type; + + const url: string = [baseUrl, apiVersion, modelEndpointUrl, id].filter(x => x).join('/'); + + return queryParams ? `${url}?${queryParams}` : url; } private getRelationships(data: any): any { @@ -143,13 +157,12 @@ export class JsonApiDatastore { private isValidToManyRelation(objects: Array): boolean { let isJsonApiModel = objects.every(item => item instanceof JsonApiModel); - let relationshipType: string = isJsonApiModel ? Reflect.getMetadata('JsonApiModelConfig', objects[0].constructor).type : ''; - return isJsonApiModel ? objects.every((item: JsonApiModel) => - Reflect.getMetadata('JsonApiModelConfig', item.constructor).type === relationshipType) : false; + let relationshipType: string = isJsonApiModel ? objects[0].modelConfig.type : ''; + return isJsonApiModel ? objects.every((item: JsonApiModel) => item.modelConfig.type === relationshipType) : false; } private buildSingleRelationshipData(model: JsonApiModel): any { - let relationshipType: string = Reflect.getMetadata('JsonApiModelConfig', model.constructor).type; + let relationshipType: string = model.modelConfig.type; let relationShipData: { type: string, id?: string, attributes?: any } = {type: relationshipType}; if (model.id) { relationShipData.id = model.id; @@ -249,7 +262,7 @@ export class JsonApiDatastore { public addToStore(modelOrModels: JsonApiModel | JsonApiModel[]): void { let models = Array.isArray(modelOrModels) ? modelOrModels : [modelOrModels]; - let type: string = Reflect.getMetadata('JsonApiModelConfig', models[0].constructor).type; + let type: string = models[0].modelConfig.type; let typeStore = this._store[type]; if (!typeStore) { typeStore = this._store[type] = {}; @@ -290,4 +303,8 @@ export class JsonApiDatastore { return model; }; + private get datastoreConfig(): DatastoreConfig { + const configFromDecorator: DatastoreConfig = Reflect.getMetadata('JsonApiDatastoreConfig', this.constructor); + return Object.assign(configFromDecorator, this.config); + } } diff --git a/test/datastore-with-config.service.ts b/test/datastore-with-config.service.ts new file mode 100644 index 00000000..7fd743ef --- /dev/null +++ b/test/datastore-with-config.service.ts @@ -0,0 +1,32 @@ +import { Injectable } from '@angular/core'; +import { Http } from '@angular/http'; +import { JsonApiDatastore, JsonApiDatastoreConfig, DatastoreConfig } from '../src'; +import { Author } from './models/author.model'; +import { Book } from './models/book.model'; +import { Chapter } from './models/chapter.model'; + +const BASE_URL = 'http://localhost:8080'; +const API_VERSION = 'v1'; + +export const BASE_URL_FROM_CONFIG = 'http://localhost:8888'; +export const API_VERSION_FROM_CONFIG = 'v2'; + +@JsonApiDatastoreConfig({ + baseUrl: BASE_URL, + apiVersion: API_VERSION, + models: { + authors: Author, + books: Book, + chapters: Chapter + } +}) +export class DatastoreWithConfig extends JsonApiDatastore { + protected config: DatastoreConfig = { + baseUrl: BASE_URL_FROM_CONFIG, + apiVersion: API_VERSION_FROM_CONFIG + }; + + constructor(http: Http) { + super(http); + } +} diff --git a/test/datastore.service.ts b/test/datastore.service.ts index cf8de61f..96249204 100644 --- a/test/datastore.service.ts +++ b/test/datastore.service.ts @@ -5,11 +5,12 @@ import { Author } from './models/author.model'; import { Book } from './models/book.model'; import { Chapter } from './models/chapter.model'; -export const BASE_URL = 'http://localhost:8080/v1/' +export const BASE_URL = 'http://localhost:8080'; +export const API_VERSION = 'v1'; -@Injectable() @JsonApiDatastoreConfig({ baseUrl: BASE_URL, + apiVersion: API_VERSION, models: { authors: Author, books: Book, @@ -17,8 +18,7 @@ export const BASE_URL = 'http://localhost:8080/v1/' } }) export class Datastore extends JsonApiDatastore { - constructor(http: Http) { - super(http); + super(http); } } diff --git a/test/models/custom-author.model.ts b/test/models/custom-author.model.ts new file mode 100644 index 00000000..ba21ee22 --- /dev/null +++ b/test/models/custom-author.model.ts @@ -0,0 +1,35 @@ +import { Book } from './book.model'; +import { JsonApiModelConfig } from '../../src/decorators/json-api-model-config.decorator'; +import { JsonApiModel } from '../../src/models/json-api.model'; +import { Attribute } from '../../src/decorators/attribute.decorator'; +import { HasMany } from '../../src/decorators/has-many.decorator'; +import {PageMetaData} from "./page-meta-data"; + +export const AUTHOR_API_VERSION = 'v3'; +export const AUTHOR_MODEL_ENDPOINT_URL = 'custom-author'; + +@JsonApiModelConfig({ + apiVersion: AUTHOR_API_VERSION, + modelEndpointUrl: AUTHOR_MODEL_ENDPOINT_URL, + type: 'authors', + meta: PageMetaData +}) +export class CustomAuthor extends JsonApiModel { + @Attribute() + name: string; + + @Attribute() + date_of_birth: Date; + + @Attribute() + date_of_death: Date; + + @Attribute() + created_at: Date; + + @Attribute() + updated_at: Date; + + @HasMany() + books: Book[]; +}