├── projects └── angular2-jsonapi │ ├── src │ ├── interfaces │ │ ├── model-type.interface.ts │ │ ├── json-model-converter-config.interface.ts │ │ ├── property-converter.interface.ts │ │ ├── overrides.interface.ts │ │ ├── datastore-config.interface.ts │ │ ├── attribute-decorator-options.interface.ts │ │ └── model-config.interface.ts │ ├── constants │ │ └── symbols.ts │ ├── providers.ts │ ├── decorators │ │ ├── json-api-datastore-config.decorator.ts │ │ ├── has-many.decorator.ts │ │ ├── belongs-to.decorator.ts │ │ ├── json-api-model-config.decorator.ts │ │ ├── json-attribute.decorator.ts │ │ ├── nested-attribute.decorator.ts │ │ └── attribute.decorator.ts │ ├── module.ts │ ├── models │ │ ├── json-api-meta.model.ts │ │ ├── json-api-query-data.ts │ │ ├── error-response.model.ts │ │ ├── json-nested.model.ts │ │ ├── json-api.model.ts │ │ └── json-api.model.spec.ts │ ├── converters │ │ ├── date │ │ │ ├── date.converter.ts │ │ │ └── date.converter.spec.ts │ │ └── json-model │ │ │ ├── json-model.converter.ts │ │ │ └── json-model.converter.spec.ts │ ├── test.ts │ ├── public-api.ts │ └── services │ │ ├── json-api-datastore.service.ts │ │ └── json-api-datastore.service.spec.ts │ ├── tslint.json │ ├── tsconfig.spec.json │ ├── test │ ├── models │ │ ├── page-meta-data.ts │ │ ├── school.model.ts │ │ ├── crime-book.model.ts │ │ ├── thingCategory.ts │ │ ├── thing.ts │ │ ├── sentence.model.ts │ │ ├── section.model.ts │ │ ├── paragraph.model.ts │ │ ├── category.model.ts │ │ ├── chapter.model.ts │ │ ├── custom-author.model.ts │ │ ├── book.model.ts │ │ └── author.model.ts │ ├── fixtures │ │ ├── category.fixture.ts │ │ ├── sentence.fixture.ts │ │ ├── section.fixture.ts │ │ ├── paragraph.fixture.ts │ │ ├── chapter.fixture.ts │ │ ├── thing.fixture.ts │ │ ├── book.fixture.ts │ │ └── author.fixture.ts │ ├── datastore.service.ts │ └── datastore-with-config.service.ts │ ├── ng-package.json │ ├── tsconfig.lib.json │ ├── package.json │ ├── package-lock.json │ ├── karma.conf.js │ └── README.md ├── .editorconfig ├── .travis.yml ├── tsconfig.json ├── angular.json ├── package.json ├── tslint.json ├── .gitignore ├── CHANGELOG.md └── README.MD /projects/angular2-jsonapi/src/interfaces/model-type.interface.ts: -------------------------------------------------------------------------------- 1 | export type ModelType = new(...args: any[]) => T; 2 | -------------------------------------------------------------------------------- /projects/angular2-jsonapi/src/constants/symbols.ts: -------------------------------------------------------------------------------- 1 | // tslint:disable-next-line:variable-name 2 | export const AttributeMetadata = Symbol('AttributeMetadata') as any; 3 | -------------------------------------------------------------------------------- /projects/angular2-jsonapi/src/interfaces/json-model-converter-config.interface.ts: -------------------------------------------------------------------------------- 1 | export interface JsonModelConverterConfig { 2 | nullValue?: boolean; 3 | hasMany?: boolean; 4 | } 5 | -------------------------------------------------------------------------------- /projects/angular2-jsonapi/src/interfaces/property-converter.interface.ts: -------------------------------------------------------------------------------- 1 | export interface PropertyConverter { 2 | mask(value: any): any; 3 | 4 | unmask(value: any): any; 5 | } 6 | -------------------------------------------------------------------------------- /projects/angular2-jsonapi/src/interfaces/overrides.interface.ts: -------------------------------------------------------------------------------- 1 | export interface Overrides { 2 | getDirtyAttributes?: (attributedMetadata: any, model ?: any) => object; 3 | toQueryString?: (params: any) => string; 4 | } 5 | -------------------------------------------------------------------------------- /projects/angular2-jsonapi/src/providers.ts: -------------------------------------------------------------------------------- 1 | import { JsonApiDatastore } from './services/json-api-datastore.service'; 2 | 3 | export * from './services/json-api-datastore.service'; 4 | 5 | export const PROVIDERS: any[] = [ 6 | JsonApiDatastore 7 | ]; 8 | -------------------------------------------------------------------------------- /projects/angular2-jsonapi/src/decorators/json-api-datastore-config.decorator.ts: -------------------------------------------------------------------------------- 1 | export function JsonApiDatastoreConfig(config: any = {}) { 2 | return (target: any) => { 3 | Reflect.defineMetadata('JsonApiDatastoreConfig', config, target); 4 | }; 5 | } 6 | -------------------------------------------------------------------------------- /projects/angular2-jsonapi/src/interfaces/datastore-config.interface.ts: -------------------------------------------------------------------------------- 1 | import { Overrides } from './overrides.interface'; 2 | 3 | export interface DatastoreConfig { 4 | apiVersion?: string; 5 | baseUrl?: string; 6 | models?: object; 7 | overrides?: Overrides; 8 | } 9 | -------------------------------------------------------------------------------- /projects/angular2-jsonapi/src/interfaces/attribute-decorator-options.interface.ts: -------------------------------------------------------------------------------- 1 | import { PropertyConverter } from './property-converter.interface'; 2 | 3 | export interface AttributeDecoratorOptions { 4 | serializedName?: string; 5 | converter?: PropertyConverter; 6 | } 7 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # Editor configuration, see https://editorconfig.org 2 | root = true 3 | 4 | [*] 5 | charset = utf-8 6 | indent_style = space 7 | indent_size = 2 8 | insert_final_newline = true 9 | trim_trailing_whitespace = true 10 | 11 | [*.md] 12 | max_line_length = off 13 | trim_trailing_whitespace = false 14 | -------------------------------------------------------------------------------- /projects/angular2-jsonapi/src/interfaces/model-config.interface.ts: -------------------------------------------------------------------------------- 1 | import { MetaModelType } from '../models/json-api-meta.model'; 2 | 3 | export interface ModelConfig { 4 | type: string; 5 | apiVersion?: string; 6 | baseUrl?: string; 7 | modelEndpointUrl?: string; 8 | meta?: MetaModelType; 9 | } 10 | -------------------------------------------------------------------------------- /projects/angular2-jsonapi/src/module.ts: -------------------------------------------------------------------------------- 1 | import { NgModule } from '@angular/core'; 2 | import { HttpClientModule } from '@angular/common/http'; 3 | import { PROVIDERS } from './providers'; 4 | 5 | @NgModule({ 6 | providers: [PROVIDERS], 7 | exports: [HttpClientModule] 8 | }) 9 | export class JsonApiModule { 10 | } 11 | -------------------------------------------------------------------------------- /projects/angular2-jsonapi/src/models/json-api-meta.model.ts: -------------------------------------------------------------------------------- 1 | export type MetaModelType = new(response: any) => T; 2 | 3 | export class JsonApiMetaModel { 4 | public links: Array; 5 | public meta: any; 6 | 7 | constructor(response: any) { 8 | this.links = response.links || []; 9 | this.meta = response.meta; 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /projects/angular2-jsonapi/src/models/json-api-query-data.ts: -------------------------------------------------------------------------------- 1 | export class JsonApiQueryData { 2 | constructor(protected jsonApiModels: Array, protected metaData?: any) { 3 | } 4 | 5 | public getModels(): T[] { 6 | return this.jsonApiModels; 7 | } 8 | 9 | public getMeta(): any { 10 | return this.metaData; 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /projects/angular2-jsonapi/tslint.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tslint.json", 3 | "rules": { 4 | "directive-selector": [ 5 | true, 6 | "attribute", 7 | "lib", 8 | "camelCase" 9 | ], 10 | "component-selector": [ 11 | true, 12 | "element", 13 | "lib", 14 | "kebab-case" 15 | ] 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /projects/angular2-jsonapi/tsconfig.spec.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tsconfig.json", 3 | "compilerOptions": { 4 | "outDir": "../../out-tsc/spec", 5 | "types": [ 6 | "jasmine", 7 | "node" 8 | ] 9 | }, 10 | "files": [ 11 | "src/test.ts" 12 | ], 13 | "include": [ 14 | "**/*.spec.ts", 15 | "**/*.d.ts" 16 | ] 17 | } 18 | -------------------------------------------------------------------------------- /projects/angular2-jsonapi/test/models/page-meta-data.ts: -------------------------------------------------------------------------------- 1 | export class MetaData { 2 | page: PageData; 3 | } 4 | 5 | export interface PageData { 6 | total?: number; 7 | number?: number; 8 | size?: number; 9 | last?: number; 10 | } 11 | 12 | export class PageMetaData { 13 | public meta: PageData = {}; 14 | 15 | constructor(response: any) { 16 | this.meta = response.meta; 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /projects/angular2-jsonapi/src/decorators/has-many.decorator.ts: -------------------------------------------------------------------------------- 1 | export function HasMany(config: any = {}) { 2 | return (target: any, propertyName: string | symbol) => { 3 | const annotations = Reflect.getMetadata('HasMany', target) || []; 4 | 5 | annotations.push({ 6 | propertyName, 7 | relationship: config.key || propertyName 8 | }); 9 | 10 | Reflect.defineMetadata('HasMany', annotations, target); 11 | }; 12 | } 13 | -------------------------------------------------------------------------------- /projects/angular2-jsonapi/test/models/school.model.ts: -------------------------------------------------------------------------------- 1 | import { JsonApiNestedModel } from '../../src/models/json-nested.model'; 2 | import { JsonAttribute } from '../../src/decorators/json-attribute.decorator'; 3 | 4 | export class School extends JsonApiNestedModel { 5 | 6 | @JsonAttribute() 7 | public name: string; 8 | 9 | @JsonAttribute() 10 | public students: number; 11 | 12 | @JsonAttribute() 13 | public foundation: Date; 14 | } 15 | -------------------------------------------------------------------------------- /projects/angular2-jsonapi/src/decorators/belongs-to.decorator.ts: -------------------------------------------------------------------------------- 1 | export function BelongsTo(config: any = {}) { 2 | return (target: any, propertyName: string | symbol) => { 3 | const annotations = Reflect.getMetadata('BelongsTo', target) || []; 4 | 5 | annotations.push({ 6 | propertyName, 7 | relationship: config.key || propertyName 8 | }); 9 | 10 | Reflect.defineMetadata('BelongsTo', annotations, target); 11 | }; 12 | } 13 | -------------------------------------------------------------------------------- /projects/angular2-jsonapi/test/fixtures/category.fixture.ts: -------------------------------------------------------------------------------- 1 | export function getSampleCategory(categoryId: string) { 2 | return { 3 | id: '' + categoryId, 4 | type: 'categories', 5 | attributes: { 6 | name: 'Category_fiction', 7 | created_at: '2018-04-02T21:12:41Z', 8 | updated_at: '2016-04-02T21:12:41Z' 9 | }, 10 | relationships: {}, 11 | links: { 12 | self: '/v1/categories/1' 13 | } 14 | }; 15 | } 16 | -------------------------------------------------------------------------------- /projects/angular2-jsonapi/test/models/crime-book.model.ts: -------------------------------------------------------------------------------- 1 | import { Attribute } from '../../src/decorators/attribute.decorator'; 2 | import { JsonApiModelConfig } from '../../src/decorators/json-api-model-config.decorator'; 3 | import { Book } from './book.model'; 4 | 5 | @JsonApiModelConfig({ 6 | type: 'crimeBooks', 7 | modelEndpointUrl: 'books' 8 | }) 9 | export class CrimeBook extends Book { 10 | 11 | @Attribute() 12 | ageLimit: number; 13 | } 14 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | sudo: false 3 | node_js: 4 | - 'lts/*' 5 | - 'node' 6 | 7 | before_install: 8 | - npm i -g npm@latest 9 | 10 | install: 11 | - npm ci 12 | - cd projects/angular2-jsonapi && npm ci && cd ../.. 13 | 14 | script: 15 | - npm test -- --no-watch --no-progress --code-coverage --browsers=ChromeHeadlessCI 16 | - npm run lint 17 | 18 | after_success: 19 | - cat ./coverage/angular2-jsonapi/lcov.info | ./node_modules/coveralls/bin/coveralls.js 20 | -------------------------------------------------------------------------------- /projects/angular2-jsonapi/test/models/thingCategory.ts: -------------------------------------------------------------------------------- 1 | import { Thing } from './thing'; 2 | import { HasMany } from '../../src/decorators/has-many.decorator'; 3 | import { JsonApiModel } from '../../src/models/json-api.model'; 4 | import { JsonApiModelConfig } from '../../src/decorators/json-api-model-config.decorator'; 5 | 6 | @JsonApiModelConfig({ 7 | type: 'thing_category' 8 | }) 9 | export class ThingCategory extends JsonApiModel { 10 | @HasMany() 11 | members: Thing[]; 12 | } 13 | -------------------------------------------------------------------------------- /projects/angular2-jsonapi/ng-package.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "../../node_modules/ng-packagr/ng-package.schema.json", 3 | "dest": "../../dist/angular2-jsonapi", 4 | "lib": { 5 | "entryFile": "src/public-api.ts", 6 | "umdModuleIds": { 7 | "date-fns": "date-fns", 8 | "lodash": "lodash", 9 | "lodash-es": "lodash-es", 10 | "qs": "qs" 11 | } 12 | }, 13 | "whitelistedNonPeerDependencies": [ 14 | "date-fns", 15 | "lodash-es", 16 | "qs", 17 | "tslib" 18 | ] 19 | } 20 | -------------------------------------------------------------------------------- /projects/angular2-jsonapi/src/decorators/json-api-model-config.decorator.ts: -------------------------------------------------------------------------------- 1 | import { JsonApiMetaModel } from '../models/json-api-meta.model'; 2 | import { ModelConfig } from '../interfaces/model-config.interface'; 3 | 4 | export function JsonApiModelConfig(config: ModelConfig) { 5 | return (target: any) => { 6 | if (typeof config.meta === 'undefined' || config.meta == null) { 7 | config.meta = JsonApiMetaModel; 8 | } 9 | 10 | Reflect.defineMetadata('JsonApiModelConfig', config, target); 11 | }; 12 | } 13 | -------------------------------------------------------------------------------- /projects/angular2-jsonapi/src/models/error-response.model.ts: -------------------------------------------------------------------------------- 1 | export interface JsonApiError { 2 | id?: string; 3 | links?: Array; 4 | status?: string; 5 | code?: string; 6 | title?: string; 7 | detail?: string; 8 | source?: { 9 | pointer?: string; 10 | parameter?: string 11 | }; 12 | meta?: any; 13 | } 14 | 15 | export class ErrorResponse { 16 | errors?: JsonApiError[] = []; 17 | 18 | constructor(errors ?: JsonApiError[]) { 19 | if (errors) { 20 | this.errors = errors; 21 | } 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /projects/angular2-jsonapi/src/converters/date/date.converter.ts: -------------------------------------------------------------------------------- 1 | import { parseISO } from 'date-fns'; 2 | import { PropertyConverter } from '../../interfaces/property-converter.interface'; 3 | 4 | export class DateConverter implements PropertyConverter { 5 | mask(value: any) { 6 | if (typeof value === 'string') { 7 | return parseISO(value); 8 | } else { 9 | return value; 10 | } 11 | } 12 | 13 | unmask(value: any) { 14 | if (value === null) { 15 | return null; 16 | } 17 | return value.toISOString(); 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /projects/angular2-jsonapi/test/fixtures/sentence.fixture.ts: -------------------------------------------------------------------------------- 1 | export function getSampleSentence(sentenceId: string, paragraphId: string, content: string = 'Dummy content') { 2 | return { 3 | id: sentenceId, 4 | type: 'sentences', 5 | attributes: { 6 | content, 7 | created_at: '2016-10-01T12:54:32Z', 8 | updated_at: '2016-10-01T12:54:32Z' 9 | }, 10 | relationships: { 11 | paragraph: { 12 | data: { 13 | id: paragraphId, 14 | type: 'paragraphs' 15 | } 16 | } 17 | } 18 | }; 19 | } 20 | -------------------------------------------------------------------------------- /projects/angular2-jsonapi/test/models/thing.ts: -------------------------------------------------------------------------------- 1 | import { ThingCategory } from './thingCategory'; 2 | import { JsonApiModelConfig } from '../../src/decorators/json-api-model-config.decorator'; 3 | import { JsonApiModel } from '../../src/models/json-api.model'; 4 | import { Attribute } from '../../src/decorators/attribute.decorator'; 5 | import { HasMany } from '../../src/decorators/has-many.decorator'; 6 | 7 | @JsonApiModelConfig({ 8 | type: 'thing' 9 | }) 10 | export class Thing extends JsonApiModel { 11 | @Attribute() 12 | name: string; 13 | 14 | @HasMany() 15 | categories: ThingCategory[]; 16 | } 17 | -------------------------------------------------------------------------------- /projects/angular2-jsonapi/test/models/sentence.model.ts: -------------------------------------------------------------------------------- 1 | import { JsonApiModelConfig } from '../../src/decorators/json-api-model-config.decorator'; 2 | import { JsonApiModel } from '../../src/models/json-api.model'; 3 | import { Attribute } from '../../src/decorators/attribute.decorator'; 4 | import { BelongsTo } from '../../src/decorators/belongs-to.decorator'; 5 | import { Paragraph } from './paragraph.model'; 6 | 7 | @JsonApiModelConfig({ 8 | type: 'sentences' 9 | }) 10 | export class Sentence extends JsonApiModel { 11 | @Attribute() 12 | content: string; 13 | 14 | @BelongsTo() 15 | paragraph: Paragraph; 16 | } 17 | -------------------------------------------------------------------------------- /projects/angular2-jsonapi/test/fixtures/section.fixture.ts: -------------------------------------------------------------------------------- 1 | export function getSampleSection(sectionId: string, chapterId: string, content: string = 'Dummy content') { 2 | return { 3 | id: sectionId, 4 | type: 'sections', 5 | attributes: { 6 | content, 7 | created_at: '2016-10-01T12:54:32Z', 8 | updated_at: '2016-10-01T12:54:32Z' 9 | }, 10 | relationships: { 11 | chapter: { 12 | data: { 13 | id: chapterId, 14 | type: 'chapters' 15 | } 16 | }, 17 | firstParagraph: { 18 | data: { 19 | id: '1', 20 | type: 'paragraphs' 21 | } 22 | } 23 | } 24 | }; 25 | } 26 | -------------------------------------------------------------------------------- /projects/angular2-jsonapi/test/fixtures/paragraph.fixture.ts: -------------------------------------------------------------------------------- 1 | export function getSampleParagraph(paragraphId: string, sectionId: string, content: string = 'Dummy content') { 2 | return { 3 | id: paragraphId, 4 | type: 'paragraphs', 5 | attributes: { 6 | content, 7 | created_at: '2016-10-01T12:54:32Z', 8 | updated_at: '2016-10-01T12:54:32Z' 9 | }, 10 | relationships: { 11 | section: { 12 | data: { 13 | id: sectionId, 14 | type: 'sections' 15 | } 16 | }, 17 | firstSentence: { 18 | data: { 19 | id: '1', 20 | type: 'sentences' 21 | } 22 | } 23 | } 24 | }; 25 | } 26 | -------------------------------------------------------------------------------- /projects/angular2-jsonapi/tsconfig.lib.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tsconfig.json", 3 | "compilerOptions": { 4 | "outDir": "../../out-tsc/lib", 5 | "target": "es5", 6 | "declaration": true, 7 | "inlineSources": true, 8 | "types": [], 9 | "lib": [ 10 | "dom", 11 | "es2018" 12 | ] 13 | }, 14 | "angularCompilerOptions": { 15 | "annotateForClosureCompiler": true, 16 | "skipTemplateCodegen": true, 17 | "strictMetadataEmit": true, 18 | "fullTemplateTypeCheck": true, 19 | "strictInjectionParameters": true, 20 | "enableResourceInlining": true 21 | }, 22 | "exclude": [ 23 | "src/test.ts", 24 | "**/*.spec.ts" 25 | ] 26 | } 27 | -------------------------------------------------------------------------------- /projects/angular2-jsonapi/test/models/section.model.ts: -------------------------------------------------------------------------------- 1 | import { JsonApiModelConfig } from '../../src/decorators/json-api-model-config.decorator'; 2 | import { JsonApiModel } from '../../src/models/json-api.model'; 3 | import { Attribute } from '../../src/decorators/attribute.decorator'; 4 | import { BelongsTo } from '../../src/decorators/belongs-to.decorator'; 5 | import { Chapter } from './chapter.model'; 6 | import { Paragraph } from './paragraph.model'; 7 | 8 | @JsonApiModelConfig({ 9 | type: 'sections' 10 | }) 11 | export class Section extends JsonApiModel { 12 | @Attribute() 13 | content: string; 14 | 15 | @BelongsTo() 16 | firstParagraph: Paragraph; 17 | 18 | @BelongsTo() 19 | chapter: Chapter; 20 | } 21 | -------------------------------------------------------------------------------- /projects/angular2-jsonapi/test/models/paragraph.model.ts: -------------------------------------------------------------------------------- 1 | import { JsonApiModelConfig } from '../../src/decorators/json-api-model-config.decorator'; 2 | import { JsonApiModel } from '../../src/models/json-api.model'; 3 | import { Attribute } from '../../src/decorators/attribute.decorator'; 4 | import { BelongsTo } from '../../src/decorators/belongs-to.decorator'; 5 | import { Sentence } from './sentence.model'; 6 | import { Section } from './section.model'; 7 | 8 | @JsonApiModelConfig({ 9 | type: 'paragraphs' 10 | }) 11 | export class Paragraph extends JsonApiModel { 12 | @Attribute() 13 | content: string; 14 | 15 | @BelongsTo() 16 | section: Section; 17 | 18 | @BelongsTo() 19 | firstSentence: Sentence; 20 | } 21 | -------------------------------------------------------------------------------- /projects/angular2-jsonapi/test/models/category.model.ts: -------------------------------------------------------------------------------- 1 | /* tslint:disable:variable-name */ 2 | import { Book } from './book.model'; 3 | import { JsonApiModelConfig } from '../../src/decorators/json-api-model-config.decorator'; 4 | import { JsonApiModel } from '../../src/models/json-api.model'; 5 | import { Attribute } from '../../src/decorators/attribute.decorator'; 6 | import { HasMany } from '../../src/decorators/has-many.decorator'; 7 | 8 | @JsonApiModelConfig({ 9 | type: 'categories' 10 | }) 11 | export class Category extends JsonApiModel { 12 | 13 | @Attribute() 14 | name: string; 15 | 16 | @Attribute() 17 | created_at: Date; 18 | 19 | @Attribute() 20 | updated_at: Date; 21 | 22 | @HasMany() 23 | books: Book[]; 24 | } 25 | -------------------------------------------------------------------------------- /projects/angular2-jsonapi/src/test.ts: -------------------------------------------------------------------------------- 1 | // This file is required by karma.conf.js and loads recursively all the .spec and framework files 2 | 3 | import 'zone.js/dist/zone'; 4 | import 'zone.js/dist/zone-testing'; 5 | import { getTestBed } from '@angular/core/testing'; 6 | import { BrowserDynamicTestingModule, platformBrowserDynamicTesting } from '@angular/platform-browser-dynamic/testing'; 7 | 8 | declare const require: any; 9 | 10 | // First, initialize the Angular testing environment. 11 | getTestBed().initTestEnvironment( 12 | BrowserDynamicTestingModule, 13 | platformBrowserDynamicTesting() 14 | ); 15 | // Then we find all the tests. 16 | const context = require.context('./', true, /\.spec\.ts$/); 17 | // And load the modules. 18 | context.keys().map(context); 19 | -------------------------------------------------------------------------------- /projects/angular2-jsonapi/test/models/chapter.model.ts: -------------------------------------------------------------------------------- 1 | /* tslint:disable:variable-name */ 2 | import { Book } from './book.model'; 3 | import { JsonApiModelConfig } from '../../src/decorators/json-api-model-config.decorator'; 4 | import { JsonApiModel } from '../../src/models/json-api.model'; 5 | import { Attribute } from '../../src/decorators/attribute.decorator'; 6 | import { BelongsTo } from '../../src/decorators/belongs-to.decorator'; 7 | import { Section } from './section.model'; 8 | 9 | @JsonApiModelConfig({ 10 | type: 'chapters' 11 | }) 12 | export class Chapter extends JsonApiModel { 13 | 14 | @Attribute() 15 | title: string; 16 | 17 | @Attribute() 18 | ordering: number; 19 | 20 | @Attribute() 21 | created_at: Date; 22 | 23 | @Attribute() 24 | updated_at: Date; 25 | 26 | @BelongsTo() 27 | book: Book; 28 | 29 | @BelongsTo() 30 | firstSection: Section; 31 | } 32 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compileOnSave": false, 3 | "compilerOptions": { 4 | "baseUrl": "./", 5 | "outDir": "./dist/out-tsc", 6 | "sourceMap": true, 7 | "declaration": false, 8 | "downlevelIteration": true, 9 | "emitDecoratorMetadata": true, 10 | "experimentalDecorators": true, 11 | "module": "esnext", 12 | "moduleResolution": "node", 13 | "importHelpers": true, 14 | "target": "es5", 15 | "typeRoots": [ 16 | "node_modules/@types" 17 | ], 18 | "lib": [ 19 | "es2018", 20 | "dom" 21 | ], 22 | "paths": { 23 | "angular2-jsonapi": [ 24 | "dist/angular2-jsonapi" 25 | ], 26 | "angular2-jsonapi/*": [ 27 | "dist/angular2-jsonapi/*" 28 | ] 29 | } 30 | }, 31 | "angularCompilerOptions": { 32 | "fullTemplateTypeCheck": true, 33 | "strictInjectionParameters": true 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /projects/angular2-jsonapi/test/fixtures/chapter.fixture.ts: -------------------------------------------------------------------------------- 1 | export function getSampleChapter(i: number, chapterId: string, chapterTitle: string = 'Dummy title') { 2 | return { 3 | id: chapterId, 4 | type: 'chapters', 5 | attributes: { 6 | title: chapterTitle, 7 | ordering: parseInt(chapterId, 10), 8 | created_at: '2016-10-01T12:54:32Z', 9 | updated_at: '2016-10-01T12:54:32Z' 10 | }, 11 | relationships: { 12 | book: { 13 | links: { 14 | self: '/v1/authors/288/relationships/book', 15 | related: '/v1/authors/288/book' 16 | }, 17 | data: { 18 | id: '' + i, 19 | type: 'books' 20 | } 21 | }, 22 | firstSection: { 23 | data: { 24 | id: '1', 25 | type: 'sections' 26 | } 27 | } 28 | }, 29 | links: { 30 | self: '/v1/authors/288' 31 | } 32 | }; 33 | } 34 | -------------------------------------------------------------------------------- /projects/angular2-jsonapi/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "angular2-jsonapi", 3 | "version": "8.2.1", 4 | "description": "A lightweight Angular 2 adapter for JSON API", 5 | "repository": { 6 | "type": "git", 7 | "url": "https://github.com/ghidoz/angular2-jsonapi" 8 | }, 9 | "author": { 10 | "name": "Daniele Ghidoli", 11 | "url": "http://danieleghidoli.it" 12 | }, 13 | "keywords": [ 14 | "angular", 15 | "angular2", 16 | "json", 17 | "jsonapi", 18 | "api" 19 | ], 20 | "license": "MIT", 21 | "bugs": { 22 | "url": "https://github.com/ghidoz/angular2-jsonapi/issues" 23 | }, 24 | "dependencies": { 25 | "date-fns": "^2.2.1", 26 | "lodash-es": "^4.17.15", 27 | "qs": "^6.8.0", 28 | "tslib": "^1.10.0" 29 | }, 30 | "peerDependencies": { 31 | "@angular/common": "^8.2.5", 32 | "@angular/core": "^8.2.5", 33 | "reflect-metadata": "^0.1.13", 34 | "rxjs": "^6.5.3" 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /projects/angular2-jsonapi/test/fixtures/thing.fixture.ts: -------------------------------------------------------------------------------- 1 | export function getSampleThing(thingId: string = 'thing_1', 2 | thingCategoryId: string = 'thing_category_1', 3 | thingName: string = 'Thing One') { 4 | return { 5 | data: [ 6 | { 7 | id: `${thingId}`, 8 | type: 'thing', 9 | attributes: { 10 | name: `${thingName}` 11 | }, 12 | relationships: { 13 | categories: { 14 | data: [ 15 | { 16 | id: `${thingCategoryId}`, 17 | type: 'thing_category' 18 | } 19 | ] 20 | } 21 | } 22 | } 23 | ], 24 | included: [ 25 | { 26 | id: `${thingCategoryId}`, 27 | type: 'thing_category', 28 | relationships: { 29 | members: { 30 | data: [ 31 | { 32 | id: `${thingId}`, 33 | type: 'thing' 34 | } 35 | ] 36 | } 37 | } 38 | }, 39 | ] 40 | }; 41 | } 42 | -------------------------------------------------------------------------------- /projects/angular2-jsonapi/test/models/custom-author.model.ts: -------------------------------------------------------------------------------- 1 | /* tslint:disable:variable-name */ 2 | import { Book } from './book.model'; 3 | import { JsonApiModelConfig } from '../../src/decorators/json-api-model-config.decorator'; 4 | import { JsonApiModel } from '../../src/models/json-api.model'; 5 | import { Attribute } from '../../src/decorators/attribute.decorator'; 6 | import { HasMany } from '../../src/decorators/has-many.decorator'; 7 | import { PageMetaData } from './page-meta-data'; 8 | 9 | export const AUTHOR_API_VERSION = 'v3'; 10 | export const AUTHOR_MODEL_ENDPOINT_URL = 'custom-author'; 11 | 12 | @JsonApiModelConfig({ 13 | apiVersion: AUTHOR_API_VERSION, 14 | modelEndpointUrl: AUTHOR_MODEL_ENDPOINT_URL, 15 | type: 'authors', 16 | meta: PageMetaData 17 | }) 18 | export class CustomAuthor extends JsonApiModel { 19 | @Attribute() 20 | name: string; 21 | 22 | @Attribute() 23 | date_of_birth: Date; 24 | 25 | @Attribute() 26 | date_of_death: Date; 27 | 28 | @Attribute() 29 | created_at: Date; 30 | 31 | @Attribute() 32 | updated_at: Date; 33 | 34 | @HasMany() 35 | books: Book[]; 36 | } 37 | -------------------------------------------------------------------------------- /projects/angular2-jsonapi/src/public-api.ts: -------------------------------------------------------------------------------- 1 | export * from './converters/json-model/json-model.converter'; 2 | 3 | export * from './decorators/has-many.decorator'; 4 | export * from './decorators/belongs-to.decorator'; 5 | export * from './decorators/attribute.decorator'; 6 | export * from './decorators/nested-attribute.decorator'; 7 | export * from './decorators/json-attribute.decorator'; 8 | export * from './decorators/json-api-model-config.decorator'; 9 | export * from './decorators/json-api-datastore-config.decorator'; 10 | 11 | export * from './models/json-api-meta.model'; 12 | export * from './models/json-api.model'; 13 | export * from './models/json-nested.model'; 14 | export * from './models/error-response.model'; 15 | export * from './models/json-api-query-data'; 16 | 17 | export * from './interfaces/overrides.interface'; 18 | export * from './interfaces/json-model-converter-config.interface'; 19 | export * from './interfaces/datastore-config.interface'; 20 | export * from './interfaces/model-config.interface'; 21 | export * from './interfaces/attribute-decorator-options.interface'; 22 | export * from './interfaces/property-converter.interface'; 23 | 24 | export * from './providers'; 25 | 26 | export * from './module'; 27 | -------------------------------------------------------------------------------- /projects/angular2-jsonapi/test/models/book.model.ts: -------------------------------------------------------------------------------- 1 | /* tslint:disable:variable-name */ 2 | import { Chapter } from './chapter.model'; 3 | import { Author } from './author.model'; 4 | import { Category } from './category.model'; 5 | import { JsonApiModelConfig } from '../../src/decorators/json-api-model-config.decorator'; 6 | import { JsonApiModel } from '../../src/models/json-api.model'; 7 | import { Attribute } from '../../src/decorators/attribute.decorator'; 8 | import { HasMany } from '../../src/decorators/has-many.decorator'; 9 | import { BelongsTo } from '../../src/decorators/belongs-to.decorator'; 10 | 11 | @JsonApiModelConfig({ 12 | type: 'books' 13 | }) 14 | export class Book extends JsonApiModel { 15 | 16 | @Attribute() 17 | title: string; 18 | 19 | @Attribute() 20 | date_published: Date; 21 | 22 | @Attribute() 23 | created_at: Date; 24 | 25 | @Attribute() 26 | updated_at: Date; 27 | 28 | @HasMany() 29 | chapters: Chapter[]; 30 | 31 | @HasMany({key: 'important-chapters'}) 32 | importantChapters: Chapter[]; 33 | 34 | @BelongsTo({key: 'first-chapter'}) 35 | firstChapter: Chapter; 36 | 37 | @BelongsTo() 38 | author: Author; 39 | 40 | @BelongsTo() 41 | category: Category; 42 | } 43 | -------------------------------------------------------------------------------- /projects/angular2-jsonapi/package-lock.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "angular2-jsonapi", 3 | "version": "8.2.1-beta", 4 | "lockfileVersion": 1, 5 | "requires": true, 6 | "dependencies": { 7 | "date-fns": { 8 | "version": "2.2.1", 9 | "resolved": "https://registry.npmjs.org/date-fns/-/date-fns-2.2.1.tgz", 10 | "integrity": "sha512-4V1i5CnTinjBvJpXTq7sDHD4NY6JPcl15112IeSNNLUWQOQ+kIuCvRGOFZMQZNvkadw8F9QTyZxz59rIRU6K+w==" 11 | }, 12 | "lodash-es": { 13 | "version": "4.17.15", 14 | "resolved": "https://registry.npmjs.org/lodash-es/-/lodash-es-4.17.15.tgz", 15 | "integrity": "sha512-rlrc3yU3+JNOpZ9zj5pQtxnx2THmvRykwL4Xlxoa8I9lHBlVbbyPhgyPMioxVZ4NqyxaVVtaJnzsyOidQIhyyQ==" 16 | }, 17 | "qs": { 18 | "version": "6.8.0", 19 | "resolved": "https://registry.npmjs.org/qs/-/qs-6.8.0.tgz", 20 | "integrity": "sha512-tPSkj8y92PfZVbinY1n84i1Qdx75lZjMQYx9WZhnkofyxzw2r7Ho39G3/aEvSUdebxpnnM4LZJCtvE/Aq3+s9w==" 21 | }, 22 | "tslib": { 23 | "version": "1.10.0", 24 | "resolved": "https://registry.npmjs.org/tslib/-/tslib-1.10.0.tgz", 25 | "integrity": "sha512-qOebF53frne81cf0S9B41ByenJ3/IuH8yJKngAX35CmiZySA0khhkovshKK+jGCaMnVomla7gVlIcc3EvKPbTQ==" 26 | } 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /projects/angular2-jsonapi/src/converters/date/date.converter.spec.ts: -------------------------------------------------------------------------------- 1 | import { DateConverter } from './date.converter'; 2 | import { parseISO } from 'date-fns'; 3 | 4 | describe('Date converter', () => { 5 | const converter: DateConverter = new DateConverter(); 6 | 7 | describe('mask method', () => { 8 | 9 | it('Null stays null', () => { 10 | const value = converter.mask(null); 11 | expect(value).toBeNull(); 12 | }); 13 | 14 | it ( 'string is transformed to Date object', () => { 15 | const value = converter.mask('2019-11-11'); 16 | expect(value instanceof Date).toBeTruthy(); 17 | }); 18 | 19 | it ( 'empty string is transformed to Date object', () => { 20 | const value = converter.mask(''); 21 | expect(value instanceof Date).toBeTruthy(); 22 | }); 23 | }); 24 | 25 | describe('unmask method', () => { 26 | 27 | it('Null stays null', () => { 28 | const value = converter.unmask(null); 29 | expect(value).toBeNull(); 30 | }); 31 | 32 | it ( 'Date to be transformed to string', () => { 33 | const value = converter.unmask(parseISO('2019-11-11')); 34 | expect(typeof value === 'string').toBeTruthy(); 35 | }); 36 | }); 37 | 38 | }); 39 | -------------------------------------------------------------------------------- /projects/angular2-jsonapi/test/models/author.model.ts: -------------------------------------------------------------------------------- 1 | /* tslint:disable:variable-name */ 2 | import { Book } from './book.model'; 3 | import { JsonApiModelConfig } from '../../src/decorators/json-api-model-config.decorator'; 4 | import { JsonApiModel } from '../../src/models/json-api.model'; 5 | import { Attribute } from '../../src/decorators/attribute.decorator'; 6 | import { PageMetaData } from './page-meta-data'; 7 | import { School } from './school.model'; 8 | import { HasMany } from '../../src/decorators/has-many.decorator'; 9 | import { NestedAttribute } from '../../src/decorators/nested-attribute.decorator'; 10 | import { JsonModelConverter } from '../../src/converters/json-model/json-model.converter'; 11 | 12 | @JsonApiModelConfig({ 13 | type: 'authors', 14 | meta: PageMetaData, 15 | }) 16 | export class Author extends JsonApiModel { 17 | @Attribute() 18 | name: string; 19 | 20 | @Attribute({ 21 | serializedName: 'dob' 22 | }) 23 | date_of_birth: Date; 24 | 25 | @Attribute() 26 | created_at: Date; 27 | 28 | @Attribute() 29 | updated_at: Date; 30 | 31 | @Attribute() 32 | firstNames: string[]; 33 | 34 | @HasMany() 35 | books: Book[]; 36 | 37 | @NestedAttribute({converter: new JsonModelConverter(School)}) 38 | school: School; 39 | } 40 | -------------------------------------------------------------------------------- /projects/angular2-jsonapi/test/datastore.service.ts: -------------------------------------------------------------------------------- 1 | import { HttpClient } from '@angular/common/http'; 2 | import { Author } from './models/author.model'; 3 | import { Book } from './models/book.model'; 4 | import { Chapter } from './models/chapter.model'; 5 | import { Section } from './models/section.model'; 6 | import { Paragraph } from './models/paragraph.model'; 7 | import { Sentence } from './models/sentence.model'; 8 | import { Category } from './models/category.model'; 9 | import { Thing } from './models/thing'; 10 | import { ThingCategory } from './models/thingCategory'; 11 | import { JsonApiDatastoreConfig } from '../src/decorators/json-api-datastore-config.decorator'; 12 | import { JsonApiDatastore } from '../src/services/json-api-datastore.service'; 13 | 14 | export const BASE_URL = 'http://localhost:8080'; 15 | export const API_VERSION = 'v1'; 16 | 17 | @JsonApiDatastoreConfig({ 18 | baseUrl: BASE_URL, 19 | apiVersion: API_VERSION, 20 | models: { 21 | authors: Author, 22 | books: Book, 23 | chapters: Chapter, 24 | categories: Category, 25 | paragraphs: Paragraph, 26 | sections: Section, 27 | sentences: Sentence, 28 | thing: Thing, 29 | thing_category: ThingCategory 30 | } 31 | }) 32 | export class Datastore extends JsonApiDatastore { 33 | constructor(http: HttpClient) { 34 | super(http); 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /projects/angular2-jsonapi/src/models/json-nested.model.ts: -------------------------------------------------------------------------------- 1 | import { ModelConfig } from '../interfaces/model-config.interface'; 2 | import { JsonApiModel } from './json-api.model'; 3 | 4 | export class JsonApiNestedModel { 5 | [key: string]: any; 6 | 7 | public nestedDataSerialization = false; 8 | 9 | constructor(data?: any) { 10 | if (data) { 11 | Object.assign(this, data); 12 | } 13 | } 14 | 15 | get modelConfig(): ModelConfig { 16 | return Reflect.getMetadata('JsonApiModelConfig', this.constructor); 17 | } 18 | 19 | public fill(data: any) { 20 | Object.assign(this, data); 21 | } 22 | 23 | public serialize(): any { 24 | return this.transformSerializedNamesToPropertyNames(); 25 | } 26 | 27 | protected transformSerializedNamesToPropertyNames() { 28 | const serializedNameToPropertyName = this.getModelPropertyNames(); 29 | const properties: any = {}; 30 | Object.keys(serializedNameToPropertyName).forEach((serializedName) => { 31 | if (this && this[serializedName] !== null && 32 | this[serializedName] !== undefined && serializedName !== 'nestedDataSerialization') { 33 | properties[serializedNameToPropertyName[serializedName]] = this[serializedName]; 34 | } 35 | }); 36 | 37 | return properties; 38 | } 39 | 40 | protected getModelPropertyNames() { 41 | return Reflect.getMetadata('AttributeMapping', this) || []; 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /projects/angular2-jsonapi/test/datastore-with-config.service.ts: -------------------------------------------------------------------------------- 1 | import { HttpClient } from '@angular/common/http'; 2 | import { Author } from './models/author.model'; 3 | import { Book } from './models/book.model'; 4 | import { Chapter } from './models/chapter.model'; 5 | import { Section } from './models/section.model'; 6 | import { Paragraph } from './models/paragraph.model'; 7 | import { Sentence } from './models/sentence.model'; 8 | import { JsonApiDatastoreConfig } from '../src/decorators/json-api-datastore-config.decorator'; 9 | import { JsonApiDatastore } from '../src/services/json-api-datastore.service'; 10 | import { DatastoreConfig } from '../src/interfaces/datastore-config.interface'; 11 | 12 | const BASE_URL = 'http://localhost:8080'; 13 | const API_VERSION = 'v1'; 14 | 15 | export const BASE_URL_FROM_CONFIG = 'http://localhost:8888'; 16 | export const API_VERSION_FROM_CONFIG = 'v2'; 17 | 18 | @JsonApiDatastoreConfig({ 19 | baseUrl: BASE_URL, 20 | apiVersion: API_VERSION, 21 | models: { 22 | authors: Author, 23 | books: Book, 24 | chapters: Chapter, 25 | paragraphs: Paragraph, 26 | sections: Section, 27 | sentences: Sentence, 28 | } 29 | }) 30 | export class DatastoreWithConfig extends JsonApiDatastore { 31 | protected config: DatastoreConfig = { 32 | baseUrl: BASE_URL_FROM_CONFIG, 33 | apiVersion: API_VERSION_FROM_CONFIG 34 | }; 35 | 36 | constructor(http: HttpClient) { 37 | super(http); 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /projects/angular2-jsonapi/test/fixtures/book.fixture.ts: -------------------------------------------------------------------------------- 1 | import { BOOK_PUBLISHED, BOOK_TITLE } from './author.fixture'; 2 | 3 | export function getSampleBook(i: number, authorId: string, categoryId: string = '1') { 4 | return { 5 | id: '' + i, 6 | type: 'books', 7 | attributes: { 8 | date_published: BOOK_PUBLISHED, 9 | title: BOOK_TITLE, 10 | created_at: '2016-09-26T21:12:41Z', 11 | updated_at: '2016-09-26T21:12:41Z' 12 | }, 13 | relationships: { 14 | chapters: { 15 | links: { 16 | self: '/v1/books/1/relationships/chapters', 17 | related: '/v1/books/1/chapters' 18 | } 19 | }, 20 | firstChapter: { 21 | links: { 22 | self: '/v1/books/1/relationships/firstChapter', 23 | related: '/v1/books/1/firstChapter' 24 | } 25 | }, 26 | author: { 27 | links: { 28 | self: '/v1/books/1/relationships/author', 29 | related: '/v1/books/1/author' 30 | }, 31 | data: { 32 | id: authorId, 33 | type: 'authors' 34 | } 35 | }, 36 | category: { 37 | links: { 38 | self: '/v1/books/1/relationships/category', 39 | related: '/v1/books/1/category' 40 | }, 41 | data: { 42 | id: categoryId, 43 | type: 'categories' 44 | } 45 | } 46 | }, 47 | links: { 48 | self: '/v1/books/1' 49 | } 50 | }; 51 | } 52 | -------------------------------------------------------------------------------- /projects/angular2-jsonapi/karma.conf.js: -------------------------------------------------------------------------------- 1 | // Karma configuration file, see link for more information 2 | // https://karma-runner.github.io/1.0/config/configuration-file.html 3 | 4 | module.exports = function (config) { 5 | config.set({ 6 | basePath: '', 7 | frameworks: ['jasmine', '@angular-devkit/build-angular'], 8 | plugins: [ 9 | require('karma-jasmine'), 10 | require('karma-chrome-launcher'), 11 | require('karma-jasmine-html-reporter'), 12 | require('karma-coverage-istanbul-reporter'), 13 | require('@angular-devkit/build-angular/plugins/karma') 14 | ], 15 | client: { 16 | clearContext: false // leave Jasmine Spec Runner output visible in browser 17 | }, 18 | coverageIstanbulReporter: { 19 | dir: require('path').join(__dirname, '../../coverage/angular2-jsonapi'), 20 | reports: ['html', 'lcovonly', 'text-summary'], 21 | fixWebpackSourcePaths: true 22 | }, 23 | reporters: ['progress', 'kjhtml'], 24 | port: 9876, 25 | colors: true, 26 | logLevel: config.LOG_INFO, 27 | autoWatch: true, 28 | browsers: ['Chrome'], 29 | singleRun: false, 30 | restartOnFileChange: true, 31 | customLaunchers: { 32 | // We can't use the sandbox in container so we disable it. 33 | // See https://github.com/travis-ci/travis-ci/issues/8836#issuecomment-359018652 34 | ChromeHeadlessCI: { 35 | base: 'ChromeHeadless', 36 | flags: ['--no-sandbox'] 37 | } 38 | } 39 | }); 40 | }; 41 | -------------------------------------------------------------------------------- /angular.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "./node_modules/@angular/cli/lib/config/schema.json", 3 | "version": 1, 4 | "newProjectRoot": "projects", 5 | "projects": { 6 | "angular2-jsonapi": { 7 | "projectType": "library", 8 | "root": "projects/angular2-jsonapi", 9 | "sourceRoot": "projects/angular2-jsonapi/src", 10 | "prefix": "lib", 11 | "architect": { 12 | "build": { 13 | "builder": "@angular-devkit/build-ng-packagr:build", 14 | "options": { 15 | "tsConfig": "projects/angular2-jsonapi/tsconfig.lib.json", 16 | "project": "projects/angular2-jsonapi/ng-package.json" 17 | } 18 | }, 19 | "test": { 20 | "builder": "@angular-devkit/build-angular:karma", 21 | "options": { 22 | "main": "projects/angular2-jsonapi/src/test.ts", 23 | "tsConfig": "projects/angular2-jsonapi/tsconfig.spec.json", 24 | "karmaConfig": "projects/angular2-jsonapi/karma.conf.js", 25 | "sourceMap": true 26 | } 27 | }, 28 | "lint": { 29 | "builder": "@angular-devkit/build-angular:tslint", 30 | "options": { 31 | "tsConfig": [ 32 | "projects/angular2-jsonapi/tsconfig.lib.json", 33 | "projects/angular2-jsonapi/tsconfig.spec.json" 34 | ], 35 | "exclude": [ 36 | "**/node_modules/**" 37 | ] 38 | } 39 | } 40 | } 41 | } 42 | }, 43 | "defaultProject": "angular2-jsonapi" 44 | } 45 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "angular2-jsonapi-workspace", 3 | "version": "0.0.0", 4 | "private": true, 5 | "scripts": { 6 | "build": "ng build", 7 | "e2e": "ng e2e", 8 | "lint": "ng lint", 9 | "ng": "ng", 10 | "start": "ng serve", 11 | "test": "ng test" 12 | }, 13 | "dependencies": {}, 14 | "devDependencies": { 15 | "@angular-devkit/build-angular": "~0.803.4", 16 | "@angular-devkit/build-ng-packagr": "~0.803.4", 17 | "@angular/animations": "~8.2.6", 18 | "@angular/cli": "~8.3.4", 19 | "@angular/common": "~8.2.6", 20 | "@angular/compiler": "~8.2.6", 21 | "@angular/compiler-cli": "~8.2.6", 22 | "@angular/core": "~8.2.6", 23 | "@angular/forms": "~8.2.6", 24 | "@angular/language-service": "~8.2.6", 25 | "@angular/platform-browser": "~8.2.6", 26 | "@angular/platform-browser-dynamic": "~8.2.6", 27 | "@angular/router": "~8.2.6", 28 | "@types/jasmine": "~3.4.0", 29 | "@types/jasminewd2": "~2.0.6", 30 | "@types/node": "~12.7.5", 31 | "codelyzer": "^5.1.0", 32 | "coveralls": "^3.0.6", 33 | "jasmine-core": "~3.4.0", 34 | "jasmine-spec-reporter": "~4.2.1", 35 | "karma": "~6.3.16", 36 | "karma-chrome-launcher": "~3.1.0", 37 | "karma-coverage-istanbul-reporter": "~2.1.0", 38 | "karma-jasmine": "~2.0.1", 39 | "karma-jasmine-html-reporter": "^1.4.2", 40 | "ng-packagr": "^5.5.0", 41 | "protractor": "~5.4.2", 42 | "reflect-metadata": "^0.1.13", 43 | "rxjs": "~6.5.3", 44 | "ts-node": "~8.3.0", 45 | "tsickle": "^0.37.0", 46 | "tslib": "^1.10.0", 47 | "tslint": "~5.20.0", 48 | "typescript": "~3.5.3", 49 | "zone.js": "~0.10.2" 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /tslint.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "tslint:recommended", 3 | "rulesDirectory": [ 4 | "codelyzer" 5 | ], 6 | "rules": { 7 | "array-type": false, 8 | "arrow-parens": false, 9 | "deprecation": { 10 | "severity": "warning" 11 | }, 12 | "import-blacklist": [ 13 | true, 14 | "rxjs/Rx" 15 | ], 16 | "interface-name": false, 17 | "max-classes-per-file": false, 18 | "max-line-length": [ 19 | true, 20 | 140 21 | ], 22 | "member-access": false, 23 | "member-ordering": [ 24 | true, 25 | { 26 | "order": [ 27 | "static-field", 28 | "instance-field", 29 | "static-method", 30 | "instance-method" 31 | ] 32 | } 33 | ], 34 | "no-consecutive-blank-lines": false, 35 | "no-console": [ 36 | true, 37 | "debug", 38 | "info", 39 | "time", 40 | "timeEnd", 41 | "trace" 42 | ], 43 | "no-empty": false, 44 | "no-inferrable-types": [ 45 | true, 46 | "ignore-params" 47 | ], 48 | "no-non-null-assertion": true, 49 | "no-redundant-jsdoc": true, 50 | "no-switch-case-fall-through": true, 51 | "no-var-requires": false, 52 | "object-literal-key-quotes": [ 53 | true, 54 | "as-needed" 55 | ], 56 | "object-literal-sort-keys": false, 57 | "ordered-imports": false, 58 | "quotemark": [ 59 | true, 60 | "single" 61 | ], 62 | "trailing-comma": false, 63 | "component-class-suffix": true, 64 | "contextual-lifecycle": true, 65 | "directive-class-suffix": true, 66 | "no-conflicting-lifecycle": true, 67 | "no-host-metadata-property": true, 68 | "no-input-rename": true, 69 | "no-inputs-metadata-property": true, 70 | "no-output-native": true, 71 | "no-output-on-prefix": true, 72 | "no-output-rename": true, 73 | "no-outputs-metadata-property": true, 74 | "template-banana-in-box": true, 75 | "template-no-negated-async": true, 76 | "use-lifecycle-interface": true, 77 | "use-pipe-transform-interface": true, 78 | "whitespace": [ 79 | true, 80 | "check-module" 81 | ] 82 | } 83 | } 84 | -------------------------------------------------------------------------------- /projects/angular2-jsonapi/src/decorators/json-attribute.decorator.ts: -------------------------------------------------------------------------------- 1 | import { AttributeDecoratorOptions } from '../interfaces/attribute-decorator-options.interface'; 2 | import { DateConverter } from '../converters/date/date.converter'; 3 | 4 | export function JsonAttribute(options: AttributeDecoratorOptions = {}): PropertyDecorator { 5 | return (target: any, propertyName: string) => { 6 | const converter = (dataType: any, value: any, forSerialisation = false): any => { 7 | let attrConverter; 8 | 9 | if (options.converter) { 10 | attrConverter = options.converter; 11 | } else if (dataType === Date) { 12 | attrConverter = new DateConverter(); 13 | } else { 14 | const datatype = new dataType(); 15 | 16 | if (datatype.mask && datatype.unmask) { 17 | attrConverter = datatype; 18 | } 19 | } 20 | 21 | if (attrConverter) { 22 | if (!forSerialisation) { 23 | return attrConverter.mask(value); 24 | } 25 | return attrConverter.unmask(value); 26 | } 27 | 28 | return value; 29 | }; 30 | 31 | const saveAnnotations = () => { 32 | const metadata = Reflect.getMetadata('JsonAttribute', target) || {}; 33 | 34 | metadata[propertyName] = { 35 | marked: true 36 | }; 37 | 38 | Reflect.defineMetadata('JsonAttribute', metadata, target); 39 | 40 | const mappingMetadata = Reflect.getMetadata('AttributeMapping', target) || {}; 41 | const serializedPropertyName = options.serializedName !== undefined ? options.serializedName : propertyName; 42 | mappingMetadata[serializedPropertyName] = propertyName; 43 | Reflect.defineMetadata('AttributeMapping', mappingMetadata, target); 44 | }; 45 | 46 | const getter = function() { 47 | if (this.nestedDataSerialization) { 48 | return converter(Reflect.getMetadata('design:type', target, propertyName), this[`_${propertyName}`], true); 49 | } 50 | return this[`_${propertyName}`]; 51 | }; 52 | 53 | const setter = function(newVal: any) { 54 | const targetType = Reflect.getMetadata('design:type', target, propertyName); 55 | this[`_${propertyName}`] = converter(targetType, newVal); 56 | }; 57 | 58 | if (delete target[propertyName]) { 59 | saveAnnotations(); 60 | Object.defineProperty(target, propertyName, { 61 | get: getter, 62 | set: setter, 63 | enumerable: true, 64 | configurable: true 65 | }); 66 | } 67 | }; 68 | } 69 | -------------------------------------------------------------------------------- /projects/angular2-jsonapi/src/converters/json-model/json-model.converter.ts: -------------------------------------------------------------------------------- 1 | import { JsonModelConverterConfig } from '../../interfaces/json-model-converter-config.interface'; 2 | import { PropertyConverter } from '../../interfaces/property-converter.interface'; 3 | import { JsonApiNestedModel } from '../../models/json-nested.model'; 4 | 5 | export const DEFAULT_OPTIONS: JsonModelConverterConfig = { 6 | nullValue: false, 7 | hasMany: false 8 | }; 9 | 10 | export class JsonModelConverter implements PropertyConverter { 11 | private modelType: any; // ModelType 12 | private options: JsonModelConverterConfig; 13 | 14 | constructor(model: T, options: JsonModelConverterConfig = {}) { 15 | this.modelType = model; // >model 16 | this.options = {...DEFAULT_OPTIONS, ...options}; 17 | } 18 | 19 | mask(value: any): T | Array { 20 | if (!value && !this.options.nullValue) { 21 | if (this.options.hasMany) { 22 | return []; 23 | } 24 | return new this.modelType(); 25 | } 26 | 27 | let result = null; 28 | if (this.options.hasMany) { 29 | if (!Array.isArray(value)) { 30 | throw new Error(`ERROR: JsonModelConverter: Expected array but got ${typeof value}.`); 31 | } 32 | result = []; 33 | for (const item of value) { 34 | if (item === null) { 35 | continue; 36 | } 37 | let temp; 38 | if (typeof item === 'object') { 39 | temp = new this.modelType(); 40 | temp.fill(item); 41 | } else { 42 | temp = item; 43 | } 44 | 45 | result.push(temp); 46 | } 47 | } else { 48 | if (!(value instanceof this.modelType)) { 49 | result = new this.modelType(); 50 | result.fill(value); 51 | } else { 52 | result = value; 53 | } 54 | } 55 | return result; 56 | } 57 | 58 | unmask(value: any): any { 59 | if (!value) { 60 | return value; 61 | } 62 | let result = null; 63 | if (Array.isArray(value)) { 64 | result = []; 65 | for (const item of value) { 66 | if (!item) { 67 | continue; 68 | } 69 | if (item instanceof JsonApiNestedModel) { 70 | item.nestedDataSerialization = true; 71 | result.push(item.serialize()); 72 | item.nestedDataSerialization = false; 73 | } else { 74 | result.push(item); 75 | } 76 | } 77 | } else { 78 | if (value instanceof JsonApiNestedModel) { 79 | value.nestedDataSerialization = true; 80 | result = value.serialize(); 81 | value.nestedDataSerialization = false; 82 | } else { 83 | result = value; 84 | } 85 | } 86 | return result; 87 | } 88 | } 89 | -------------------------------------------------------------------------------- /projects/angular2-jsonapi/src/decorators/nested-attribute.decorator.ts: -------------------------------------------------------------------------------- 1 | import { AttributeMetadata } from '../constants/symbols'; 2 | import { AttributeDecoratorOptions } from '../interfaces/attribute-decorator-options.interface'; 3 | import * as _ from 'lodash'; 4 | 5 | export function NestedAttribute(options: AttributeDecoratorOptions = {}): PropertyDecorator { 6 | return (target: any, propertyName: string) => { 7 | const converter = (dataType: any, value: any, forSerialisation = false): any => { 8 | let attrConverter; 9 | 10 | if (options.converter) { 11 | attrConverter = options.converter; 12 | } else { 13 | const datatype = new dataType(); 14 | 15 | if (datatype.mask && datatype.unmask) { 16 | attrConverter = datatype; 17 | } 18 | } 19 | 20 | if (attrConverter) { 21 | if (!forSerialisation) { 22 | return attrConverter.mask(value); 23 | } 24 | return attrConverter.unmask(value); 25 | } 26 | 27 | return value; 28 | }; 29 | 30 | const saveAnnotations = () => { 31 | const metadata = Reflect.getMetadata('NestedAttribute', target) || {}; 32 | 33 | metadata[propertyName] = { 34 | marked: true 35 | }; 36 | 37 | Reflect.defineMetadata('NestedAttribute', metadata, target); 38 | 39 | const mappingMetadata = Reflect.getMetadata('AttributeMapping', target) || {}; 40 | const serializedPropertyName = options.serializedName !== undefined ? options.serializedName : propertyName; 41 | mappingMetadata[serializedPropertyName] = propertyName; 42 | Reflect.defineMetadata('AttributeMapping', mappingMetadata, target); 43 | }; 44 | 45 | const updateMetadata = (instance: any) => { 46 | const newValue = instance[`_${propertyName}`]; 47 | 48 | if (!instance[AttributeMetadata]) { 49 | instance[AttributeMetadata] = {}; 50 | } 51 | if (instance[AttributeMetadata][propertyName] && !instance.isModelInitialization()) { 52 | instance[AttributeMetadata][propertyName].newValue = newValue; 53 | instance[AttributeMetadata][propertyName].hasDirtyAttributes = !_.isEqual( 54 | instance[AttributeMetadata][propertyName].oldValue, 55 | newValue 56 | ); 57 | instance[AttributeMetadata][propertyName].serialisationValue = newValue; 58 | } else { 59 | const oldValue = _.cloneDeep(newValue); 60 | instance[AttributeMetadata][propertyName] = { 61 | newValue, 62 | oldValue, 63 | converter, 64 | nested: true, 65 | hasDirtyAttributes: !_.isEqual(newValue, oldValue) 66 | }; 67 | } 68 | }; 69 | 70 | const getter = function() { 71 | return this[`_${propertyName}`]; 72 | }; 73 | 74 | const setter = function(newVal: any) { 75 | const targetType = Reflect.getMetadata('design:type', target, propertyName); 76 | this[`_${propertyName}`] = converter(targetType, newVal); 77 | updateMetadata(this); 78 | }; 79 | 80 | if (delete target[propertyName]) { 81 | saveAnnotations(); 82 | Object.defineProperty(target, propertyName, { 83 | get: getter, 84 | set: setter, 85 | enumerable: true, 86 | configurable: true 87 | }); 88 | 89 | } 90 | }; 91 | } 92 | -------------------------------------------------------------------------------- /projects/angular2-jsonapi/src/decorators/attribute.decorator.ts: -------------------------------------------------------------------------------- 1 | import { AttributeMetadata } from '../constants/symbols'; 2 | import { AttributeDecoratorOptions } from '../interfaces/attribute-decorator-options.interface'; 3 | import { DateConverter } from '../converters/date/date.converter'; 4 | import * as _ from 'lodash'; 5 | 6 | export function Attribute(options: AttributeDecoratorOptions = {}): PropertyDecorator { 7 | return (target: any, propertyName: string) => { 8 | const converter = (dataType: any, value: any, forSerialisation = false): any => { 9 | let attrConverter; 10 | 11 | if (options.converter) { 12 | attrConverter = options.converter; 13 | } else if (dataType === Date) { 14 | attrConverter = new DateConverter(); 15 | } else { 16 | const datatype = new dataType(); 17 | 18 | if (datatype.mask && datatype.unmask) { 19 | attrConverter = datatype; 20 | } 21 | } 22 | 23 | if (attrConverter) { 24 | if (!forSerialisation) { 25 | return attrConverter.mask(value); 26 | } 27 | return attrConverter.unmask(value); 28 | } 29 | 30 | return value; 31 | }; 32 | 33 | const saveAnnotations = () => { 34 | const metadata = Reflect.getMetadata('Attribute', target) || {}; 35 | 36 | metadata[propertyName] = { 37 | marked: true 38 | }; 39 | 40 | Reflect.defineMetadata('Attribute', metadata, target); 41 | 42 | const mappingMetadata = Reflect.getMetadata('AttributeMapping', target) || {}; 43 | const serializedPropertyName = options.serializedName !== undefined ? options.serializedName : propertyName; 44 | mappingMetadata[serializedPropertyName] = propertyName; 45 | Reflect.defineMetadata('AttributeMapping', mappingMetadata, target); 46 | }; 47 | 48 | const setMetadata = ( 49 | instance: any, 50 | oldValue: any, 51 | newValue: any 52 | ) => { 53 | const targetType = Reflect.getMetadata('design:type', target, propertyName); 54 | 55 | if (!instance[AttributeMetadata]) { 56 | instance[AttributeMetadata] = {}; 57 | } 58 | instance[AttributeMetadata][propertyName] = { 59 | newValue, 60 | oldValue, 61 | nested: false, 62 | serializedName: options.serializedName, 63 | hasDirtyAttributes: !_.isEqual(oldValue, newValue), 64 | serialisationValue: converter(targetType, newValue, true) 65 | }; 66 | }; 67 | 68 | const getter = function() { 69 | return this[`_${propertyName}`]; 70 | }; 71 | 72 | const setter = function(newVal: any) { 73 | const targetType = Reflect.getMetadata('design:type', target, propertyName); 74 | const convertedValue = converter(targetType, newVal); 75 | let oldValue = null; 76 | if (this.isModelInitialization() && this.id) { 77 | oldValue = converter(targetType, newVal); 78 | } else { 79 | if (this[AttributeMetadata] && this[AttributeMetadata][propertyName]) { 80 | oldValue = this[AttributeMetadata][propertyName].oldValue; 81 | } 82 | } 83 | 84 | this[`_${propertyName}`] = convertedValue; 85 | setMetadata(this, oldValue, convertedValue); 86 | }; 87 | 88 | if (delete target[propertyName]) { 89 | saveAnnotations(); 90 | Object.defineProperty(target, propertyName, { 91 | get: getter, 92 | set: setter, 93 | enumerable: true, 94 | configurable: true 95 | }); 96 | } 97 | }; 98 | } 99 | -------------------------------------------------------------------------------- /projects/angular2-jsonapi/test/fixtures/author.fixture.ts: -------------------------------------------------------------------------------- 1 | import { getSampleBook } from './book.fixture'; 2 | import { getSampleChapter } from './chapter.fixture'; 3 | import { getSampleSection } from './section.fixture'; 4 | import { getSampleParagraph } from './paragraph.fixture'; 5 | import { getSampleSentence } from './sentence.fixture'; 6 | import { getSampleCategory } from './category.fixture'; 7 | 8 | export const AUTHOR_ID = '1'; 9 | export const AUTHOR_NAME = 'J. R. R. Tolkien'; 10 | export const AUTHOR_BIRTH = '1892-01-03'; 11 | export const AUTHOR_DEATH = '1973-09-02'; 12 | export const AUTHOR_CREATED = '2016-09-26T21:12:40Z'; 13 | export const AUTHOR_UPDATED = '2016-09-26T21:12:45Z'; 14 | 15 | export const BOOK_TITLE = 'The Fellowship of the Ring'; 16 | export const BOOK_PUBLISHED = '1954-07-29'; 17 | 18 | export const CATEGORY_ID = '1'; 19 | 20 | export const CHAPTER_TITLE = 'The Return Journey'; 21 | 22 | export function getAuthorData(relationship?: string, total: number = 0): any { 23 | const response: any = { 24 | id: AUTHOR_ID, 25 | type: 'authors', 26 | attributes: { 27 | name: AUTHOR_NAME, 28 | dob: AUTHOR_BIRTH, 29 | date_of_death: AUTHOR_DEATH, 30 | created_at: AUTHOR_CREATED, 31 | updated_at: AUTHOR_UPDATED 32 | }, 33 | relationships: { 34 | books: { 35 | links: { 36 | self: '/v1/authors/1/relationships/books', 37 | related: '/v1/authors/1/books' 38 | } 39 | } 40 | }, 41 | links: { 42 | self: '/v1/authors/1' 43 | } 44 | }; 45 | 46 | if (relationship && relationship.indexOf('books') !== -1) { 47 | response.relationships.books.data = []; 48 | 49 | for (let i = 1; i <= total; i++) { 50 | response.relationships.books.data.push({ 51 | id: '' + i, 52 | type: 'books' 53 | }); 54 | } 55 | } 56 | 57 | return response; 58 | } 59 | 60 | export function getIncludedBooks(totalBooks: number, relationship?: string, totalChapters: number = 0): any[] { 61 | const responseArray: any[] = []; 62 | let chapterId = 0; 63 | 64 | for (let i = 1; i <= totalBooks; i++) { 65 | const book: any = getSampleBook(i, AUTHOR_ID, CATEGORY_ID); 66 | responseArray.push(book); 67 | 68 | if (relationship && relationship.indexOf('books.chapters') !== -1) { 69 | book.relationships.chapters.data = []; 70 | for (let ic = 1; ic <= totalChapters; ic++) { 71 | chapterId++; 72 | book.relationships.chapters.data.push({ 73 | id: `${chapterId}`, 74 | type: 'chapters' 75 | }); 76 | 77 | const chapter = getSampleChapter(i, `${chapterId}`, CHAPTER_TITLE); 78 | 79 | responseArray.push(chapter); 80 | } 81 | } 82 | 83 | if (relationship && relationship.indexOf('books.category') !== -1) { 84 | let categoryInclude = responseArray.find((category) => { 85 | return category.type === 'categories' && category.id === CATEGORY_ID; 86 | }); 87 | 88 | if (!categoryInclude) { 89 | categoryInclude = getSampleCategory(CATEGORY_ID); 90 | categoryInclude.relationships.books = {data: []}; 91 | responseArray.push(categoryInclude); 92 | } 93 | 94 | categoryInclude.relationships.books.data.push({ 95 | id: `${i}`, 96 | type: 'books' 97 | }); 98 | } 99 | 100 | if (relationship && relationship.indexOf('books.firstChapter') !== -1) { 101 | const firstChapterId = '1'; 102 | 103 | book.relationships['first-chapter'] = { 104 | data: { 105 | id: firstChapterId, 106 | type: 'chapters' 107 | } 108 | }; 109 | 110 | const findFirstChapterInclude = responseArray.find((chapter) => chapter.id === firstChapterId); 111 | 112 | if (!findFirstChapterInclude) { 113 | const chapter = getSampleChapter(i, `${firstChapterId}`, CHAPTER_TITLE); 114 | responseArray.push(chapter); 115 | } 116 | } 117 | 118 | if (relationship && relationship.indexOf('books.firstChapter.firstSection') !== -1) { 119 | const section = getSampleSection('1', '1'); 120 | responseArray.push(section); 121 | } 122 | 123 | if (relationship && relationship.indexOf('books.firstChapter.firstSection.firstParagraph') !== -1) { 124 | const paragraph = getSampleParagraph('1', '1'); 125 | responseArray.push(paragraph); 126 | } 127 | 128 | if (relationship && relationship.indexOf('books.firstChapter.firstSection.firstParagraph.firstSentence') !== -1) { 129 | const sentence = getSampleSentence('1', '1'); 130 | responseArray.push(sentence); 131 | } 132 | } 133 | 134 | return responseArray; 135 | } 136 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See http://help.github.com/ignore-files/ for more about ignoring files. 2 | 3 | # compiled output 4 | /dist 5 | /tmp 6 | /out-tsc 7 | # Only exists if Bazel was run 8 | /bazel-out 9 | 10 | # dependencies 11 | /node_modules 12 | 13 | # profiling files 14 | chrome-profiler-events*.json 15 | speed-measure-plugin*.json 16 | 17 | # IDEs and editors 18 | /.idea 19 | .project 20 | .classpath 21 | .c9/ 22 | *.launch 23 | .settings/ 24 | *.sublime-workspace 25 | 26 | # IDE - VSCode 27 | .vscode/* 28 | !.vscode/settings.json 29 | !.vscode/tasks.json 30 | !.vscode/launch.json 31 | !.vscode/extensions.json 32 | .history/* 33 | 34 | # misc 35 | /.sass-cache 36 | /connect.lock 37 | /coverage 38 | /libpeerconnection.log 39 | npm-debug.log 40 | yarn-error.log 41 | testem.log 42 | /typings 43 | 44 | # System Files 45 | .DS_Store 46 | Thumbs.db 47 | ### Archives template 48 | # It's better to unpack these files and commit the raw source because 49 | # git has its own built in compression methods. 50 | *.7z 51 | *.jar 52 | *.rar 53 | *.zip 54 | *.gz 55 | *.tgz 56 | *.bzip 57 | *.bz2 58 | *.xz 59 | *.lzma 60 | *.cab 61 | 62 | # Packing-only formats 63 | *.iso 64 | *.tar 65 | 66 | # Package management formats 67 | *.dmg 68 | *.xpi 69 | *.gem 70 | *.egg 71 | *.deb 72 | *.rpm 73 | *.msi 74 | *.msm 75 | *.msp 76 | 77 | ### VisualStudioCode template 78 | .vscode/* 79 | !.vscode/settings.json 80 | !.vscode/tasks.json 81 | !.vscode/launch.json 82 | !.vscode/extensions.json 83 | 84 | ### JetBrains template 85 | # Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio and WebStorm 86 | # Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839 87 | 88 | # User-specific stuff 89 | .idea/**/workspace.xml 90 | .idea/**/tasks.xml 91 | .idea/**/usage.statistics.xml 92 | .idea/**/dictionaries 93 | .idea/**/shelf 94 | 95 | # Generated files 96 | .idea/**/contentModel.xml 97 | 98 | # Sensitive or high-churn files 99 | .idea/**/dataSources/ 100 | .idea/**/dataSources.ids 101 | .idea/**/dataSources.local.xml 102 | .idea/**/sqlDataSources.xml 103 | .idea/**/dynamic.xml 104 | .idea/**/uiDesigner.xml 105 | .idea/**/dbnavigator.xml 106 | 107 | # Gradle 108 | .idea/**/gradle.xml 109 | .idea/**/libraries 110 | 111 | # Gradle and Maven with auto-import 112 | # When using Gradle or Maven with auto-import, you should exclude module files, 113 | # since they will be recreated, and may cause churn. Uncomment if using 114 | # auto-import. 115 | # .idea/modules.xml 116 | # .idea/*.iml 117 | # .idea/modules 118 | # *.iml 119 | # *.ipr 120 | 121 | # CMake 122 | cmake-build-*/ 123 | 124 | # Mongo Explorer plugin 125 | .idea/**/mongoSettings.xml 126 | 127 | # File-based project format 128 | *.iws 129 | 130 | # IntelliJ 131 | out/ 132 | 133 | # mpeltonen/sbt-idea plugin 134 | .idea_modules/ 135 | 136 | # JIRA plugin 137 | atlassian-ide-plugin.xml 138 | 139 | # Cursive Clojure plugin 140 | .idea/replstate.xml 141 | 142 | # Crashlytics plugin (for Android Studio and IntelliJ) 143 | com_crashlytics_export_strings.xml 144 | crashlytics.properties 145 | crashlytics-build.properties 146 | fabric.properties 147 | 148 | # Editor-based Rest Client 149 | .idea/httpRequests 150 | 151 | # Android studio 3.1+ serialized cache file 152 | .idea/caches/build_file_checksums.ser 153 | 154 | ### macOS template 155 | # General 156 | .DS_Store 157 | .AppleDouble 158 | .LSOverride 159 | 160 | # Icon must end with two \r 161 | Icon 162 | 163 | # Thumbnails 164 | ._* 165 | 166 | # Files that might appear in the root of a volume 167 | .DocumentRevisions-V100 168 | .fseventsd 169 | .Spotlight-V100 170 | .TemporaryItems 171 | .Trashes 172 | .VolumeIcon.icns 173 | .com.apple.timemachine.donotpresent 174 | 175 | # Directories potentially created on remote AFP share 176 | .AppleDB 177 | .AppleDesktop 178 | Network Trash Folder 179 | Temporary Items 180 | .apdisk 181 | 182 | ### Node template 183 | # Logs 184 | logs 185 | *.log 186 | npm-debug.log* 187 | yarn-debug.log* 188 | yarn-error.log* 189 | lerna-debug.log* 190 | 191 | # Diagnostic reports (https://nodejs.org/api/report.html) 192 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json 193 | 194 | # Runtime data 195 | pids 196 | *.pid 197 | *.seed 198 | *.pid.lock 199 | 200 | # Directory for instrumented libs generated by jscoverage/JSCover 201 | lib-cov 202 | 203 | # Coverage directory used by tools like istanbul 204 | coverage 205 | *.lcov 206 | 207 | # nyc test coverage 208 | .nyc_output 209 | 210 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) 211 | .grunt 212 | 213 | # Bower dependency directory (https://bower.io/) 214 | bower_components 215 | 216 | # node-waf configuration 217 | .lock-wscript 218 | 219 | # Compiled binary addons (https://nodejs.org/api/addons.html) 220 | build/Release 221 | 222 | # Dependency directories 223 | node_modules/ 224 | jspm_packages/ 225 | 226 | # TypeScript v1 declaration files 227 | typings/ 228 | 229 | # TypeScript cache 230 | *.tsbuildinfo 231 | 232 | # Optional npm cache directory 233 | .npm 234 | 235 | # Optional eslint cache 236 | .eslintcache 237 | 238 | # Optional REPL history 239 | .node_repl_history 240 | 241 | # Output of 'npm pack' 242 | *.tgz 243 | 244 | # Yarn Integrity file 245 | .yarn-integrity 246 | 247 | # dotenv environment variables file 248 | .env 249 | .env.test 250 | 251 | # parcel-bundler cache (https://parceljs.org/) 252 | .cache 253 | 254 | # next.js build output 255 | .next 256 | 257 | # nuxt.js build output 258 | .nuxt 259 | 260 | # vuepress build output 261 | .vuepress/dist 262 | 263 | # Serverless directories 264 | .serverless/ 265 | 266 | # FuseBox cache 267 | .fusebox/ 268 | 269 | # DynamoDB Local files 270 | .dynamodb/ 271 | 272 | ### Windows template 273 | # Windows thumbnail cache files 274 | Thumbs.db 275 | Thumbs.db:encryptable 276 | ehthumbs.db 277 | ehthumbs_vista.db 278 | 279 | # Dump file 280 | *.stackdump 281 | 282 | # Folder config file 283 | [Dd]esktop.ini 284 | 285 | # Recycle Bin used on file shares 286 | $RECYCLE.BIN/ 287 | 288 | # Windows Installer files 289 | *.cab 290 | *.msi 291 | *.msix 292 | *.msm 293 | *.msp 294 | 295 | # Windows shortcuts 296 | *.lnk 297 | 298 | ### Linux template 299 | *~ 300 | 301 | # temporary files which can be created if a process still has a handle open of a deleted file 302 | .fuse_hidden* 303 | 304 | # KDE directory preferences 305 | .directory 306 | 307 | # Linux trash folder which might appear on any partition or disk 308 | .Trash-* 309 | 310 | # .nfs files are created when an open file is removed but is still being accessed 311 | .nfs* 312 | 313 | ### Vim template 314 | # Swap 315 | [._]*.s[a-v][a-z] 316 | [._]*.sw[a-p] 317 | [._]s[a-rt-v][a-z] 318 | [._]ss[a-gi-z] 319 | [._]sw[a-p] 320 | 321 | # Session 322 | Session.vim 323 | Sessionx.vim 324 | 325 | # Temporary 326 | .netrwhist 327 | *~ 328 | # Auto-generated tag files 329 | tags 330 | # Persistent undo 331 | [._]*.un~ 332 | 333 | -------------------------------------------------------------------------------- /projects/angular2-jsonapi/src/converters/json-model/json-model.converter.spec.ts: -------------------------------------------------------------------------------- 1 | import { JsonModelConverter } from './json-model.converter'; 2 | import { School } from '../../../test/models/school.model'; 3 | 4 | describe('JsonModel converter', () => { 5 | let converter: JsonModelConverter; 6 | 7 | describe('mask method', () => { 8 | 9 | describe('ArrayModel - simple values', () => { 10 | beforeEach(() => { 11 | converter = new JsonModelConverter(Array, {hasMany: true}); 12 | }); 13 | 14 | it('should return empty array when empty input', () => { 15 | const result = converter.mask(null); 16 | expect(result instanceof Array).toBeTruthy(); 17 | }); 18 | 19 | it('should return the empty array when provided empty array', () => { 20 | const DATA: Array = []; 21 | expect(converter.mask(DATA)).toEqual(DATA); 22 | }); 23 | 24 | it('should return the array with data when provided default value', () => { 25 | const DATA: Array = [1, 2, 3]; 26 | expect(converter.mask(DATA)).toEqual(DATA); 27 | }); 28 | 29 | it('should filter-out null values from array', () => { 30 | const DATA: Array = [1, 2, null, 3]; 31 | const result = converter.mask(DATA); 32 | expect(result.length).toBe(3); 33 | expect(result).not.toContain(null); 34 | }); 35 | }); 36 | 37 | describe('ArrayModel - simple values - nullable', () => { 38 | beforeEach(() => { 39 | converter = new JsonModelConverter(Array, {hasMany: true, nullValue: true}); 40 | }); 41 | 42 | it('should throw error when provided empty array when empty input', () => { 43 | const VALUE = null; 44 | expect(() => { 45 | converter.mask(VALUE); 46 | }).toThrow(new Error(`ERROR: JsonModelConverter: Expected array but got ${typeof VALUE}.`)); 47 | }); 48 | }); 49 | 50 | describe('Array model -> object values', () => { 51 | 52 | beforeEach(() => { 53 | converter = new JsonModelConverter(School, {hasMany: true}); 54 | }); 55 | 56 | it('should return array of Schools from provided data', () => { 57 | const DATA: Array = [ 58 | {name: 'Massachusetts Institute of Technology', students: 11319, foundation: '1861-10-04'}, 59 | {name: 'Charles University', students: 51438, foundation: '1348-04-07'}, 60 | ]; 61 | const result: Array = converter.mask(DATA); 62 | expect(result.length).toBe(2); 63 | expect(result[0]).toEqual(new School(DATA[0])); 64 | expect(result[1]).toEqual(new School(DATA[1])); 65 | }); 66 | }); 67 | 68 | describe('ObjectModel', () => { 69 | beforeEach(() => { 70 | converter = new JsonModelConverter(School); 71 | }); 72 | 73 | it('should return new School when provided without value', () => { 74 | const result = converter.mask(null); 75 | expect(result instanceof School).toBeTruthy(); 76 | }); 77 | 78 | it('should not create new instance when already provided School instance', () => { 79 | const DATA = new School({ 80 | name: 'Massachusetts Institute of Technology', 81 | students: 11319, 82 | foundation: '1861-10-04' 83 | }); 84 | const result = converter.mask(DATA); 85 | expect(result).toEqual(DATA); 86 | }); 87 | 88 | it('should instance of School with data when provided initial data', () => { 89 | const DATA = {name: 'Massachusetts Institute of Technology', students: 11319, foundation: '1861-10-04'}; 90 | const result: School = converter.mask(DATA); 91 | expect(result.name).toBeTruthy(DATA.name); 92 | expect(result.students).toBeTruthy(DATA.students); 93 | expect(result.foundation).toBeTruthy(new Date(DATA.foundation)); 94 | }); 95 | }); 96 | 97 | describe('ObjectModel - nullable', () => { 98 | beforeEach(() => { 99 | converter = new JsonModelConverter(School, {nullValue: false}); 100 | }); 101 | 102 | it('should return null when null', () => { 103 | const result = converter.mask(null); 104 | expect(result instanceof School).toBeTruthy(); 105 | }); 106 | }); 107 | }); 108 | 109 | describe('unmask method', () => { 110 | describe('ArrayModel - simple values', () => { 111 | beforeEach(() => { 112 | converter = new JsonModelConverter(Array, {hasMany: true}); 113 | }); 114 | 115 | it('should return serialized output when provided null', () => { 116 | const result = converter.unmask(null); 117 | expect(result).toBeNull(); 118 | }); 119 | 120 | it('should return serialized array of strings', () => { 121 | const DATA: Array = ['a', 'b', 'c']; 122 | expect(converter.unmask(DATA)).toEqual(DATA); 123 | }); 124 | 125 | it('should filter-out null values from array', () => { 126 | const DATA: Array = [1, 2, null, 3]; 127 | const result = converter.unmask(DATA); 128 | expect(result.length).toBe(3); 129 | expect(result).not.toContain(null); 130 | }); 131 | }); 132 | 133 | describe('Array model -> object values', () => { 134 | 135 | beforeEach(() => { 136 | converter = new JsonModelConverter(School, {hasMany: true}); 137 | }); 138 | 139 | it('should return serialized Schools from provided Array of Schools', () => { 140 | const DATA: Array = [ 141 | {name: 'Massachusetts Institute of Technology', students: 11319, foundation: '1861-10-04'}, 142 | {name: 'Charles University', students: 51438, foundation: '1348-04-07'}, 143 | ]; 144 | const schools: Array = [new School(DATA[0]), new School(DATA[1])]; 145 | const result: Array = converter.unmask(schools); 146 | expect(result.length).toBe(2); 147 | result.forEach((element, index: number) => { 148 | expect(element.name).toBe(DATA[index].name); 149 | expect(element.students).toBe(DATA[index].students); 150 | expect(element.foundation).toContain(DATA[index].foundation); 151 | }); 152 | }); 153 | }); 154 | 155 | describe('ObjectModel - nullable', () => { 156 | beforeEach(() => { 157 | converter = new JsonModelConverter(School, {nullValue: false}); 158 | }); 159 | 160 | it('should return null when null', () => { 161 | const result = converter.unmask(null); 162 | expect(result).toEqual(null); 163 | }); 164 | 165 | it('should return serialized school when provided School instance', () => { 166 | const DATA = {name: 'Massachusetts Institute of Technology', students: 11319, foundation: '1861-10-04'}; 167 | const result = converter.unmask(new School(DATA)); 168 | expect(result.name).toBe(DATA.name); 169 | expect(result.students).toBe(DATA.students); 170 | expect(result.foundation).toContain(DATA.foundation); 171 | }); 172 | }); 173 | }); 174 | }); 175 | -------------------------------------------------------------------------------- /projects/angular2-jsonapi/src/models/json-api.model.ts: -------------------------------------------------------------------------------- 1 | import { find, includes } from 'lodash-es'; 2 | import { Observable } from 'rxjs'; 3 | import { JsonApiDatastore, ModelType } from '../services/json-api-datastore.service'; 4 | import { ModelConfig } from '../interfaces/model-config.interface'; 5 | import * as _ from 'lodash'; 6 | import { AttributeMetadata } from '../constants/symbols'; 7 | import { HttpHeaders } from '@angular/common/http'; 8 | 9 | /** 10 | * HACK/FIXME: 11 | * Type 'symbol' cannot be used as an index type. 12 | * TypeScript 2.9.x 13 | * See https://github.com/Microsoft/TypeScript/issues/24587. 14 | */ 15 | // tslint:disable-next-line:variable-name 16 | const AttributeMetadataIndex: string = AttributeMetadata as any; 17 | 18 | export class JsonApiModel { 19 | id: string; 20 | public modelInitialization = false; 21 | 22 | [key: string]: any; 23 | 24 | lastSyncModels: Array; 25 | 26 | constructor(private internalDatastore: JsonApiDatastore, data?: any) { 27 | if (data) { 28 | this.modelInitialization = true; 29 | this.id = data.id; 30 | Object.assign(this, data.attributes); 31 | this.modelInitialization = false; 32 | } 33 | } 34 | 35 | public isModelInitialization(): boolean { 36 | return this.modelInitialization; 37 | } 38 | 39 | public syncRelationships(data: any, included: any, remainingModels?: Array): void { 40 | if (this.lastSyncModels === included) { 41 | return; 42 | } 43 | 44 | if (data) { 45 | let modelsForProcessing = remainingModels; 46 | 47 | if (modelsForProcessing === undefined) { 48 | modelsForProcessing = [].concat(included); 49 | } 50 | 51 | this.parseHasMany(data, included, modelsForProcessing); 52 | this.parseBelongsTo(data, included, modelsForProcessing); 53 | } 54 | 55 | this.lastSyncModels = included; 56 | } 57 | 58 | public save(params?: any, headers?: HttpHeaders, customUrl?: string): Observable { 59 | this.checkChanges(); 60 | const attributesMetadata: any = this[AttributeMetadataIndex]; 61 | return this.internalDatastore.saveRecord(attributesMetadata, this, params, headers, customUrl); 62 | } 63 | 64 | get hasDirtyAttributes() { 65 | this.checkChanges(); 66 | const attributesMetadata: any = this[AttributeMetadataIndex]; 67 | let hasDirtyAttributes = false; 68 | for (const propertyName in attributesMetadata) { 69 | if (attributesMetadata.hasOwnProperty(propertyName)) { 70 | const metadata: any = attributesMetadata[propertyName]; 71 | if (metadata.hasDirtyAttributes) { 72 | hasDirtyAttributes = true; 73 | break; 74 | } 75 | } 76 | } 77 | return hasDirtyAttributes; 78 | } 79 | 80 | private checkChanges() { 81 | const attributesMetadata: any = this[AttributeMetadata]; 82 | for (const propertyName in attributesMetadata) { 83 | if (attributesMetadata.hasOwnProperty(propertyName)) { 84 | const metadata: any = attributesMetadata[propertyName]; 85 | if (metadata.nested) { 86 | this[AttributeMetadata][propertyName].hasDirtyAttributes = !_.isEqual( 87 | attributesMetadata[propertyName].oldValue, 88 | attributesMetadata[propertyName].newValue 89 | ); 90 | this[AttributeMetadata][propertyName].serialisationValue = attributesMetadata[propertyName].converter( 91 | Reflect.getMetadata('design:type', this, propertyName), 92 | _.cloneDeep(attributesMetadata[propertyName].newValue), 93 | true 94 | ); 95 | } 96 | } 97 | } 98 | } 99 | 100 | public rollbackAttributes(): void { 101 | const attributesMetadata: any = this[AttributeMetadataIndex]; 102 | for (const propertyName in attributesMetadata) { 103 | if (attributesMetadata.hasOwnProperty(propertyName)) { 104 | if (attributesMetadata[propertyName].hasDirtyAttributes) { 105 | this[propertyName] = _.cloneDeep(attributesMetadata[propertyName].oldValue); 106 | } 107 | } 108 | } 109 | } 110 | 111 | get modelConfig(): ModelConfig { 112 | return Reflect.getMetadata('JsonApiModelConfig', this.constructor); 113 | } 114 | 115 | private parseHasMany(data: any, included: any, remainingModels: Array): void { 116 | const hasMany: any = Reflect.getMetadata('HasMany', this); 117 | 118 | if (hasMany) { 119 | for (const metadata of hasMany) { 120 | const relationship: any = data.relationships ? data.relationships[metadata.relationship] : null; 121 | 122 | if (relationship && relationship.data && Array.isArray(relationship.data)) { 123 | let allModels: JsonApiModel[] = []; 124 | const modelTypesFetched: any = []; 125 | 126 | for (const typeIndex of Object.keys(relationship.data)) { 127 | const typeName: string = relationship.data[typeIndex].type; 128 | 129 | if (!includes(modelTypesFetched, typeName)) { 130 | modelTypesFetched.push(typeName); 131 | // tslint:disable-next-line:max-line-length 132 | const modelType: ModelType = Reflect.getMetadata('JsonApiDatastoreConfig', this.internalDatastore.constructor).models[typeName]; 133 | 134 | if (modelType) { 135 | const relationshipModels: JsonApiModel[] = this.getHasManyRelationship( 136 | modelType, 137 | relationship.data, 138 | included, 139 | typeName, 140 | remainingModels 141 | ); 142 | 143 | if (relationshipModels.length > 0) { 144 | allModels = allModels.concat(relationshipModels); 145 | } 146 | } else { 147 | throw {message: `parseHasMany - Model type for relationship ${typeName} not found.`}; 148 | } 149 | } 150 | } 151 | 152 | this[metadata.propertyName] = allModels; 153 | } 154 | } 155 | } 156 | } 157 | 158 | private parseBelongsTo(data: any, included: Array, remainingModels: Array): void { 159 | const belongsTo: any = Reflect.getMetadata('BelongsTo', this); 160 | 161 | if (belongsTo) { 162 | for (const metadata of belongsTo) { 163 | const relationship: any = data.relationships ? data.relationships[metadata.relationship] : null; 164 | if (relationship && relationship.data) { 165 | const dataRelationship: any = (relationship.data instanceof Array) ? relationship.data[0] : relationship.data; 166 | if (dataRelationship) { 167 | const typeName: string = dataRelationship.type; 168 | // tslint:disable-next-line:max-line-length 169 | const modelType: ModelType = Reflect.getMetadata('JsonApiDatastoreConfig', this.internalDatastore.constructor).models[typeName]; 170 | 171 | if (modelType) { 172 | const relationshipModel = this.getBelongsToRelationship( 173 | modelType, 174 | dataRelationship, 175 | included, 176 | typeName, 177 | remainingModels 178 | ); 179 | 180 | if (relationshipModel) { 181 | this[metadata.propertyName] = relationshipModel; 182 | } 183 | } else { 184 | throw {message: `parseBelongsTo - Model type for relationship ${typeName} not found.`}; 185 | } 186 | } 187 | } 188 | } 189 | } 190 | } 191 | 192 | private getHasManyRelationship( 193 | modelType: ModelType, 194 | data: any, 195 | included: any, 196 | typeName: string, 197 | remainingModels: Array 198 | ): Array { 199 | const relationshipList: Array = []; 200 | 201 | data.forEach((item: any) => { 202 | const relationshipData: any = find(included, {id: item.id, type: typeName} as any); 203 | 204 | if (relationshipData) { 205 | const newObject: T = this.createOrPeek(modelType, relationshipData); 206 | 207 | const indexOfNewlyFoundModel = remainingModels.indexOf(relationshipData); 208 | const modelsForProcessing = remainingModels.concat([]); 209 | 210 | if (indexOfNewlyFoundModel !== -1) { 211 | modelsForProcessing.splice(indexOfNewlyFoundModel, 1); 212 | newObject.syncRelationships(relationshipData, included, modelsForProcessing); 213 | } 214 | 215 | relationshipList.push(newObject); 216 | } 217 | }); 218 | 219 | return relationshipList; 220 | } 221 | 222 | private getBelongsToRelationship( 223 | modelType: ModelType, 224 | data: any, 225 | included: Array, 226 | typeName: string, 227 | remainingModels: Array 228 | ): T | null { 229 | const id: string = data.id; 230 | 231 | const relationshipData: any = find(included, {id, type: typeName} as any); 232 | 233 | if (relationshipData) { 234 | const newObject: T = this.createOrPeek(modelType, relationshipData); 235 | 236 | const indexOfNewlyFoundModel = remainingModels.indexOf(relationshipData); 237 | const modelsForProcessing = remainingModels.concat([]); 238 | 239 | if (indexOfNewlyFoundModel !== -1) { 240 | modelsForProcessing.splice(indexOfNewlyFoundModel, 1); 241 | newObject.syncRelationships(relationshipData, included, modelsForProcessing); 242 | } 243 | 244 | return newObject; 245 | } 246 | 247 | return this.internalDatastore.peekRecord(modelType, id); 248 | } 249 | 250 | private createOrPeek(modelType: ModelType, data: any): T { 251 | const peek = this.internalDatastore.peekRecord(modelType, data.id); 252 | 253 | if (peek) { 254 | _.extend(peek, this.internalDatastore.transformSerializedNamesToPropertyNames(modelType, data.attributes)); 255 | return peek; 256 | } 257 | 258 | const newObject: T = this.internalDatastore.deserializeModel(modelType, data); 259 | this.internalDatastore.addToStore(newObject); 260 | 261 | return newObject; 262 | } 263 | } 264 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # [8.0.0] 2 | 3 | * Restructured library to follow Angular standards 4 | 5 | # [7.1.2] 6 | 7 | * Fix parsing has many relationships when there is no models provided 8 | 9 | # [7.1.1] 10 | 11 | * Allow removal of HasMany relations by setting empty array ([#224](https://github.com/ghidoz/angular2-jsonapi/pull/224)) 12 | 13 | # [7.1.0] 14 | 15 | * Allow mixed model types in a single HasMany relationships ([#216](https://github.com/ghidoz/angular2-jsonapi/pull/216)) 16 | 17 | ### Bug fixes 18 | 19 | * Fix using relationship config key ([#210](https://github.com/ghidoz/angular2-jsonapi/pull/210)) 20 | * Fix connecting related model resources parsed from response ([#213](https://github.com/ghidoz/angular2-jsonapi/pull/213)) 21 | 22 | # [7.0.0] 23 | 24 | ### BREAKING CHANGES 25 | 26 | * Upgraded the library to Angular 7 27 | 28 | # [6.1.3] 29 | 30 | * Fixed rollbackAttributes to public 31 | * Fixed tests 32 | 33 | # [6.1.2] 34 | 35 | * Added support for custom request options 36 | 37 | # [6.1.2-beta] 38 | 39 | * Added support for custom request options 40 | 41 | # [6.0.1] 42 | 43 | * Angular 6 support 44 | * Smaller bug fixes 45 | 46 | ### BREAKING CHANGES 47 | 48 | * Required RxJS v6 49 | 50 | # [6.0.2-beta] 51 | 52 | * Fix types issues 53 | 54 | # [6.0.0-beta] 55 | 56 | ### BREAKING CHANGES 57 | 58 | * Upgraded rxjs to version 6 59 | 60 | # [5.1.1-beta], [5.1.1] (2018-06-06) 61 | 62 | ### Bug fixes 63 | 64 | * Fix serializedName in included relationships ([#174](https://github.com/ghidoz/angular2-jsonapi/issues/174)) 65 | * Fix parsing belongsTo and hasMany relationships 66 | 67 | # [5.0.0] (2018-11-04) 68 | 69 | ### BREAKING CHANGES 70 | 71 | * Replace HttpModule with HttpClientModule 72 | 73 | # [4.1.0] (2018-03-01) 74 | 75 | ### Bug fixes 76 | 77 | * Fix creating nested models passed through included property 78 | 79 | # [4.0.3] (2018-01-16) 80 | 81 | ### Features 82 | 83 | * Update peer dependencies to support Angular v5 84 | 85 | # [4.0.2] (2017-11-06) 86 | 87 | ### Bug fixes 88 | 89 | * Fix date timezone 90 | * Fix falsy attributes mapping 91 | * Tweak methods visibility 92 | 93 | # [4.0.1] (2017-11-06) 94 | 95 | ### Bug fixes 96 | 97 | * Fix updating has many relationships after saving a model 98 | 99 | # [4.0.0] (2017-11-03) 100 | 101 | ### BREAKING CHANGES 102 | 103 | - Update to Angular 4 104 | 105 | ### Features 106 | 107 | - Update Attribute decorator with custom property converter ([88d8d30](https://github.com/ghidoz/angular2-jsonapi/commit/88d8d3070928ca57cd7d02c82784363d46eccdb7)) 108 | - Make JsonApiModelMeta more customizable ([b678bb7](https://github.com/ghidoz/angular2-jsonapi/commit/b678bb7d2125e15a275d2768927feeede3fb933b)) 109 | - Add support for overriding internal methods ([f15be33](https://github.com/ghidoz/angular2-jsonapi/commit/f15be33890c8c444f05862faa6f63ec2422bb0c1)) 110 | - Add support for custom endpoint URLs ([9b43f12](https://github.com/ghidoz/angular2-jsonapi/commit/9b43f12ee25bd4b23e8c1603f34efb7cb8d201c2)) 111 | 112 | ### Bug fixes 113 | 114 | - Fix serializing query parameters ([cc58b73](cc58b739b867ae0f403f9e3b85026dd5c4122749)) 115 | - Remove attributes from relationship objects ([5f1a3fc](https://github.com/ghidoz/angular2-jsonapi/commit/5f1a3fcda0dcc95b2f3fbbf771893c7e2b868dd1)) 116 | - Fix saving model metadata ([cbf26d7](https://github.com/ghidoz/angular2-jsonapi/commit/cbf26d7dad07bc7d9788c45a61e60de914c8ee10)) 117 | 118 | # [3.4.0](https://github.com/ghidoz/angular2-jsonapi/compare/v3.3.0...v3.4.0) (2016-12-17) 119 | 120 | ### Bug Fixes 121 | - Make library AOT ready and remove conflicting reflect-metadata import ([#13](https://github.com/ghidoz/angular2-jsonapi/issues/13)) ([#35](https://github.com/ghidoz/angular2-jsonapi/issues/35)) ([8186f8b](https://github.com/ghidoz/angular2-jsonapi/commit/8186f8b)) 122 | 123 | ### Features 124 | - Ability to work with dates as Date objects ([76c652b](https://github.com/ghidoz/angular2-jsonapi/commit/76c652b)) 125 | - Throw a better error when a relationship is not mapped ([d135e58](https://github.com/ghidoz/angular2-jsonapi/commit/d135e58)) 126 | 127 | # [3.3.0](https://github.com/ghidoz/angular2-jsonapi/compare/v3.2.1...v3.3.0) (2016-11-01) 128 | 129 | ### Bug Fixes 130 | - Override accept/content-type headers instead of adding ([#39](https://github.com/ghidoz/angular2-jsonapi/issues/39)) ([5c1f984](https://github.com/ghidoz/angular2-jsonapi/commit/5c1f984)) 131 | - Remove "hard" dependencies to @angular packages and introduce peerDependencies ([#46](https://github.com/ghidoz/angular2-jsonapi/issues/46)) ([6efe0f8](https://github.com/ghidoz/angular2-jsonapi/commit/6efe0f8)) 132 | 133 | ### Features 134 | - Support error objects from JSON API specification ([d41ecb9](https://github.com/ghidoz/angular2-jsonapi/commit/d41ecb9)) 135 | 136 | # [3.2.1](https://github.com/ghidoz/angular2-jsonapi/compare/v3.2.0...v3.2.1) (2016-10-13) 137 | 138 | ### Bug Fixes 139 | - Move some types under devDependencies ([#35](https://github.com/ghidoz/angular2-jsonapi/issues/35)) ([b20df04](https://github.com/ghidoz/angular2-jsonapi/commit/b20df04)) 140 | - Add typings index in package.json ([#36](https://github.com/ghidoz/angular2-jsonapi/issues/36)) ([e5446e6](https://github.com/ghidoz/angular2-jsonapi/commit/e5446e6)) 141 | 142 | # [3.2.0](https://github.com/ghidoz/angular2-jsonapi/compare/v3.1.1...v3.2.0) (2016-10-12) 143 | 144 | ### Bug Fixes 145 | - Add existence check on belongs to parsing ([3f538a0](https://github.com/ghidoz/angular2-jsonapi/commit/3f538a0)) 146 | - Removed dependency on typings ([#22](https://github.com/ghidoz/angular2-jsonapi/issues/22)) ([cec0a6a](https://github.com/ghidoz/angular2-jsonapi/commit/cec0a6a)) 147 | - Fix optional relationship on BelongsTo and HasMany ([764631c](https://github.com/ghidoz/angular2-jsonapi/commit/764631c)) 148 | 149 | ### Features 150 | - Add delete record method ([#28](https://github.com/ghidoz/angular2-jsonapi/issues/28)) ([2f6c380](https://github.com/ghidoz/angular2-jsonapi/commit/2f6c380)) 151 | - Make parameters and return values of JsonApiDatastore generic ([4120437](https://github.com/ghidoz/angular2-jsonapi/commit/4120437)) 152 | 153 | # [3.1.1](https://github.com/ghidoz/angular2-jsonapi/compare/v3.1.0...v3.1.1) (2016-09-22) 154 | 155 | ### Bug Fixes 156 | - Fix one-to-one relationship ([21ebac8](https://github.com/ghidoz/angular2-jsonapi/commit/21ebac8)) 157 | - Add check on data length parsing the HasMany relationship ([#14](https://github.com/ghidoz/angular2-jsonapi/issues/14)) ([0b9ac31](https://github.com/ghidoz/angular2-jsonapi/commit/0b9ac31)) 158 | 159 | # [3.1.0](https://github.com/ghidoz/angular2-jsonapi/compare/v3.0.0...v3.1.0) (2016-09-22) 160 | 161 | ### Bug Fixes 162 | - Do not delete relationship from object when saving a model ([8751d3f](https://github.com/ghidoz/angular2-jsonapi/commit/8751d3f)) 163 | 164 | ### Features 165 | - Allow overriding of JsonApiDatastore's error handler in derived classes ([98a300b](https://github.com/ghidoz/angular2-jsonapi/commit/98a300b)) 166 | - Parse infinite levels of relationships by reference ([bd02e3a](https://github.com/ghidoz/angular2-jsonapi/commit/bd02e3a)) 167 | - Push object to hasMany relationship array when updating object relationship ([99d082a](https://github.com/ghidoz/angular2-jsonapi/commit/99d082a)) 168 | 169 | # [3.0.0](https://github.com/ghidoz/angular2-jsonapi/compare/v2.1.0...v3.0.0) (2016-09-18) 170 | 171 | ### Features 172 | - Implement persistence and save() method ([46aa23f](https://github.com/ghidoz/angular2-jsonapi/commit/46aa23f)) 173 | - Add peekRecord and peekAll, caching records in the store ([43de815](https://github.com/ghidoz/angular2-jsonapi/commit/43de815)) 174 | - Implement PATCH method ([#9](https://github.com/ghidoz/angular2-jsonapi/issues/9)) ([4b47443](https://github.com/ghidoz/angular2-jsonapi/commit/4b47443)) 175 | - Add Attribute decorator and tracking of attributes changes + save only dirty attributes ([fe20b8b](https://github.com/ghidoz/angular2-jsonapi/commit/fe20b8b)) 176 | - Add hasDirtyAttributes property to model ([a38fa1c](https://github.com/ghidoz/angular2-jsonapi/commit/a38fa1c)) 177 | - Add rollbackAttributes() method to model ([fc377fb](https://github.com/ghidoz/angular2-jsonapi/commit/fc377fb)) 178 | - Upgrade to Angular 2.0.0 final version ([3c30cdd](https://github.com/ghidoz/angular2-jsonapi/commit/3c30cdd)) 179 | 180 | ### BREAKING CHANGES 181 | - It's mandatory decorate each models' property with the `Attribute()` decorator 182 | - The `createRecord()` method does not call the API anymore, it just creates the object. In order to save the object on the server, you need to call the `save()` method on the model. 183 | - Since this library uses to the `Http` service of the Angular 2.0.0 final, you should use this Angular version in your project. 184 | 185 | # [2.1.0](https://github.com/ghidoz/angular2-jsonapi/compare/v2.0.0...v2.1.0) (2016-09-16) 186 | 187 | ### Bug Fixes 188 | - Enable nested relationships sync ([#7](https://github.com/ghidoz/angular2-jsonapi/issues/7)) (8b7b662) 189 | 190 | # [2.0.0](https://github.com/ghidoz/angular2-jsonapi/compare/v1.2.1...v2.0.0) (2016-09-02) 191 | 192 | ### Bug Fixes 193 | - Allow Http service to be injected in JsonApiDatastore ([#4](https://github.com/ghidoz/angular2-jsonapi/issues/4)) ([1e567c5](https://github.com/ghidoz/angular2-jsonapi/commit/1e567c5)) 194 | 195 | ### Features 196 | - Upgrade to Angular RC6 ([4b02c8a](https://github.com/ghidoz/angular2-jsonapi/commit/4b02c8a)) 197 | - Implement NgModule ([410b3b2](https://github.com/ghidoz/angular2-jsonapi/commit/410b3b2)) 198 | 199 | ### BREAKING CHANGES 200 | - Since this library uses to the `Http` service of the Angular RC6, you should use this Angular version in your project. 201 | - Instead of adding `PROVIDERS` to bootstrap dependencies, import the new `JsonApiModule` in the main module 202 | 203 | # [1.2.1](https://github.com/ghidoz/angular2-jsonapi/compare/v1.2.0...v1.2.1) (2016-08-30) 204 | 205 | ### Bug Fixes 206 | - Fix: id should be a string ([#5](https://github.com/ghidoz/angular2-jsonapi/issues/5)) ([72b7fb0](https://github.com/ghidoz/angular2-jsonapi/commit/72b7fb0)) 207 | 208 | # [1.2.0](https://github.com/ghidoz/angular2-jsonapi/compare/v1.1.0...v1.2.0) (2016-08-26) 209 | 210 | ### Bug Fixes 211 | - Use a string for include field instead of the class name ([e7f7b7f](https://github.com/ghidoz/angular2-jsonapi/commit/e7f7b7f)) 212 | 213 | ### Features 214 | - Add BelongsTo relationship ([edfc2af](https://github.com/ghidoz/angular2-jsonapi/commit/edfc2af)) 215 | 216 | ### BREAKING CHANGES 217 | - You cannot use the class name anymore when including the relationship. You should use the field name as a string. 218 | 219 | # [1.1.0](https://github.com/ghidoz/angular2-jsonapi/compare/v1.0.0...v1.1.0) (2016-08-25) 220 | 221 | ### Features 222 | - Can set global custom headers and headers for each call ([#2](https://github.com/ghidoz/angular2-jsonapi/issues/2)) ([bef14f3](https://github.com/ghidoz/angular2-jsonapi/commit/bef14f3)) 223 | 224 | # 1.0.0 (2016-08-05) 225 | 226 | ### Features 227 | - Add config and models decorators 228 | - Add query method 229 | - Add findRecord 230 | - Add createRecord 231 | - Add basic relationship parsing 232 | -------------------------------------------------------------------------------- /projects/angular2-jsonapi/src/models/json-api.model.spec.ts: -------------------------------------------------------------------------------- 1 | import { TestBed } from '@angular/core/testing'; 2 | import { parseISO } from 'date-fns'; 3 | import { Author } from '../../test/models/author.model'; 4 | import { AUTHOR_ID, BOOK_PUBLISHED, BOOK_TITLE, CHAPTER_TITLE, getAuthorData, getIncludedBooks } from '../../test/fixtures/author.fixture'; 5 | import { Book } from '../../test/models/book.model'; 6 | import { HttpClientTestingModule } from '@angular/common/http/testing'; 7 | import { Datastore } from '../../test/datastore.service'; 8 | import { Chapter } from '../../test/models/chapter.model'; 9 | 10 | let datastore: Datastore; 11 | 12 | describe('JsonApiModel', () => { 13 | 14 | beforeEach(() => { 15 | 16 | TestBed.configureTestingModule({ 17 | imports: [ 18 | HttpClientTestingModule, 19 | ], 20 | providers: [ 21 | Datastore 22 | ] 23 | }); 24 | 25 | datastore = TestBed.get(Datastore); 26 | }); 27 | 28 | describe('constructor', () => { 29 | 30 | it('should be instantiated with attributes', () => { 31 | const DATA = { 32 | id: '1', 33 | attributes: { 34 | name: 'Daniele', 35 | surname: 'Ghidoli', 36 | date_of_birth: '1987-05-25', 37 | school: {name: 'Massachusetts Institute of Technology', students: 11319, foundation: '1861-10-04'} 38 | } 39 | }; 40 | const author: Author = new Author(datastore, DATA); 41 | expect(author).toBeDefined(); 42 | expect(author.id).toBe('1'); 43 | expect(author.name).toBe('Daniele'); 44 | expect(author.date_of_birth.getTime()).toBe(parseISO('1987-05-25').getTime()); 45 | expect(author.school.name).toBe('Massachusetts Institute of Technology'); 46 | expect(author.school.students).toBe(11319); 47 | expect(author.school.foundation.getTime()).toBe(parseISO('1861-10-04').getTime()); 48 | }); 49 | 50 | it('should be instantiated without attributes', () => { 51 | const author: Author = new Author(datastore); 52 | expect(author).toBeDefined(); 53 | expect(author.id).toBeUndefined(); 54 | expect(author.date_of_birth).toBeUndefined(); 55 | }); 56 | 57 | }); 58 | 59 | describe('hasDirtyAttributes', () => { 60 | 61 | it('should be instantiated with attributes', () => { 62 | const DATA = { 63 | id: '1', 64 | attributes: { 65 | name: 'Daniele', 66 | surname: 'Ghidoli', 67 | date_of_birth: '1987-05-25' 68 | } 69 | }; 70 | const author: Author = new Author(datastore, DATA); 71 | expect(author.hasDirtyAttributes).toBeFalsy(); 72 | }); 73 | 74 | it('should have dirty attributes after change', () => { 75 | const author: Author = new Author(datastore); 76 | expect(author.hasDirtyAttributes).toBeFalsy(); 77 | author.name = 'Peter'; 78 | expect(author.hasDirtyAttributes).toBeTruthy(); 79 | }); 80 | 81 | it('should reset dirty attributes', () => { 82 | const DATA = { 83 | id: '1', 84 | attributes: { 85 | name: 'Daniele', 86 | surname: 'Ghidoli', 87 | date_of_birth: '1987-05-25' 88 | } 89 | }; 90 | const author: Author = new Author(datastore, DATA); 91 | author.name = 'Peter'; 92 | author.rollbackAttributes(); 93 | expect(author.hasDirtyAttributes).toBeFalsy(); 94 | expect(author.name).toContain(DATA.attributes.name); 95 | }); 96 | 97 | }); 98 | 99 | describe('syncRelationships', () => { 100 | 101 | let author: Author; 102 | 103 | it('should return the object when there is no relationship included', () => { 104 | author = new Author(datastore, getAuthorData()); 105 | expect(author).toBeDefined(); 106 | expect(author.id).toBe(AUTHOR_ID); 107 | expect(author.books).toBeUndefined(); 108 | }); 109 | 110 | describe('parseHasMany', () => { 111 | 112 | it('should return the parsed relationships when one is included', () => { 113 | const BOOK_NUMBER = 4; 114 | const DATA = getAuthorData('books', BOOK_NUMBER); 115 | author = new Author(datastore, DATA); 116 | author.syncRelationships(DATA, getIncludedBooks(BOOK_NUMBER)); 117 | expect(author).toBeDefined(); 118 | expect(author.id).toBe(AUTHOR_ID); 119 | expect(author.books).toBeDefined(); 120 | expect(author.books.length).toBe(BOOK_NUMBER); 121 | author.books.forEach((book: Book, index: number) => { 122 | expect(book instanceof Book).toBeTruthy(); 123 | expect(+book.id).toBe(index + 1); 124 | expect(book.title).toBe(BOOK_TITLE); 125 | expect(book.date_published.valueOf()).toBe(parseISO(BOOK_PUBLISHED).valueOf()); 126 | }); 127 | }); 128 | 129 | it('should return an empty array for hasMany relationship when one is included without any elements', () => { 130 | const BOOK_NUMBER = 0; 131 | const DATA = getAuthorData('books', BOOK_NUMBER); 132 | author = new Author(datastore, DATA); 133 | author.syncRelationships(DATA, getIncludedBooks(BOOK_NUMBER)); 134 | 135 | expect(author).toBeDefined(); 136 | expect(author.id).toBe(AUTHOR_ID); 137 | expect(author.books).toBeDefined(); 138 | expect(author.books.length).toBe(BOOK_NUMBER); 139 | }); 140 | 141 | it('should parse infinite levels of relationships by reference', () => { 142 | const BOOK_NUMBER = 4; 143 | const DATA = getAuthorData('books', BOOK_NUMBER); 144 | author = new Author(datastore, DATA); 145 | datastore.addToStore(author); 146 | author.syncRelationships(DATA, getIncludedBooks(BOOK_NUMBER)); 147 | author.books.forEach((book: Book, index: number) => { 148 | expect(book.author).toBeDefined(); 149 | expect(book.author).toEqual(author); 150 | expect(book.author.books[index]).toEqual(author.books[index]); 151 | }); 152 | 153 | }); 154 | 155 | it('should parse relationships included in more than one resource', () => { 156 | const BOOK_NUMBER = 4; 157 | const REL = 'books.category.books'; 158 | const DATA = getAuthorData(REL, BOOK_NUMBER); 159 | author = new Author(datastore, DATA); 160 | author.syncRelationships(DATA, getIncludedBooks(BOOK_NUMBER, REL)); 161 | author.books.forEach((book: Book) => { 162 | expect(book.category).toBeDefined(); 163 | expect(book.category.books.length).toBe(BOOK_NUMBER); 164 | }); 165 | }); 166 | 167 | it('should return the parsed relationships when two nested ones are included', () => { 168 | const REL = 'books,books.chapters'; 169 | const BOOK_NUMBER = 2; 170 | const CHAPTERS_NUMBER = 4; 171 | const DATA = getAuthorData(REL, BOOK_NUMBER); 172 | const INCLUDED = getIncludedBooks(BOOK_NUMBER, REL, CHAPTERS_NUMBER); 173 | author = new Author(datastore, DATA); 174 | author.syncRelationships(DATA, INCLUDED); 175 | expect(author).toBeDefined(); 176 | expect(author.id).toBe(AUTHOR_ID); 177 | expect(author.books).toBeDefined(); 178 | expect(author.books.length).toBe(BOOK_NUMBER); 179 | author.books.forEach((book: Book, index: number) => { 180 | expect(book instanceof Book).toBeTruthy(); 181 | expect(+book.id).toBe(index + 1); 182 | expect(book.title).toBe(BOOK_TITLE); 183 | expect(book.date_published.valueOf()).toBe(parseISO(BOOK_PUBLISHED).valueOf()); 184 | expect(book.chapters).toBeDefined(); 185 | expect(book.chapters.length).toBe(CHAPTERS_NUMBER); 186 | book.chapters.forEach((chapter: Chapter, cindex: number) => { 187 | expect(chapter instanceof Chapter).toBeTruthy(); 188 | expect(chapter.title).toBe(CHAPTER_TITLE); 189 | expect(chapter.book).toEqual(book); 190 | }); 191 | }); 192 | }); 193 | 194 | describe('update relationships', () => { 195 | it('should return updated relationship', () => { 196 | const REL = 'books'; 197 | const BOOK_NUMBER = 1; 198 | const CHAPTERS_NUMBER = 4; 199 | const DATA = getAuthorData(REL, BOOK_NUMBER); 200 | const INCLUDED = getIncludedBooks(BOOK_NUMBER); 201 | const NEW_BOOK_TITLE = 'The Hobbit'; 202 | author = new Author(datastore, DATA); 203 | author.syncRelationships(DATA, INCLUDED); 204 | const newIncluded = INCLUDED.concat([]); 205 | newIncluded.forEach((model) => { 206 | if (model.type === 'books') { 207 | model.attributes.title = NEW_BOOK_TITLE; 208 | } 209 | }); 210 | author.syncRelationships(DATA, newIncluded); 211 | author.books.forEach((book) => { 212 | expect(book.title).toBe(NEW_BOOK_TITLE); 213 | }); 214 | }); 215 | }); 216 | }); 217 | 218 | describe('parseBelongsTo', () => { 219 | it('should parse the first level of belongsTo relationships', () => { 220 | const REL = 'books'; 221 | const BOOK_NUMBER = 2; 222 | const CHAPTERS_NUMBER = 4; 223 | const DATA = getAuthorData(REL, BOOK_NUMBER); 224 | const INCLUDED = getIncludedBooks(BOOK_NUMBER, 'books.chapters,books.firstChapter', 5); 225 | 226 | author = new Author(datastore, DATA); 227 | author.syncRelationships(DATA, INCLUDED); 228 | 229 | expect(author.books[0].firstChapter).toBeDefined(); 230 | }); 231 | 232 | it('should parse the second level of belongsTo relationships', () => { 233 | const REL = 'books'; 234 | const BOOK_NUMBER = 2; 235 | const CHAPTERS_NUMBER = 4; 236 | const DATA = getAuthorData(REL, BOOK_NUMBER); 237 | const INCLUDED = getIncludedBooks( 238 | BOOK_NUMBER, 239 | 'books.chapters,books.firstChapter,books.firstChapter.firstSection', 240 | 5 241 | ); 242 | 243 | author = new Author(datastore, DATA); 244 | author.syncRelationships(DATA, INCLUDED); 245 | 246 | expect(author.books[0].firstChapter.firstSection).toBeDefined(); 247 | }); 248 | 249 | it('should parse the third level of belongsTo relationships', () => { 250 | const REL = 'books'; 251 | const BOOK_NUMBER = 2; 252 | const CHAPTERS_NUMBER = 4; 253 | const DATA = getAuthorData(REL, BOOK_NUMBER); 254 | const INCLUDED = getIncludedBooks( 255 | BOOK_NUMBER, 256 | // tslint:disable-next-line:max-line-length 257 | 'books.chapters,books.firstChapter,books.firstChapter.firstSection,books.firstChapter.firstSection.firstParagraph', 258 | 5 259 | ); 260 | 261 | author = new Author(datastore, DATA); 262 | author.syncRelationships(DATA, INCLUDED); 263 | 264 | expect(author.books[0].firstChapter.firstSection.firstParagraph).toBeDefined(); 265 | }); 266 | 267 | it('should parse the fourth level of belongsTo relationships', () => { 268 | const REL = 'books'; 269 | const BOOK_NUMBER = 2; 270 | const CHAPTERS_NUMBER = 4; 271 | const DATA = getAuthorData(REL, BOOK_NUMBER); 272 | const INCLUDED = getIncludedBooks( 273 | BOOK_NUMBER, 274 | // tslint:disable-next-line:max-line-length 275 | 'books.chapters,books.firstChapter,books.firstChapter.firstSection,books.firstChapter.firstSection.firstParagraph,books.firstChapter.firstSection.firstParagraph.firstSentence', 276 | 5 277 | ); 278 | 279 | author = new Author(datastore, DATA); 280 | author.syncRelationships(DATA, INCLUDED); 281 | 282 | expect(author.books[0].firstChapter.firstSection.firstParagraph.firstSentence).toBeDefined(); 283 | }); 284 | }); 285 | }); 286 | 287 | describe('hasDirtyAttributes & rollbackAttributes', () => { 288 | const author = new Author(datastore, { 289 | id: '1', 290 | attributes: { 291 | name: 'Daniele' 292 | } 293 | }); 294 | 295 | it('should return that has dirty attributes', () => { 296 | author.name = 'New Name'; 297 | expect(author.hasDirtyAttributes).toBeTruthy(); 298 | }); 299 | 300 | it('should to rollback to the initial author name', () => { 301 | author.rollbackAttributes(); 302 | expect(author.name).toEqual('Daniele'); 303 | expect(author.hasDirtyAttributes).toBeFalsy(); 304 | }); 305 | }); 306 | }); 307 | -------------------------------------------------------------------------------- /projects/angular2-jsonapi/README.md: -------------------------------------------------------------------------------- 1 | # Angular 2 JSON API 2 | 3 | A lightweight Angular 2 adapter for [JSON API](http://jsonapi.org/) 4 | 5 | [![Build Status](https://travis-ci.org/ghidoz/angular2-jsonapi.svg?branch=master)](https://travis-ci.org/ghidoz/angular2-jsonapi) 6 | [![Coverage Status](https://coveralls.io/repos/github/ghidoz/angular2-jsonapi/badge.svg?branch=master)](https://coveralls.io/github/ghidoz/angular2-jsonapi?branch=master) 7 | [![Angular 2 Style Guide](https://mgechev.github.io/angular2-style-guide/images/badge.svg)](https://angular.io/styleguide) 8 | [![Dependency Status](https://david-dm.org/ghidoz/angular2-jsonapi.svg)](https://david-dm.org/ghidoz/angular2-jsonapi) 9 | [![devDependency Status](https://david-dm.org/ghidoz/angular2-jsonapi/dev-status.svg)](https://david-dm.org/ghidoz/angular2-jsonapi#info=devDependencies) 10 | [![npm version](https://badge.fury.io/js/angular2-jsonapi.svg)](https://badge.fury.io/js/angular2-jsonapi) 11 | 12 | ## Table of Contents 13 | - [Introduction](#Introduction) 14 | - [Installation](#installation) 15 | - [Usage](#usage) 16 | - [Configuration](#configuration) 17 | - [Finding Records](#finding-records) 18 | - [Querying for Multiple Records](#querying-for-multiple-records) 19 | - [Retrieving a Single Record](#retrieving-a-single-record) 20 | - [Creating, Updating and Deleting](#creating-updating-and-deleting) 21 | - [Creating Records](#creating-records) 22 | - [Updating Records](#updating-records) 23 | - [Persisting Records](#persisting-records) 24 | - [Deleting Records](#deleting-records) 25 | - [Relationships](#relationships) 26 | - [Metadata](#metadata) 27 | - [Custom Headers](#custom-headers) 28 | - [Error handling](#error-handling) 29 | - [Dates](#dates) 30 | - [Development](#development) 31 | - [Additional tools](#additional-tools) 32 | - [License](#licence) 33 | 34 | ## Introduction 35 | Why this library? Because [JSON API](http://jsonapi.org/) is an awesome standard, but the responses that you get and the way to interact with endpoints are not really easy and directly consumable from Angular. 36 | 37 | Moreover, using Angular2 and Typescript, we like to interact with classes and models, not with bare JSONs. Thanks to this library, you will be able to map all your data into models and relationships like these: 38 | 39 | ```javascript 40 | [ 41 | Post{ 42 | id: 1, 43 | title: 'My post', 44 | content: 'My content', 45 | comments: [ 46 | Comment{ 47 | id: 1, 48 | // ... 49 | }, 50 | Comment{ 51 | id: 2, 52 | // ... 53 | } 54 | ] 55 | }, 56 | // ... 57 | ] 58 | ``` 59 | 60 | 61 | ## Installation 62 | 63 | To install this library, run: 64 | ```bash 65 | $ npm install angular2-jsonapi --save 66 | ``` 67 | 68 | Add the `JsonApiModule` to your app module imports: 69 | ```typescript 70 | import { JsonApiModule } from 'angular2-jsonapi'; 71 | 72 | @NgModule({ 73 | imports: [ 74 | BrowserModule, 75 | JsonApiModule 76 | ], 77 | declarations: [ 78 | AppComponent 79 | ], 80 | bootstrap: [AppComponent] 81 | }) 82 | export class AppModule { } 83 | ``` 84 | 85 | ## Usage 86 | 87 | ### Configuration 88 | 89 | Firstly, create your `Datastore` service: 90 | - Extend the `JsonApiDatastore` class 91 | - 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) 92 | - Pass the `HttpClient` depencency to the parent constructor. 93 | 94 | ```typescript 95 | import { JsonApiDatastoreConfig, JsonApiDatastore, DatastoreConfig } from 'angular2-jsonapi'; 96 | 97 | const config: DatastoreConfig = { 98 | baseUrl: 'http://localhost:8000/v1/', 99 | models: { 100 | posts: Post, 101 | comments: Comment, 102 | users: User 103 | } 104 | } 105 | 106 | @Injectable() 107 | @JsonApiDatastoreConfig(config) 108 | export class Datastore extends JsonApiDatastore { 109 | 110 | constructor(http: HttpClient) { 111 | super(http); 112 | } 113 | 114 | } 115 | ``` 116 | 117 | Then set up your models: 118 | - Extend the `JsonApiModel` class 119 | - Decorate it with `@JsonApiModelConfig`, passing the `type` 120 | - Decorate the class properties with `@Attribute` 121 | - Decorate the relationships attributes with `@HasMany` and `@BelongsTo` 122 | - (optional) Define your [Metadata](#metadata) 123 | 124 | ```typescript 125 | import { JsonApiModelConfig, JsonApiModel, Attribute, HasMany, BelongsTo } from 'angular2-jsonapi'; 126 | 127 | @JsonApiModelConfig({ 128 | type: 'posts' 129 | }) 130 | export class Post extends JsonApiModel { 131 | 132 | @Attribute() 133 | title: string; 134 | 135 | @Attribute() 136 | content: string; 137 | 138 | @Attribute({ serializedName: 'created-at' }) 139 | createdAt: Date; 140 | 141 | @HasMany() 142 | comments: Comment[]; 143 | } 144 | 145 | @JsonApiModelConfig({ 146 | type: 'comments' 147 | }) 148 | export class Comment extends JsonApiModel { 149 | 150 | @Attribute() 151 | title: string; 152 | 153 | @Attribute() 154 | created_at: Date; 155 | 156 | @BelongsTo() 157 | post: Post; 158 | 159 | @BelongsTo() 160 | user: User; 161 | } 162 | 163 | @JsonApiModelConfig({ 164 | type: 'users' 165 | }) 166 | export class User extends JsonApiModel { 167 | 168 | @Attribute() 169 | name: string; 170 | // ... 171 | } 172 | ``` 173 | 174 | ### Finding Records 175 | 176 | #### Querying for Multiple Records 177 | 178 | Now, you can use your `Datastore` in order to query your API with the `findAll()` method: 179 | - The first argument is the type of object you want to query. 180 | - The second argument is the list of params: write them in JSON format and they will be serialized. 181 | - The returned value is a document which gives access to the metdata and the models. 182 | ```typescript 183 | // ... 184 | constructor(private datastore: Datastore) { } 185 | 186 | getPosts(){ 187 | this.datastore.findAll(Post, { 188 | page: { size: 10, number: 1 }, 189 | filter: { 190 | title: 'My Post', 191 | }, 192 | }).subscribe( 193 | (posts: JsonApiQueryData) => console.log(posts.getModels()) 194 | ); 195 | } 196 | ``` 197 | 198 | Use `peekAll()` to retrieve all of the records for a given type that are already loaded into the store, without making a network request: 199 | 200 | ```typescript 201 | let posts = this.datastore.peekAll(Post); 202 | ``` 203 | 204 | 205 | #### Retrieving a Single Record 206 | 207 | Use `findRecord()` to retrieve a record by its type and ID: 208 | 209 | ```typescript 210 | this.datastore.findRecord(Post, '1').subscribe( 211 | (post: Post) => console.log(post) 212 | ); 213 | ``` 214 | 215 | Use `peekRecord()` to retrieve a record by its type and ID, without making a network request. This will return the record only if it is already present in the store: 216 | 217 | ```typescript 218 | let post = this.datastore.peekRecord(Post, '1'); 219 | ``` 220 | 221 | ### Creating, Updating and Deleting 222 | 223 | #### Creating Records 224 | 225 | You can create records by calling the `createRecord()` method on the datastore: 226 | - The first argument is the type of object you want to create. 227 | - The second is a JSON with the object attributes. 228 | 229 | ```typescript 230 | this.datastore.createRecord(Post, { 231 | title: 'My post', 232 | content: 'My content' 233 | }); 234 | ``` 235 | 236 | #### Updating Records 237 | 238 | Making changes to records is as simple as setting the attribute you want to change: 239 | 240 | ```typescript 241 | this.datastore.findRecord(Post, '1').subscribe( 242 | (post: Post) => { 243 | post.title = 'New title'; 244 | } 245 | ); 246 | ``` 247 | 248 | #### Persisting Records 249 | 250 | Records are persisted on a per-instance basis. Call `save()` on any instance of `JsonApiModel` and it will make a network request. 251 | 252 | The library takes care of tracking the state of each record for you, so that newly created records are treated differently from existing records when saving. 253 | 254 | Newly created records will be `POST`ed: 255 | 256 | ```typescript 257 | let post = this.datastore.createRecord(Post, { 258 | title: 'My post', 259 | content: 'My content' 260 | }); 261 | 262 | post.save().subscribe(); // => POST to '/posts' 263 | ``` 264 | 265 | Records that already exist on the backend are updated using the HTTP `PATCH` verb: 266 | 267 | ```typescript 268 | this.datastore.findRecord(Post, '1').subscribe( 269 | (post: Post) => { 270 | post.title = 'New title'; 271 | post.save().subscribe(); // => PATCH to '/posts/1' 272 | } 273 | ); 274 | ``` 275 | 276 | The `save()` method will return an `Observer` that you need to subscribe: 277 | 278 | ```typescript 279 | post.save().subscribe( 280 | (post: Post) => console.log(post) 281 | ); 282 | ``` 283 | 284 | **Note**: always remember to call the `subscribe()` method, even if you are not interested in doing something with the response. Since the `http` method return a [cold Observable](https://github.com/Reactive-Extensions/RxJS/blob/master/doc/gettingstarted/creating.md#cold-vs-hot-observables), the request won't go out until something subscribes to the observable. 285 | 286 | You can tell if a record has outstanding changes that have not yet been saved by checking its `hasDirtyAttributes` property. 287 | 288 | At this point, you can either persist your changes via `save()` or you can roll back your changes. Calling `rollbackAttributes()` for a saved record reverts all the dirty attributes to their original value. 289 | 290 | ```typescript 291 | this.datastore.findRecord(Post, '1').subscribe( 292 | (post: Post) => { 293 | console.log(post.title); // => 'Old title' 294 | console.log(post.hasDirtyAttributes); // => false 295 | post.title = 'New title'; 296 | console.log(post.hasDirtyAttributes); // => true 297 | post.rollbackAttributes(); 298 | console.log(post.hasDirtyAttributes); // => false 299 | console.log(post.title); // => 'Old title' 300 | } 301 | ); 302 | ``` 303 | 304 | #### Deleting Records 305 | 306 | For deleting a record, just call the datastore's method `deleteRecord()`, passing the type and the id of the record: 307 | 308 | ```typescript 309 | this.datastore.deleteRecord(Post, '1').subscribe(() => { 310 | // deleted! 311 | }); 312 | ``` 313 | 314 | ### Relationships 315 | 316 | #### Querying records 317 | 318 | In order to query an object including its relationships, you can pass in its options the attribute name you want to load with the relationships: 319 | 320 | ```typescript 321 | this.datastore.findAll(Post, { 322 | page: { size: 10, number: 1}, 323 | include: 'comments' 324 | }).subscribe( 325 | (document) => { 326 | console.log(document.getMeta()); // metadata 327 | console.log(document.getModels()); // models 328 | } 329 | ); 330 | ``` 331 | 332 | The same, if you want to include relationships when finding a record: 333 | 334 | ```typescript 335 | this.datastore.findRecord(Post, '1', { 336 | include: 'comments,comments.user' 337 | }).subscribe( 338 | (post: Post) => console.log(post) 339 | ); 340 | ``` 341 | 342 | The library will try to resolve relationships on infinite levels connecting nested objects by reference. So that you can have a `Post`, with a list of `Comment`s, that have a `User` that has `Post`s, that have `Comment`s... etc. 343 | 344 | **Note**: If you `include` multiple relationships, **do not** use whitespaces in the `include` string (e.g. `comments, comments.user`) as those will be encoded to `%20` and this results in a broken URL. 345 | 346 | #### Creating Records 347 | 348 | If the object you want to create has a **one-to-many** relationship, you can do this: 349 | 350 | ```typescript 351 | let post = this.datastore.peekRecord(Post, '1'); 352 | let comment = this.datastore.createRecord(Comment, { 353 | title: 'My comment', 354 | post: post 355 | }); 356 | comment.save().subscribe(); 357 | ``` 358 | 359 | The library will do its best to discover which relationships map to one another. In the code above, for example, setting the `comment` relationship with the `post` will update the `post.comments` array, automatically adding the `comment` object! 360 | 361 | If you want to include a relationship when creating a record to have it parsed in the response, you can pass the `params` object to the `save()` method: 362 | 363 | ```typescript 364 | comment.save({ 365 | include: 'user' 366 | }).subscribe( 367 | (comment: Comment) => console.log(comment) 368 | ); 369 | ``` 370 | 371 | #### Updating Records 372 | 373 | You can also update an object that comes from a relationship: 374 | 375 | ```typescript 376 | this.datastore.findRecord(Post, '1', { 377 | include: 'comments' 378 | }).subscribe( 379 | (post: Post) => { 380 | let comment: Comment = post.comments[0]; 381 | comment.title = 'Cool'; 382 | comment.save().subscribe((comment: Comment) => { 383 | console.log(comment); 384 | }); 385 | } 386 | ); 387 | ``` 388 | 389 | ### Metadata 390 | Metadata such as links or data for pagination purposes can also be included in the result. 391 | 392 | For each model a specific MetadataModel can be defined. To do this, the class name needs to be added in the ModelConfig. 393 | 394 | If no MetadataModel is explicitly defined, the default one will be used, which contains an array of links and `meta` property. 395 | ``` 396 | @JsonApiModelConfig({ 397 | type: 'deals', 398 | meta: JsonApiMetaModel 399 | }) 400 | export class Deal extends JsonApiModel 401 | ``` 402 | 403 | An instance of a class provided in `meta` property will get the whole response in a constructor. 404 | 405 | ### Datastore config 406 | 407 | 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. 408 | 409 | ```typescript 410 | @JsonApiDatastoreConfig(config: DatastoreConfig) 411 | export class Datastore extends JsonApiDatastore { 412 | private customConfig: DatastoreConfig = { 413 | baseUrl: 'http://something.com' 414 | } 415 | 416 | constructor() { 417 | this.config = this.customConfig; 418 | } 419 | } 420 | ``` 421 | 422 | `config`: 423 | 424 | * `models` - all the models which will be stored in the datastore 425 | * `baseUrl` - base API URL 426 | * `apiVersion` - optional, a string which will be appended to the baseUrl 427 | * `overrides` - used for overriding internal methods to achive custom functionalities 428 | 429 | ##### Overrides 430 | 431 | * `getDirtyAttributes` - determines which model attributes are dirty 432 | * `toQueryString` - transforms query parameters to a query string 433 | 434 | 435 | ### Model config 436 | 437 | ```typescript 438 | @JsonApiModelConfig(options: ModelOptions) 439 | export class Post extends JsonApiModel { } 440 | ``` 441 | 442 | `options`: 443 | 444 | * `type` 445 | * `baseUrl` - if not specified, the global `baseUrl` will be used 446 | * `apiVersion` - if not specified, the global `apiVersion` will be used 447 | * `modelEndpointUrl` - if not specified, `type` will be used instead 448 | * `meta` - optional, metadata model 449 | 450 | ### Decorators 451 | 452 | #### Model decorators 453 | 454 | * `Attribute(options: AttributeDecoratorOptions)` 455 | 456 | * `AttributeDecoratorOptions`: 457 | 458 | * `converter`, optional, must implement `PropertyConverter` interface 459 | * `serializedName`, optional 460 | 461 | ### Custom Headers 462 | 463 | By default, the library adds these headers, according to the [JSON API MIME Types](http://jsonapi.org/#mime-types): 464 | 465 | ``` 466 | Accept: application/vnd.api+json 467 | Content-Type: application/vnd.api+json 468 | ``` 469 | 470 | You can also add your custom headers to be appended to each http call: 471 | 472 | ```typescript 473 | this.datastore.headers = new HttpHeaders({'Authorization': 'Bearer ' + accessToken}); 474 | ``` 475 | 476 | Or you can pass the headers as last argument of any datastore call method: 477 | 478 | ```typescript 479 | this.datastore.findAll(Post, { 480 | include: 'comments' 481 | }, new HttpHeaders({'Authorization': 'Bearer ' + accessToken})); 482 | ``` 483 | 484 | and in the `save()` method: 485 | 486 | ```typescript 487 | post.save({}, new HttpHeaders({'Authorization': 'Bearer ' + accessToken})).subscribe(); 488 | ``` 489 | 490 | ### Custom request options 491 | 492 | You can add your custom request options to be appended to each http call: 493 | 494 | ```typescript 495 | this.datastore.requestOptions = { 496 | withCredentials: false, 497 | myOption: 123 498 | } 499 | ``` 500 | 501 | ### Error handling 502 | 503 | Error handling is done in the `subscribe` method of the returned Observables. 504 | If your server returns valid [JSON API Error Objects](http://jsonapi.org/format/#error-objects) you can access them in your onError method: 505 | 506 | ```typescript 507 | import {ErrorResponse} from "angular2-jsonapi"; 508 | 509 | ... 510 | 511 | this.datastore.findAll(Post).subscribe( 512 | (posts: Post[]) => console.log(posts), 513 | (errorResponse) => { 514 | if (errorResponse instanceof ErrorResponse) { 515 | // do something with errorResponse 516 | console.log(errorResponse.errors); 517 | } 518 | } 519 | ); 520 | ``` 521 | 522 | It's also possible to handle errors for all requests by overriding `handleError(error: any): Observable` in the datastore. 523 | 524 | ### Dates 525 | 526 | The library will automatically transform date values into `Date` objects and it will serialize them when sending to the server. In order to do that, remember to set the type of the corresponding attribute as `Date`: 527 | 528 | ```typescript 529 | @JsonApiModelConfig({ 530 | type: 'posts' 531 | }) 532 | export class Post extends JsonApiModel { 533 | 534 | // ... 535 | 536 | @Attribute() 537 | created_at: Date; 538 | 539 | } 540 | ``` 541 | 542 | Moreover, it should be noted that the following assumptions have been made: 543 | - Dates are expected to be received in one of the ISO 8601 formats, as per the [JSON API spec recommendation](http://jsonapi.org/recommendations/#date-and-time-fields); 544 | - Dates are always sent in full ISO 8601 format, with local timezone and without milliseconds (example: `2001-02-03T14:05:06+07:00`). 545 | 546 | 547 | ## Development 548 | 549 | To generate all `*.js`, `*.js.map` and `*.d.ts` files: 550 | 551 | ```bash 552 | $ npm run build 553 | ``` 554 | 555 | To lint all `*.ts` files: 556 | 557 | ```bash 558 | $ npm run lint 559 | ``` 560 | 561 | ## Additional tools 562 | * Gem for generating the model definitions based on active model serializers: https://github.com/oncore-education/jsonapi_models 563 | 564 | ## Thanks 565 | 566 | This library is inspired by the draft of [this never implemented library](https://github.com/beauby/angular2-jsonapi). 567 | 568 | ## License 569 | 570 | MIT © [Daniele Ghidoli](http://danieleghidoli.it) 571 | -------------------------------------------------------------------------------- /projects/angular2-jsonapi/src/services/json-api-datastore.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@angular/core'; 2 | import { HttpClient, HttpErrorResponse, HttpHeaders, HttpResponse } from '@angular/common/http'; 3 | import { find } from 'lodash-es'; 4 | import { catchError, map } from 'rxjs/operators'; 5 | import { Observable, of, throwError } from 'rxjs'; 6 | import { JsonApiModel } from '../models/json-api.model'; 7 | import { ErrorResponse } from '../models/error-response.model'; 8 | import { JsonApiQueryData } from '../models/json-api-query-data'; 9 | import * as qs from 'qs'; 10 | import { DatastoreConfig } from '../interfaces/datastore-config.interface'; 11 | import { ModelConfig } from '../interfaces/model-config.interface'; 12 | import { AttributeMetadata } from '../constants/symbols'; 13 | import 'reflect-metadata'; 14 | 15 | export type ModelType = new(datastore: JsonApiDatastore, data: any) => T; 16 | 17 | /** 18 | * HACK/FIXME: 19 | * Type 'symbol' cannot be used as an index type. 20 | * TypeScript 2.9.x 21 | * See https://github.com/Microsoft/TypeScript/issues/24587. 22 | */ 23 | // tslint:disable-next-line:variable-name 24 | const AttributeMetadataIndex: string = AttributeMetadata as any; 25 | 26 | @Injectable() 27 | export class JsonApiDatastore { 28 | 29 | protected config: DatastoreConfig; 30 | private globalHeaders: HttpHeaders; 31 | private globalRequestOptions: object = {}; 32 | private internalStore: { [type: string]: { [id: string]: JsonApiModel } } = {}; 33 | private toQueryString: (params: any) => string = this.datastoreConfig.overrides 34 | && this.datastoreConfig.overrides.toQueryString ? 35 | this.datastoreConfig.overrides.toQueryString : this._toQueryString; 36 | 37 | constructor(protected http: HttpClient) { 38 | } 39 | 40 | set headers(headers: HttpHeaders) { 41 | this.globalHeaders = headers; 42 | } 43 | 44 | set requestOptions(requestOptions: object) { 45 | this.globalRequestOptions = requestOptions; 46 | } 47 | 48 | public get datastoreConfig(): DatastoreConfig { 49 | const configFromDecorator: DatastoreConfig = Reflect.getMetadata('JsonApiDatastoreConfig', this.constructor); 50 | return Object.assign(configFromDecorator, this.config); 51 | } 52 | 53 | private get getDirtyAttributes() { 54 | if (this.datastoreConfig.overrides 55 | && this.datastoreConfig.overrides.getDirtyAttributes) { 56 | return this.datastoreConfig.overrides.getDirtyAttributes; 57 | } 58 | return JsonApiDatastore.getDirtyAttributes; 59 | } 60 | 61 | private static getDirtyAttributes(attributesMetadata: any): { string: any } { 62 | const dirtyData: any = {}; 63 | 64 | for (const propertyName in attributesMetadata) { 65 | if (attributesMetadata.hasOwnProperty(propertyName)) { 66 | const metadata: any = attributesMetadata[propertyName]; 67 | 68 | if (metadata.hasDirtyAttributes) { 69 | const attributeName = metadata.serializedName != null ? metadata.serializedName : propertyName; 70 | dirtyData[attributeName] = metadata.serialisationValue ? metadata.serialisationValue : metadata.newValue; 71 | } 72 | } 73 | } 74 | return dirtyData; 75 | } 76 | 77 | /** 78 | * @deprecated use findAll method to take all models 79 | */ 80 | query( 81 | modelType: ModelType, 82 | params?: any, 83 | headers?: HttpHeaders, 84 | customUrl?: string 85 | ): Observable { 86 | const requestHeaders: HttpHeaders = this.buildHttpHeaders(headers); 87 | const url: string = this.buildUrl(modelType, params, undefined, customUrl); 88 | return this.http.get(url, {headers: requestHeaders}) 89 | .pipe( 90 | map((res: any) => this.extractQueryData(res, modelType)), 91 | catchError((res: any) => this.handleError(res)) 92 | ); 93 | } 94 | 95 | public findAll( 96 | modelType: ModelType, 97 | params?: any, 98 | headers?: HttpHeaders, 99 | customUrl?: string 100 | ): Observable> { 101 | const url: string = this.buildUrl(modelType, params, undefined, customUrl); 102 | const requestOptions: object = this.buildRequestOptions({headers, observe: 'response'}); 103 | 104 | return this.http.get(url, requestOptions) 105 | .pipe( 106 | map((res: HttpResponse) => this.extractQueryData(res, modelType, true)), 107 | catchError((res: any) => this.handleError(res)) 108 | ); 109 | } 110 | 111 | public findRecord( 112 | modelType: ModelType, 113 | id: string, 114 | params?: any, 115 | headers?: HttpHeaders, 116 | customUrl?: string 117 | ): Observable { 118 | const requestOptions: object = this.buildRequestOptions({headers, observe: 'response'}); 119 | const url: string = this.buildUrl(modelType, params, id, customUrl); 120 | 121 | return this.http.get(url, requestOptions) 122 | .pipe( 123 | map((res: HttpResponse) => this.extractRecordData(res, modelType)), 124 | catchError((res: any) => this.handleError(res)) 125 | ); 126 | } 127 | 128 | public createRecord(modelType: ModelType, data?: any): T { 129 | return new modelType(this, {attributes: data}); 130 | } 131 | 132 | public saveRecord( 133 | attributesMetadata: any, 134 | model: T, 135 | params?: any, 136 | headers?: HttpHeaders, 137 | customUrl?: string 138 | ): Observable { 139 | const modelType = model.constructor as ModelType; 140 | const modelConfig: ModelConfig = model.modelConfig; 141 | const typeName: string = modelConfig.type; 142 | const relationships: any = this.getRelationships(model); 143 | const url: string = this.buildUrl(modelType, params, model.id, customUrl); 144 | 145 | let httpCall: Observable>; 146 | const body: any = { 147 | data: { 148 | relationships, 149 | type: typeName, 150 | id: model.id, 151 | attributes: this.getDirtyAttributes(attributesMetadata, model) 152 | } 153 | }; 154 | 155 | const requestOptions: object = this.buildRequestOptions({headers, observe: 'response'}); 156 | 157 | if (model.id) { 158 | httpCall = this.http.patch(url, body, requestOptions) as Observable>; 159 | } else { 160 | httpCall = this.http.post(url, body, requestOptions) as Observable>; 161 | } 162 | 163 | return httpCall 164 | .pipe( 165 | map((res) => [200, 201].indexOf(res.status) !== -1 ? this.extractRecordData(res, modelType, model) : model), 166 | catchError((res) => { 167 | if (res == null) { 168 | return of(model); 169 | } 170 | return this.handleError(res); 171 | }), 172 | map((res) => this.updateRelationships(res, relationships)) 173 | ); 174 | } 175 | 176 | public deleteRecord( 177 | modelType: ModelType, 178 | id: string, 179 | headers?: HttpHeaders, 180 | customUrl?: string 181 | ): Observable { 182 | const requestOptions: object = this.buildRequestOptions({headers}); 183 | const url: string = this.buildUrl(modelType, null, id, customUrl); 184 | 185 | return this.http.delete(url, requestOptions) 186 | .pipe( 187 | catchError((res: HttpErrorResponse) => this.handleError(res)) 188 | ); 189 | } 190 | 191 | public peekRecord(modelType: ModelType, id: string): T | null { 192 | const type: string = Reflect.getMetadata('JsonApiModelConfig', modelType).type; 193 | return this.internalStore[type] ? this.internalStore[type][id] as T : null; 194 | } 195 | 196 | public peekAll(modelType: ModelType): Array { 197 | const type = Reflect.getMetadata('JsonApiModelConfig', modelType).type; 198 | const typeStore = this.internalStore[type]; 199 | return typeStore ? Object.keys(typeStore).map((key) => typeStore[key] as T) : []; 200 | } 201 | 202 | public deserializeModel(modelType: ModelType, data: any) { 203 | data.attributes = this.transformSerializedNamesToPropertyNames(modelType, data.attributes); 204 | return new modelType(this, data); 205 | } 206 | 207 | public addToStore(modelOrModels: JsonApiModel | JsonApiModel[]): void { 208 | const models = Array.isArray(modelOrModels) ? modelOrModels : [modelOrModels]; 209 | const type: string = models[0].modelConfig.type; 210 | let typeStore = this.internalStore[type]; 211 | 212 | if (!typeStore) { 213 | typeStore = this.internalStore[type] = {}; 214 | } 215 | 216 | for (const model of models) { 217 | typeStore[model.id] = model; 218 | } 219 | } 220 | 221 | public transformSerializedNamesToPropertyNames(modelType: ModelType, attributes: any) { 222 | const serializedNameToPropertyName = this.getModelPropertyNames(modelType.prototype); 223 | const properties: any = {}; 224 | 225 | Object.keys(serializedNameToPropertyName).forEach((serializedName) => { 226 | if (attributes && attributes[serializedName] !== null && attributes[serializedName] !== undefined) { 227 | properties[serializedNameToPropertyName[serializedName]] = attributes[serializedName]; 228 | } 229 | }); 230 | 231 | return properties; 232 | } 233 | 234 | protected buildUrl( 235 | modelType: ModelType, 236 | params?: any, 237 | id?: string, 238 | customUrl?: string 239 | ): string { 240 | // TODO: use HttpParams instead of appending a string to the url 241 | const queryParams: string = this.toQueryString(params); 242 | 243 | if (customUrl) { 244 | return queryParams ? `${customUrl}?${queryParams}` : customUrl; 245 | } 246 | 247 | const modelConfig: ModelConfig = Reflect.getMetadata('JsonApiModelConfig', modelType); 248 | 249 | const baseUrl = modelConfig.baseUrl || this.datastoreConfig.baseUrl; 250 | const apiVersion = modelConfig.apiVersion || this.datastoreConfig.apiVersion; 251 | const modelEndpointUrl: string = modelConfig.modelEndpointUrl || modelConfig.type; 252 | 253 | const url: string = [baseUrl, apiVersion, modelEndpointUrl, id].filter((x) => x).join('/'); 254 | 255 | return queryParams ? `${url}?${queryParams}` : url; 256 | } 257 | 258 | protected getRelationships(data: any): any { 259 | let relationships: any; 260 | 261 | const belongsToMetadata: any[] = Reflect.getMetadata('BelongsTo', data) || []; 262 | const hasManyMetadata: any[] = Reflect.getMetadata('HasMany', data) || []; 263 | 264 | for (const key in data) { 265 | if (data.hasOwnProperty(key)) { 266 | if (data[key] instanceof JsonApiModel) { 267 | relationships = relationships || {}; 268 | 269 | if (data[key].id) { 270 | const entity = belongsToMetadata.find((it: any) => it.propertyName === key); 271 | const relationshipKey = entity.relationship; 272 | relationships[relationshipKey] = { 273 | data: this.buildSingleRelationshipData(data[key]) 274 | }; 275 | } 276 | } else if (data[key] instanceof Array) { 277 | const entity = hasManyMetadata.find((it: any) => it.propertyName === key); 278 | if (entity && this.isValidToManyRelation(data[key])) { 279 | relationships = relationships || {}; 280 | 281 | const relationshipKey = entity.relationship; 282 | const relationshipData = data[key] 283 | .filter((model: JsonApiModel) => model.id) 284 | .map((model: JsonApiModel) => this.buildSingleRelationshipData(model)); 285 | 286 | relationships[relationshipKey] = { 287 | data: relationshipData 288 | }; 289 | } 290 | } else if (data[key] === null) { 291 | const entity = belongsToMetadata.find((entity: any) => entity.propertyName === key); 292 | 293 | if (entity) { 294 | relationships = relationships || {}; 295 | 296 | relationships[entity.relationship] = { 297 | data: null 298 | }; 299 | } 300 | } 301 | } 302 | } 303 | 304 | return relationships; 305 | } 306 | 307 | protected isValidToManyRelation(objects: Array): boolean { 308 | if (!objects.length) { 309 | return true; 310 | } 311 | const isJsonApiModel = objects.every((item) => item instanceof JsonApiModel); 312 | if (!isJsonApiModel) { 313 | return false; 314 | } 315 | const types = objects.map((item: JsonApiModel) => item.modelConfig.modelEndpointUrl || item.modelConfig.type); 316 | return types 317 | .filter((type: string, index: number, self: string[]) => self.indexOf(type) === index) 318 | .length === 1; 319 | } 320 | 321 | protected buildSingleRelationshipData(model: JsonApiModel): any { 322 | const relationshipType: string = model.modelConfig.type; 323 | const relationShipData: { type: string, id?: string, attributes?: any } = {type: relationshipType}; 324 | 325 | if (model.id) { 326 | relationShipData.id = model.id; 327 | } else { 328 | const attributesMetadata: any = Reflect.getMetadata('Attribute', model); 329 | relationShipData.attributes = this.getDirtyAttributes(attributesMetadata, model); 330 | } 331 | 332 | return relationShipData; 333 | } 334 | 335 | protected extractQueryData( 336 | response: HttpResponse, 337 | modelType: ModelType, 338 | withMeta = false 339 | ): Array | JsonApiQueryData { 340 | const body: any = response.body; 341 | const models: T[] = []; 342 | 343 | body.data.forEach((data: any) => { 344 | const model: T = this.deserializeModel(modelType, data); 345 | this.addToStore(model); 346 | 347 | if (body.included) { 348 | model.syncRelationships(data, body.included.concat(data)); 349 | this.addToStore(model); 350 | } 351 | 352 | models.push(model); 353 | }); 354 | 355 | if (withMeta && withMeta === true) { 356 | return new JsonApiQueryData(models, this.parseMeta(body, modelType)); 357 | } 358 | 359 | return models; 360 | } 361 | 362 | protected extractRecordData( 363 | res: HttpResponse, 364 | modelType: ModelType, 365 | model?: T 366 | ): T { 367 | const body: any = res.body; 368 | // Error in Angular < 5.2.4 (see https://github.com/angular/angular/issues/20744) 369 | // null is converted to 'null', so this is temporary needed to make testcase possible 370 | // (and to avoid a decrease of the coverage) 371 | if (!body || body === 'null') { 372 | throw new Error('no body in response'); 373 | } 374 | 375 | if (!body.data) { 376 | if (res.status === 201 || !model) { 377 | throw new Error('expected data in response'); 378 | } 379 | return model; 380 | } 381 | 382 | if (model) { 383 | model.modelInitialization = true; 384 | model.id = body.data.id; 385 | Object.assign(model, body.data.attributes); 386 | model.modelInitialization = false; 387 | } 388 | 389 | const deserializedModel = model || this.deserializeModel(modelType, body.data); 390 | this.addToStore(deserializedModel); 391 | if (body.included) { 392 | deserializedModel.syncRelationships(body.data, body.included); 393 | this.addToStore(deserializedModel); 394 | } 395 | 396 | return deserializedModel; 397 | } 398 | 399 | protected handleError(error: any): Observable { 400 | if ( 401 | error instanceof HttpErrorResponse && 402 | error.error instanceof Object && 403 | error.error.errors && 404 | error.error.errors instanceof Array 405 | ) { 406 | const errors: ErrorResponse = new ErrorResponse(error.error.errors); 407 | return throwError(errors); 408 | } 409 | 410 | return throwError(error); 411 | } 412 | 413 | protected parseMeta(body: any, modelType: ModelType): any { 414 | const metaModel: any = Reflect.getMetadata('JsonApiModelConfig', modelType).meta; 415 | return new metaModel(body); 416 | } 417 | 418 | /** 419 | * @deprecated use buildHttpHeaders method to build request headers 420 | */ 421 | protected getOptions(customHeaders?: HttpHeaders): any { 422 | return { 423 | headers: this.buildHttpHeaders(customHeaders), 424 | }; 425 | } 426 | 427 | protected buildHttpHeaders(customHeaders?: HttpHeaders): HttpHeaders { 428 | let requestHeaders: HttpHeaders = new HttpHeaders({ 429 | Accept: 'application/vnd.api+json', 430 | 'Content-Type': 'application/vnd.api+json' 431 | }); 432 | 433 | if (this.globalHeaders) { 434 | this.globalHeaders.keys().forEach((key) => { 435 | if (this.globalHeaders.has(key)) { 436 | requestHeaders = requestHeaders.set(key, this.globalHeaders.get(key)); 437 | } 438 | }); 439 | } 440 | 441 | if (customHeaders) { 442 | customHeaders.keys().forEach((key) => { 443 | if (customHeaders.has(key)) { 444 | requestHeaders = requestHeaders.set(key, customHeaders.get(key)); 445 | } 446 | }); 447 | } 448 | 449 | return requestHeaders; 450 | } 451 | 452 | protected resetMetadataAttributes(res: T, attributesMetadata: any, modelType: ModelType) { 453 | for (const propertyName in attributesMetadata) { 454 | if (attributesMetadata.hasOwnProperty(propertyName)) { 455 | const metadata: any = attributesMetadata[propertyName]; 456 | 457 | if (metadata.hasDirtyAttributes) { 458 | metadata.hasDirtyAttributes = false; 459 | } 460 | } 461 | } 462 | 463 | // @ts-ignore 464 | res[AttributeMetadataIndex] = attributesMetadata; 465 | return res; 466 | } 467 | 468 | protected updateRelationships(model: T, relationships: any): T { 469 | const modelsTypes: any = Reflect.getMetadata('JsonApiDatastoreConfig', this.constructor).models; 470 | 471 | for (const relationship in relationships) { 472 | if (relationships.hasOwnProperty(relationship) && model.hasOwnProperty(relationship) && model[relationship]) { 473 | const relationshipModel: JsonApiModel = model[relationship]; 474 | const hasMany: any[] = Reflect.getMetadata('HasMany', relationshipModel); 475 | const propertyHasMany: any = find(hasMany, (property) => { 476 | return modelsTypes[property.relationship] === model.constructor; 477 | }); 478 | 479 | if (propertyHasMany) { 480 | relationshipModel[propertyHasMany.propertyName] = relationshipModel[propertyHasMany.propertyName] || []; 481 | 482 | const indexOfModel = relationshipModel[propertyHasMany.propertyName].indexOf(model); 483 | 484 | if (indexOfModel === -1) { 485 | relationshipModel[propertyHasMany.propertyName].push(model); 486 | } else { 487 | relationshipModel[propertyHasMany.propertyName][indexOfModel] = model; 488 | } 489 | } 490 | } 491 | } 492 | 493 | return model; 494 | } 495 | 496 | protected getModelPropertyNames(model: JsonApiModel) { 497 | return Reflect.getMetadata('AttributeMapping', model) || []; 498 | } 499 | 500 | private buildRequestOptions(customOptions: any = {}): object { 501 | const httpHeaders: HttpHeaders = this.buildHttpHeaders(customOptions.headers); 502 | 503 | const requestOptions: object = Object.assign(customOptions, { 504 | headers: httpHeaders 505 | }); 506 | 507 | return Object.assign(this.globalRequestOptions, requestOptions); 508 | } 509 | 510 | private _toQueryString(params: any): string { 511 | return qs.stringify(params, {arrayFormat: 'brackets'}); 512 | } 513 | } 514 | -------------------------------------------------------------------------------- /README.MD: -------------------------------------------------------------------------------- 1 | # Angular 2 JSON API 2 | 3 | A lightweight Angular 2 adapter for [JSON API](http://jsonapi.org/) 4 | 5 | [![Build Status](https://travis-ci.org/ghidoz/angular2-jsonapi.svg?branch=master)](https://travis-ci.org/ghidoz/angular2-jsonapi) 6 | [![Coverage Status](https://coveralls.io/repos/github/ghidoz/angular2-jsonapi/badge.svg?branch=master)](https://coveralls.io/github/ghidoz/angular2-jsonapi?branch=master) 7 | [![Angular 2 Style Guide](https://mgechev.github.io/angular2-style-guide/images/badge.svg)](https://angular.io/styleguide) 8 | [![Dependency Status](https://david-dm.org/ghidoz/angular2-jsonapi.svg)](https://david-dm.org/ghidoz/angular2-jsonapi) 9 | [![devDependency Status](https://david-dm.org/ghidoz/angular2-jsonapi/dev-status.svg)](https://david-dm.org/ghidoz/angular2-jsonapi#info=devDependencies) 10 | [![npm version](https://badge.fury.io/js/angular2-jsonapi.svg)](https://badge.fury.io/js/angular2-jsonapi) 11 | 12 | ## Table of Contents 13 | - [Introduction](#Introduction) 14 | - [Installation](#installation) 15 | - [Usage](#usage) 16 | - [Configuration](#configuration) 17 | - [Finding Records](#finding-records) 18 | - [Querying for Multiple Records](#querying-for-multiple-records) 19 | - [Retrieving a Single Record](#retrieving-a-single-record) 20 | - [Creating, Updating and Deleting](#creating-updating-and-deleting) 21 | - [Creating Records](#creating-records) 22 | - [Updating Records](#updating-records) 23 | - [Persisting Records](#persisting-records) 24 | - [Deleting Records](#deleting-records) 25 | - [Relationships](#relationships) 26 | - [Metadata](#metadata) 27 | - [Custom Headers](#custom-headers) 28 | - [Error handling](#error-handling) 29 | - [Dates](#dates) 30 | - [Development](#development) 31 | - [Additional tools](#additional-tools) 32 | - [License](#licence) 33 | 34 | ## Introduction 35 | Why this library? Because [JSON API](http://jsonapi.org/) is an awesome standard, but the responses that you get and the way to interact with endpoints are not really easy and directly consumable from Angular. 36 | 37 | Moreover, using Angular2 and Typescript, we like to interact with classes and models, not with bare JSONs. Thanks to this library, you will be able to map all your data into models and relationships like these: 38 | 39 | ```javascript 40 | [ 41 | Post{ 42 | id: 1, 43 | title: 'My post', 44 | content: 'My content', 45 | comments: [ 46 | Comment{ 47 | id: 1, 48 | // ... 49 | }, 50 | Comment{ 51 | id: 2, 52 | // ... 53 | } 54 | ] 55 | }, 56 | // ... 57 | ] 58 | ``` 59 | 60 | 61 | ## Installation 62 | 63 | To install this library, run: 64 | ```bash 65 | $ npm install angular2-jsonapi --save 66 | ``` 67 | 68 | Add the `JsonApiModule` to your app module imports: 69 | ```typescript 70 | import { JsonApiModule } from 'angular2-jsonapi'; 71 | 72 | @NgModule({ 73 | imports: [ 74 | BrowserModule, 75 | JsonApiModule 76 | ], 77 | declarations: [ 78 | AppComponent 79 | ], 80 | bootstrap: [AppComponent] 81 | }) 82 | export class AppModule { } 83 | ``` 84 | 85 | ### Angular CLI configuration (for CLI 8.1+) 86 | 87 | Beginning from Angular CLI 8.1 the `tsconfig.json` does not sets the `emitDecoratorMetadata` option (see https://blog.ninja-squad.com/2019/07/03/angular-cli-8.1/#typescript-configuration-changes). But we need it to read the metadata from the models. So make sure to update your `tsconfig.json`: 88 | 89 | ```json 90 | { 91 | "compilerOptions": { 92 | "emitDecoratorMetadata": true, 93 | } 94 | } 95 | ``` 96 | 97 | ### Notice for `es2015` compilation 98 | 99 | Beginning with Angular 8 the default compile target will be `es2015` (in `tsconfig.json`). 100 | Make sure to add this line in your `src/polyfills.ts` as we need it to read metadata from the models: 101 | 102 | ```typescript 103 | import 'core-js/proposals/reflect-metadata'; 104 | ``` 105 | 106 | **Warning**: If you have circular dependencies in your model definitions (see https://github.com/ghidoz/angular2-jsonapi/issues/236#issuecomment-519473153 for example), you need to change the compile target to `es5` as this lead to runtime errors `ReferenceError: Cannot access 'x' before initialization` (see https://github.com/angular/angular/issues/30106#issuecomment-497699838). 107 | 108 | ## Usage 109 | 110 | ### Configuration 111 | 112 | Firstly, create your `Datastore` service: 113 | - Extend the `JsonApiDatastore` class 114 | - 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) 115 | - Pass the `HttpClient` depencency to the parent constructor. 116 | 117 | ```typescript 118 | import { JsonApiDatastoreConfig, JsonApiDatastore, DatastoreConfig } from 'angular2-jsonapi'; 119 | 120 | const config: DatastoreConfig = { 121 | baseUrl: 'http://localhost:8000/v1/', 122 | models: { 123 | posts: Post, 124 | comments: Comment, 125 | users: User 126 | } 127 | } 128 | 129 | @Injectable() 130 | @JsonApiDatastoreConfig(config) 131 | export class Datastore extends JsonApiDatastore { 132 | 133 | constructor(http: HttpClient) { 134 | super(http); 135 | } 136 | 137 | } 138 | ``` 139 | 140 | Then set up your models: 141 | - Extend the `JsonApiModel` class 142 | - Decorate it with `@JsonApiModelConfig`, passing the `type` 143 | - Decorate the class properties with `@Attribute` 144 | - Decorate the relationships attributes with `@HasMany` and `@BelongsTo` 145 | - (optional) Define your [Metadata](#metadata) 146 | 147 | ```typescript 148 | import { JsonApiModelConfig, JsonApiModel, Attribute, HasMany, BelongsTo } from 'angular2-jsonapi'; 149 | 150 | @JsonApiModelConfig({ 151 | type: 'posts' 152 | }) 153 | export class Post extends JsonApiModel { 154 | 155 | @Attribute() 156 | title: string; 157 | 158 | @Attribute() 159 | content: string; 160 | 161 | @Attribute({ serializedName: 'created-at' }) 162 | createdAt: Date; 163 | 164 | @HasMany() 165 | comments: Comment[]; 166 | } 167 | 168 | @JsonApiModelConfig({ 169 | type: 'comments' 170 | }) 171 | export class Comment extends JsonApiModel { 172 | 173 | @Attribute() 174 | title: string; 175 | 176 | @Attribute() 177 | created_at: Date; 178 | 179 | @BelongsTo() 180 | post: Post; 181 | 182 | @BelongsTo() 183 | user: User; 184 | } 185 | 186 | @JsonApiModelConfig({ 187 | type: 'users' 188 | }) 189 | export class User extends JsonApiModel { 190 | 191 | @Attribute() 192 | name: string; 193 | // ... 194 | } 195 | ``` 196 | 197 | ### Finding Records 198 | 199 | #### Querying for Multiple Records 200 | 201 | Now, you can use your `Datastore` in order to query your API with the `findAll()` method: 202 | - The first argument is the type of object you want to query. 203 | - The second argument is the list of params: write them in JSON format and they will be serialized. 204 | - The returned value is a document which gives access to the metdata and the models. 205 | ```typescript 206 | // ... 207 | constructor(private datastore: Datastore) { } 208 | 209 | getPosts(){ 210 | this.datastore.findAll(Post, { 211 | page: { size: 10, number: 1 }, 212 | filter: { 213 | title: 'My Post', 214 | }, 215 | }).subscribe( 216 | (posts: JsonApiQueryData) => console.log(posts.getModels()) 217 | ); 218 | } 219 | ``` 220 | 221 | Use `peekAll()` to retrieve all of the records for a given type that are already loaded into the store, without making a network request: 222 | 223 | ```typescript 224 | let posts = this.datastore.peekAll(Post); 225 | ``` 226 | 227 | 228 | #### Retrieving a Single Record 229 | 230 | Use `findRecord()` to retrieve a record by its type and ID: 231 | 232 | ```typescript 233 | this.datastore.findRecord(Post, '1').subscribe( 234 | (post: Post) => console.log(post) 235 | ); 236 | ``` 237 | 238 | Use `peekRecord()` to retrieve a record by its type and ID, without making a network request. This will return the record only if it is already present in the store: 239 | 240 | ```typescript 241 | let post = this.datastore.peekRecord(Post, '1'); 242 | ``` 243 | 244 | ### Creating, Updating and Deleting 245 | 246 | #### Creating Records 247 | 248 | You can create records by calling the `createRecord()` method on the datastore: 249 | - The first argument is the type of object you want to create. 250 | - The second is a JSON with the object attributes. 251 | 252 | ```typescript 253 | this.datastore.createRecord(Post, { 254 | title: 'My post', 255 | content: 'My content' 256 | }); 257 | ``` 258 | 259 | #### Updating Records 260 | 261 | Making changes to records is as simple as setting the attribute you want to change: 262 | 263 | ```typescript 264 | this.datastore.findRecord(Post, '1').subscribe( 265 | (post: Post) => { 266 | post.title = 'New title'; 267 | } 268 | ); 269 | ``` 270 | 271 | #### Persisting Records 272 | 273 | Records are persisted on a per-instance basis. Call `save()` on any instance of `JsonApiModel` and it will make a network request. 274 | 275 | The library takes care of tracking the state of each record for you, so that newly created records are treated differently from existing records when saving. 276 | 277 | Newly created records will be `POST`ed: 278 | 279 | ```typescript 280 | let post = this.datastore.createRecord(Post, { 281 | title: 'My post', 282 | content: 'My content' 283 | }); 284 | 285 | post.save().subscribe(); // => POST to '/posts' 286 | ``` 287 | 288 | Records that already exist on the backend are updated using the HTTP `PATCH` verb: 289 | 290 | ```typescript 291 | this.datastore.findRecord(Post, '1').subscribe( 292 | (post: Post) => { 293 | post.title = 'New title'; 294 | post.save().subscribe(); // => PATCH to '/posts/1' 295 | } 296 | ); 297 | ``` 298 | 299 | The `save()` method will return an `Observer` that you need to subscribe: 300 | 301 | ```typescript 302 | post.save().subscribe( 303 | (post: Post) => console.log(post) 304 | ); 305 | ``` 306 | 307 | **Note**: always remember to call the `subscribe()` method, even if you are not interested in doing something with the response. Since the `http` method return a [cold Observable](https://github.com/Reactive-Extensions/RxJS/blob/master/doc/gettingstarted/creating.md#cold-vs-hot-observables), the request won't go out until something subscribes to the observable. 308 | 309 | You can tell if a record has outstanding changes that have not yet been saved by checking its `hasDirtyAttributes` property. 310 | 311 | At this point, you can either persist your changes via `save()` or you can roll back your changes. Calling `rollbackAttributes()` for a saved record reverts all the dirty attributes to their original value. 312 | 313 | ```typescript 314 | this.datastore.findRecord(Post, '1').subscribe( 315 | (post: Post) => { 316 | console.log(post.title); // => 'Old title' 317 | console.log(post.hasDirtyAttributes); // => false 318 | post.title = 'New title'; 319 | console.log(post.hasDirtyAttributes); // => true 320 | post.rollbackAttributes(); 321 | console.log(post.hasDirtyAttributes); // => false 322 | console.log(post.title); // => 'Old title' 323 | } 324 | ); 325 | ``` 326 | 327 | #### Deleting Records 328 | 329 | For deleting a record, just call the datastore's method `deleteRecord()`, passing the type and the id of the record: 330 | 331 | ```typescript 332 | this.datastore.deleteRecord(Post, '1').subscribe(() => { 333 | // deleted! 334 | }); 335 | ``` 336 | 337 | ### Relationships 338 | 339 | #### Querying records 340 | 341 | In order to query an object including its relationships, you can pass in its options the attribute name you want to load with the relationships: 342 | 343 | ```typescript 344 | this.datastore.findAll(Post, { 345 | page: { size: 10, number: 1}, 346 | include: 'comments' 347 | }).subscribe( 348 | (document) => { 349 | console.log(document.getMeta()); // metadata 350 | console.log(document.getModels()); // models 351 | } 352 | ); 353 | ``` 354 | 355 | The same, if you want to include relationships when finding a record: 356 | 357 | ```typescript 358 | this.datastore.findRecord(Post, '1', { 359 | include: 'comments,comments.user' 360 | }).subscribe( 361 | (post: Post) => console.log(post) 362 | ); 363 | ``` 364 | 365 | The library will try to resolve relationships on infinite levels connecting nested objects by reference. So that you can have a `Post`, with a list of `Comment`s, that have a `User` that has `Post`s, that have `Comment`s... etc. 366 | 367 | **Note**: If you `include` multiple relationships, **do not** use whitespaces in the `include` string (e.g. `comments, comments.user`) as those will be encoded to `%20` and this results in a broken URL. 368 | 369 | #### Creating Records 370 | 371 | If the object you want to create has a **one-to-many** relationship, you can do this: 372 | 373 | ```typescript 374 | let post = this.datastore.peekRecord(Post, '1'); 375 | let comment = this.datastore.createRecord(Comment, { 376 | title: 'My comment', 377 | post: post 378 | }); 379 | comment.save().subscribe(); 380 | ``` 381 | 382 | The library will do its best to discover which relationships map to one another. In the code above, for example, setting the `comment` relationship with the `post` will update the `post.comments` array, automatically adding the `comment` object! 383 | 384 | If you want to include a relationship when creating a record to have it parsed in the response, you can pass the `params` object to the `save()` method: 385 | 386 | ```typescript 387 | comment.save({ 388 | include: 'user' 389 | }).subscribe( 390 | (comment: Comment) => console.log(comment) 391 | ); 392 | ``` 393 | 394 | #### Updating Records 395 | 396 | You can also update an object that comes from a relationship: 397 | 398 | ```typescript 399 | this.datastore.findRecord(Post, '1', { 400 | include: 'comments' 401 | }).subscribe( 402 | (post: Post) => { 403 | let comment: Comment = post.comments[0]; 404 | comment.title = 'Cool'; 405 | comment.save().subscribe((comment: Comment) => { 406 | console.log(comment); 407 | }); 408 | } 409 | ); 410 | ``` 411 | 412 | ### Metadata 413 | Metadata such as links or data for pagination purposes can also be included in the result. 414 | 415 | For each model a specific MetadataModel can be defined. To do this, the class name needs to be added in the ModelConfig. 416 | 417 | If no MetadataModel is explicitly defined, the default one will be used, which contains an array of links and `meta` property. 418 | ``` 419 | @JsonApiModelConfig({ 420 | type: 'deals', 421 | meta: JsonApiMetaModel 422 | }) 423 | export class Deal extends JsonApiModel 424 | ``` 425 | 426 | An instance of a class provided in `meta` property will get the whole response in a constructor. 427 | 428 | ### Datastore config 429 | 430 | 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. 431 | 432 | ```typescript 433 | @JsonApiDatastoreConfig(config: DatastoreConfig) 434 | export class Datastore extends JsonApiDatastore { 435 | private customConfig: DatastoreConfig = { 436 | baseUrl: 'http://something.com' 437 | } 438 | 439 | constructor() { 440 | this.config = this.customConfig; 441 | } 442 | } 443 | ``` 444 | 445 | `config`: 446 | 447 | * `models` - all the models which will be stored in the datastore 448 | * `baseUrl` - base API URL 449 | * `apiVersion` - optional, a string which will be appended to the baseUrl 450 | * `overrides` - used for overriding internal methods to achive custom functionalities 451 | 452 | ##### Overrides 453 | 454 | * `getDirtyAttributes` - determines which model attributes are dirty 455 | * `toQueryString` - transforms query parameters to a query string 456 | 457 | 458 | ### Model config 459 | 460 | ```typescript 461 | @JsonApiModelConfig(options: ModelOptions) 462 | export class Post extends JsonApiModel { } 463 | ``` 464 | 465 | `options`: 466 | 467 | * `type` 468 | * `baseUrl` - if not specified, the global `baseUrl` will be used 469 | * `apiVersion` - if not specified, the global `apiVersion` will be used 470 | * `modelEndpointUrl` - if not specified, `type` will be used instead 471 | * `meta` - optional, metadata model 472 | 473 | ### Decorators 474 | 475 | #### Model decorators 476 | 477 | * `Attribute(options: AttributeDecoratorOptions)` 478 | 479 | * `AttributeDecoratorOptions`: 480 | 481 | * `converter`, optional, must implement `PropertyConverter` interface 482 | * `serializedName`, optional 483 | 484 | ### Custom Headers 485 | 486 | By default, the library adds these headers, according to the [JSON API MIME Types](http://jsonapi.org/#mime-types): 487 | 488 | ``` 489 | Accept: application/vnd.api+json 490 | Content-Type: application/vnd.api+json 491 | ``` 492 | 493 | You can also add your custom headers to be appended to each http call: 494 | 495 | ```typescript 496 | this.datastore.headers = new HttpHeaders({'Authorization': 'Bearer ' + accessToken}); 497 | ``` 498 | 499 | Or you can pass the headers as last argument of any datastore call method: 500 | 501 | ```typescript 502 | this.datastore.findAll(Post, { 503 | include: 'comments' 504 | }, new HttpHeaders({'Authorization': 'Bearer ' + accessToken})); 505 | ``` 506 | 507 | and in the `save()` method: 508 | 509 | ```typescript 510 | post.save({}, new HttpHeaders({'Authorization': 'Bearer ' + accessToken})).subscribe(); 511 | ``` 512 | 513 | ### Custom request options 514 | 515 | You can add your custom request options to be appended to each http call: 516 | 517 | ```typescript 518 | this.datastore.requestOptions = { 519 | withCredentials: false, 520 | myOption: 123 521 | } 522 | ``` 523 | 524 | ### Error handling 525 | 526 | Error handling is done in the `subscribe` method of the returned Observables. 527 | If your server returns valid [JSON API Error Objects](http://jsonapi.org/format/#error-objects) you can access them in your onError method: 528 | 529 | ```typescript 530 | import {ErrorResponse} from "angular2-jsonapi"; 531 | 532 | ... 533 | 534 | this.datastore.findAll(Post).subscribe( 535 | (posts: Post[]) => console.log(posts), 536 | (errorResponse) => { 537 | if (errorResponse instanceof ErrorResponse) { 538 | // do something with errorResponse 539 | console.log(errorResponse.errors); 540 | } 541 | } 542 | ); 543 | ``` 544 | 545 | It's also possible to handle errors for all requests by overriding `handleError(error: any): Observable` in the datastore. 546 | 547 | ### Dates 548 | 549 | The library will automatically transform date values into `Date` objects and it will serialize them when sending to the server. In order to do that, remember to set the type of the corresponding attribute as `Date`: 550 | 551 | ```typescript 552 | @JsonApiModelConfig({ 553 | type: 'posts' 554 | }) 555 | export class Post extends JsonApiModel { 556 | 557 | // ... 558 | 559 | @Attribute() 560 | created_at: Date; 561 | 562 | } 563 | ``` 564 | 565 | Moreover, it should be noted that the following assumptions have been made: 566 | - Dates are expected to be received in one of the ISO 8601 formats, as per the [JSON API spec recommendation](http://jsonapi.org/recommendations/#date-and-time-fields); 567 | - Dates are always sent in full ISO 8601 format, with local timezone and without milliseconds (example: `2001-02-03T14:05:06+07:00`). 568 | 569 | 570 | ## Development 571 | 572 | To generate all `*.js`, `*.js.map` and `*.d.ts` files: 573 | 574 | ```bash 575 | $ npm run build 576 | ``` 577 | 578 | To lint all `*.ts` files: 579 | 580 | ```bash 581 | $ npm run lint 582 | ``` 583 | 584 | ## Additional tools 585 | * Gem for generating the model definitions based on active model serializers: https://github.com/oncore-education/jsonapi_models 586 | 587 | ## Thanks 588 | 589 | This library is inspired by the draft of [this never implemented library](https://github.com/beauby/angular2-jsonapi). 590 | 591 | ## License 592 | 593 | MIT © [Daniele Ghidoli](http://danieleghidoli.it) 594 | -------------------------------------------------------------------------------- /projects/angular2-jsonapi/src/services/json-api-datastore.service.spec.ts: -------------------------------------------------------------------------------- 1 | import { TestBed } from '@angular/core/testing'; 2 | import { parseISO } from 'date-fns'; 3 | import { Author } from '../../test/models/author.model'; 4 | import { Chapter } from '../../test/models/chapter.model'; 5 | import { AUTHOR_API_VERSION, AUTHOR_MODEL_ENDPOINT_URL, CustomAuthor } from '../../test/models/custom-author.model'; 6 | import { AUTHOR_BIRTH, AUTHOR_ID, AUTHOR_NAME, BOOK_TITLE, getAuthorData } from '../../test/fixtures/author.fixture'; 7 | import { HttpClientTestingModule, HttpTestingController } from '@angular/common/http/testing'; 8 | import { API_VERSION, BASE_URL, Datastore } from '../../test/datastore.service'; 9 | import { ErrorResponse } from '../models/error-response.model'; 10 | import { getSampleBook } from '../../test/fixtures/book.fixture'; 11 | import { Book } from '../../test/models/book.model'; 12 | import { CrimeBook } from '../../test/models/crime-book.model'; 13 | import { API_VERSION_FROM_CONFIG, BASE_URL_FROM_CONFIG, DatastoreWithConfig } from '../../test/datastore-with-config.service'; 14 | import { HttpHeaders } from '@angular/common/http'; 15 | import { Thing } from '../../test/models/thing'; 16 | import { getSampleThing } from '../../test/fixtures/thing.fixture'; 17 | import { ModelConfig } from '../interfaces/model-config.interface'; 18 | import { JsonApiQueryData } from '../models/json-api-query-data'; 19 | 20 | let datastore: Datastore; 21 | let datastoreWithConfig: DatastoreWithConfig; 22 | let httpMock: HttpTestingController; 23 | 24 | // workaround, see https://github.com/angular/angular/pull/8961 25 | class MockError extends Response implements Error { 26 | name: any; 27 | message: any; 28 | } 29 | 30 | describe('JsonApiDatastore', () => { 31 | beforeEach(() => { 32 | TestBed.configureTestingModule({ 33 | imports: [ 34 | HttpClientTestingModule, 35 | ], 36 | providers: [ 37 | Datastore, 38 | DatastoreWithConfig, 39 | ] 40 | }); 41 | 42 | datastore = TestBed.get(Datastore); 43 | datastoreWithConfig = TestBed.get(DatastoreWithConfig); 44 | httpMock = TestBed.get(HttpTestingController); 45 | }); 46 | 47 | afterEach(() => { 48 | httpMock.verify(); 49 | }); 50 | 51 | describe('query', () => { 52 | it('should build basic url from the data from datastore decorator', () => { 53 | const authorModelConfig: ModelConfig = Reflect.getMetadata('JsonApiModelConfig', Author); 54 | const expectedUrl = `${BASE_URL}/${API_VERSION}/${authorModelConfig.type}`; 55 | 56 | datastore.findAll(Author).subscribe(); 57 | 58 | const queryRequest = httpMock.expectOne({method: 'GET', url: expectedUrl}); 59 | queryRequest.flush({data: []}); 60 | }); 61 | 62 | it('should build basic url and apiVersion from the config variable if exists', () => { 63 | const authorModelConfig: ModelConfig = Reflect.getMetadata('JsonApiModelConfig', Author); 64 | const expectedUrl = `${BASE_URL_FROM_CONFIG}/${API_VERSION_FROM_CONFIG}/${authorModelConfig.type}`; 65 | 66 | datastoreWithConfig.findAll(Author).subscribe(); 67 | 68 | const queryRequest = httpMock.expectOne({method: 'GET', url: expectedUrl}); 69 | queryRequest.flush({data: []}); 70 | }); 71 | 72 | // tslint:disable-next-line:max-line-length 73 | it('should use apiVersion and modelEnpointUrl from the model instead of datastore if model has apiVersion and/or modelEndpointUrl specified', () => { 74 | const authorModelConfig: ModelConfig = Reflect.getMetadata('JsonApiModelConfig', CustomAuthor); 75 | const expectedUrl = `${BASE_URL_FROM_CONFIG}/${AUTHOR_API_VERSION}/${AUTHOR_MODEL_ENDPOINT_URL}`; 76 | 77 | datastoreWithConfig.findAll(CustomAuthor).subscribe(); 78 | 79 | const queryRequest = httpMock.expectOne({method: 'GET', url: expectedUrl}); 80 | queryRequest.flush({data: []}); 81 | }); 82 | 83 | it('should set JSON API headers', () => { 84 | const expectedUrl = `${BASE_URL}/${API_VERSION}/authors`; 85 | 86 | datastore.findAll(Author).subscribe(); 87 | 88 | const queryRequest = httpMock.expectOne({method: 'GET', url: expectedUrl}); 89 | expect(queryRequest.request.headers.get('Content-Type')).toEqual('application/vnd.api+json'); 90 | expect(queryRequest.request.headers.get('Accept')).toEqual('application/vnd.api+json'); 91 | queryRequest.flush({data: []}); 92 | }); 93 | 94 | it('should build url with nested params', () => { 95 | const queryData = { 96 | page: { 97 | size: 10, number: 1 98 | }, 99 | include: 'comments', 100 | filter: { 101 | title: { 102 | keyword: 'Tolkien' 103 | } 104 | } 105 | }; 106 | 107 | // tslint:disable-next-line:prefer-template 108 | const expectedUrl = `${BASE_URL}/${API_VERSION}/` + 'authors?' + 109 | encodeURIComponent('page[size]') + '=10&' + 110 | encodeURIComponent('page[number]') + '=1&' + 111 | encodeURIComponent('include') + '=comments&' + 112 | encodeURIComponent('filter[title][keyword]') + '=Tolkien'; 113 | 114 | datastore.findAll(Author, queryData).subscribe(); 115 | 116 | httpMock.expectNone(`${BASE_URL}/${API_VERSION}`); 117 | const queryRequest = httpMock.expectOne({method: 'GET', url: expectedUrl}); 118 | queryRequest.flush({data: []}); 119 | }); 120 | 121 | it('should have custom headers', () => { 122 | const expectedUrl = `${BASE_URL}/${API_VERSION}/authors`; 123 | 124 | datastore.findAll(Author, null, new HttpHeaders({Authorization: 'Bearer'})).subscribe(); 125 | 126 | const queryRequest = httpMock.expectOne({method: 'GET', url: expectedUrl}); 127 | expect(queryRequest.request.headers.get('Authorization')).toEqual('Bearer'); 128 | queryRequest.flush({data: []}); 129 | }); 130 | 131 | it('should override base headers', () => { 132 | const expectedUrl = `${BASE_URL}/${API_VERSION}/authors`; 133 | 134 | datastore.headers = new HttpHeaders({Authorization: 'Bearer'}); 135 | datastore.findAll(Author, null, new HttpHeaders({Authorization: 'Basic'})).subscribe(); 136 | 137 | const queryRequest = httpMock.expectOne({method: 'GET', url: expectedUrl}); 138 | expect(queryRequest.request.headers.get('Authorization')).toEqual('Basic'); 139 | queryRequest.flush({data: []}); 140 | }); 141 | 142 | it('should get authors', () => { 143 | const expectedUrl = `${BASE_URL}/${API_VERSION}/authors`; 144 | 145 | datastore.findAll(Author).subscribe((data: JsonApiQueryData) => { 146 | const authors = data.getModels(); 147 | expect(authors).toBeDefined(); 148 | expect(authors.length).toEqual(1); 149 | expect(authors[0].id).toEqual(AUTHOR_ID); 150 | expect(authors[0].name).toEqual(AUTHOR_NAME); 151 | expect(authors[1]).toBeUndefined(); 152 | }); 153 | 154 | const queryRequest = httpMock.expectOne(expectedUrl); 155 | queryRequest.flush({data: [getAuthorData()]}); 156 | }); 157 | 158 | it('should get authors with custom metadata', () => { 159 | const expectedUrl = `${BASE_URL}/${API_VERSION}/authors`; 160 | 161 | datastore.findAll(Author).subscribe((document) => { 162 | expect(document).toBeDefined(); 163 | expect(document.getModels().length).toEqual(1); 164 | expect(document.getMeta().meta.page.number).toEqual(1); 165 | }); 166 | 167 | const findAllRequest = httpMock.expectOne(expectedUrl); 168 | findAllRequest.flush({ 169 | data: [getAuthorData()], 170 | meta: { 171 | page: { 172 | number: 1, 173 | size: 1, 174 | total: 1, 175 | last: 1 176 | } 177 | } 178 | }); 179 | }); 180 | 181 | it('should get data with default metadata', () => { 182 | const expectedUrl = `${BASE_URL}/${API_VERSION}/books`; 183 | 184 | datastore.findAll(Book).subscribe((document) => { 185 | expect(document).toBeDefined(); 186 | expect(document.getModels().length).toEqual(1); 187 | expect(document.getMeta().links[0]).toEqual('http://www.example.org'); 188 | }); 189 | 190 | const findAllRequest = httpMock.expectOne(expectedUrl); 191 | findAllRequest.flush({ 192 | data: [getSampleBook(1, '1')], 193 | links: ['http://www.example.org'] 194 | }); 195 | }); 196 | 197 | it('should get cyclic HasMany relationships', () => { 198 | const expectedQueryString = 'include=categories.members'; 199 | const expectedUrl = encodeURI(`${BASE_URL}/${API_VERSION}/thing?${expectedQueryString}`); 200 | 201 | datastore.findAll(Thing, {include: 'categories.members'}).subscribe((document) => { 202 | expect(document).toBeDefined(); 203 | expect(document.getModels()[0].categories[0].members.length).toBe(1); 204 | expect(document.getModels()[0].categories[0].members[0]).toBe(document.getModels()[0]); 205 | }); 206 | 207 | const queryRequest = httpMock.expectOne(expectedUrl); 208 | queryRequest.flush(getSampleThing()); 209 | }); 210 | 211 | it('should fire error', () => { 212 | const expectedUrl = `${BASE_URL}/${API_VERSION}/authors`; 213 | const dummyResponse = { 214 | errors: [ 215 | { 216 | code: '100', 217 | title: 'Example error', 218 | detail: 'detailed error Message' 219 | } 220 | ] 221 | }; 222 | 223 | datastore.findAll(Author).subscribe( 224 | (authors) => fail('onNext has been called'), 225 | (response) => { 226 | expect(response).toEqual(jasmine.any(ErrorResponse)); 227 | expect(response.errors.length).toEqual(1); 228 | expect(response.errors[0].code).toEqual(dummyResponse.errors[0].code); 229 | expect(response.errors[0].title).toEqual(dummyResponse.errors[0].title); 230 | expect(response.errors[0].detail).toEqual(dummyResponse.errors[0].detail); 231 | }, 232 | () => fail('onCompleted has been called') 233 | ); 234 | 235 | const queryRequest = httpMock.expectOne(expectedUrl); 236 | queryRequest.flush(dummyResponse, {status: 500, statusText: 'Internal Server Error'}); 237 | }); 238 | 239 | it('should generate correct query string for array params with findAll', () => { 240 | const expectedQueryString = 'arrayParam[]=4&arrayParam[]=5&arrayParam[]=6'; 241 | const expectedUrl = encodeURI(`${BASE_URL}/${API_VERSION}/books?${expectedQueryString}`); 242 | 243 | datastore.findAll(Book, {arrayParam: [4, 5, 6]}).subscribe(); 244 | 245 | const findAllRequest = httpMock.expectOne(expectedUrl); 246 | findAllRequest.flush({data: []}); 247 | }); 248 | 249 | it('should generate correct query string for array params with query', () => { 250 | const expectedQueryString = 'arrayParam[]=4&arrayParam[]=5&arrayParam[]=6'; 251 | const expectedUrl = encodeURI(`${BASE_URL}/${API_VERSION}/books?${expectedQueryString}`); 252 | 253 | datastore.findAll(Book, {arrayParam: [4, 5, 6]}).subscribe(); 254 | 255 | const queryRequest = httpMock.expectOne(expectedUrl); 256 | queryRequest.flush({data: []}); 257 | }); 258 | 259 | it('should generate correct query string for nested params with findAll', () => { 260 | const expectedQueryString = 'filter[text]=test123'; 261 | const expectedUrl = encodeURI(`${BASE_URL}/${API_VERSION}/books?${expectedQueryString}`); 262 | 263 | datastore.findAll(Book, {filter: {text: 'test123'}}).subscribe(); 264 | 265 | const findAllRequest = httpMock.expectOne(expectedUrl); 266 | findAllRequest.flush({data: []}); 267 | }); 268 | 269 | it('should generate correct query string for nested array params with findAll', () => { 270 | const expectedQueryString = 'filter[text][]=1&filter[text][]=2'; 271 | const expectedUrl = encodeURI(`${BASE_URL}/${API_VERSION}/books?${expectedQueryString}`); 272 | 273 | datastore.findAll(Book, {filter: {text: [1, 2]}}).subscribe(); 274 | 275 | const findAllRequest = httpMock.expectOne(expectedUrl); 276 | findAllRequest.flush({data: []}); 277 | }); 278 | }); 279 | 280 | describe('findRecord', () => { 281 | it('should get author', () => { 282 | const expectedUrl = `${BASE_URL}/${API_VERSION}/authors/${AUTHOR_ID}`; 283 | 284 | datastore.findRecord(Author, AUTHOR_ID).subscribe((author) => { 285 | expect(author).toBeDefined(); 286 | expect(author.id).toBe(AUTHOR_ID); 287 | expect(author.date_of_birth).toEqual(parseISO(AUTHOR_BIRTH)); 288 | }); 289 | 290 | const findRecordRequest = httpMock.expectOne(expectedUrl); 291 | findRecordRequest.flush({data: getAuthorData()}); 292 | }); 293 | 294 | it('should generate correct query string for array params with findRecord', () => { 295 | const expectedQueryString = 'arrayParam[]=4&arrayParam[]=5&arrayParam[]=6'; 296 | const expectedUrl = encodeURI(`${BASE_URL}/${API_VERSION}/books/1?${expectedQueryString}`); 297 | 298 | datastore.findRecord(Book, '1', {arrayParam: [4, 5, 6]}).subscribe(); 299 | 300 | const findRecordRequest = httpMock.expectOne(expectedUrl); 301 | findRecordRequest.flush({data: getAuthorData()}); 302 | }); 303 | }); 304 | 305 | describe('saveRecord', () => { 306 | it('should create new author', () => { 307 | const expectedUrl = `${BASE_URL}/${API_VERSION}/authors`; 308 | const author = datastore.createRecord(Author, { 309 | name: AUTHOR_NAME, 310 | date_of_birth: AUTHOR_BIRTH 311 | }); 312 | 313 | author.save().subscribe((val) => { 314 | expect(val.id).toBeDefined(); 315 | expect(val.id).toEqual(AUTHOR_ID); 316 | }); 317 | 318 | httpMock.expectNone(`${BASE_URL}/${API_VERSION}`); 319 | const saveRequest = httpMock.expectOne({method: 'POST', url: expectedUrl}); 320 | const obj = saveRequest.request.body.data; 321 | expect(obj.attributes).toBeDefined(); 322 | expect(obj.attributes.name).toEqual(AUTHOR_NAME); 323 | expect(obj.attributes.dob).toEqual(parseISO(AUTHOR_BIRTH).toISOString()); 324 | expect(obj.id).toBeUndefined(); 325 | expect(obj.type).toBe('authors'); 326 | expect(obj.relationships).toBeUndefined(); 327 | 328 | saveRequest.flush({ 329 | data: { 330 | id: AUTHOR_ID, 331 | type: 'authors', 332 | attributes: { 333 | name: AUTHOR_NAME, 334 | } 335 | } 336 | }, {status: 201, statusText: 'Created'}); 337 | }); 338 | 339 | it('should throw error on new author with 201 response but no body', () => { 340 | const expectedUrl = `${BASE_URL}/${API_VERSION}/authors`; 341 | const author = datastore.createRecord(Author, { 342 | name: AUTHOR_NAME 343 | }); 344 | 345 | author.save().subscribe( 346 | () => fail('should throw error'), 347 | (error) => expect(error).toEqual(new Error('no body in response')) 348 | ); 349 | 350 | const saveRequest = httpMock.expectOne({method: 'POST', url: expectedUrl}); 351 | saveRequest.flush(null, {status: 201, statusText: 'Created'}); 352 | }); 353 | 354 | it('should throw error on new author with 201 response but no data', () => { 355 | const expectedUrl = `${BASE_URL}/${API_VERSION}/authors`; 356 | const author = datastore.createRecord(Author, { 357 | name: AUTHOR_NAME 358 | }); 359 | 360 | author.save().subscribe( 361 | () => fail('should throw error'), 362 | (error) => expect(error).toEqual(new Error('expected data in response')) 363 | ); 364 | 365 | const saveRequest = httpMock.expectOne({method: 'POST', url: expectedUrl}); 366 | saveRequest.flush({}, {status: 201, statusText: 'Created'}); 367 | }); 368 | 369 | it('should create new author with 204 response', () => { 370 | const expectedUrl = `${BASE_URL}/${API_VERSION}/authors`; 371 | const author = datastore.createRecord(Author, { 372 | name: AUTHOR_NAME 373 | }); 374 | 375 | author.save().subscribe((val) => { 376 | expect(val).toBeDefined(); 377 | }); 378 | 379 | const saveRequest = httpMock.expectOne({method: 'POST', url: expectedUrl}); 380 | saveRequest.flush(null, {status: 204, statusText: 'No Content'}); 381 | }); 382 | 383 | it('should create new author with existing ToMany-relationship', () => { 384 | const expectedUrl = `${BASE_URL}/${API_VERSION}/authors`; 385 | const author = datastore.createRecord(Author, { 386 | name: AUTHOR_NAME 387 | }); 388 | author.books = [new Book(datastore, { 389 | id: '10', 390 | title: BOOK_TITLE 391 | })]; 392 | 393 | author.save().subscribe(); 394 | 395 | const saveRequest = httpMock.expectOne(expectedUrl); 396 | const obj = saveRequest.request.body.data; 397 | expect(obj.attributes.name).toEqual(AUTHOR_NAME); 398 | expect(obj.id).toBeUndefined(); 399 | expect(obj.type).toBe('authors'); 400 | expect(obj.relationships).toBeDefined(); 401 | expect(obj.relationships.books.data.length).toBe(1); 402 | expect(obj.relationships.books.data[0].id).toBe('10'); 403 | 404 | saveRequest.flush(null, {status: 204, statusText: 'No Content'}); 405 | }); 406 | 407 | it('should create new author with new ToMany-relationship', () => { 408 | const expectedUrl = `${BASE_URL}/${API_VERSION}/authors`; 409 | const author = datastore.createRecord(Author, { 410 | name: AUTHOR_NAME 411 | }); 412 | author.books = [datastore.createRecord(Book, { 413 | title: BOOK_TITLE 414 | })]; 415 | 416 | author.save().subscribe(); 417 | 418 | const saveRequest = httpMock.expectOne(expectedUrl); 419 | const obj = saveRequest.request.body.data; 420 | expect(obj.attributes.name).toEqual(AUTHOR_NAME); 421 | expect(obj.id).toBeUndefined(); 422 | expect(obj.type).toBe('authors'); 423 | expect(obj.relationships).toBeDefined(); 424 | expect(obj.relationships.books.data.length).toBe(0); 425 | 426 | saveRequest.flush(null, {status: 204, statusText: 'No Content'}); 427 | }); 428 | 429 | it('should create new author with new ToMany-relationship 2', () => { 430 | const expectedUrl = `${BASE_URL}/${API_VERSION}/authors`; 431 | const author = datastore.createRecord(Author, { 432 | name: AUTHOR_NAME 433 | }); 434 | author.books = [datastore.createRecord(Book, { 435 | id: 123, 436 | title: BOOK_TITLE 437 | }), datastore.createRecord(Book, { 438 | title: `New book - ${BOOK_TITLE}` 439 | })]; 440 | 441 | author.save().subscribe(); 442 | 443 | const saveRequest = httpMock.expectOne(expectedUrl); 444 | const obj = saveRequest.request.body.data; 445 | expect(obj.id).toBeUndefined(); 446 | expect(obj.relationships).toBeDefined(); 447 | expect(obj.relationships.books.data.length).toBe(1); 448 | 449 | saveRequest.flush(null, {status: 204, statusText: 'No Content'}); 450 | }); 451 | 452 | it('should create new book with existing BelongsTo-relationship', () => { 453 | const expectedUrl = `${BASE_URL}/${API_VERSION}/books`; 454 | const book = datastore.createRecord(Book, { 455 | title: BOOK_TITLE 456 | }); 457 | book.author = new Author(datastore, { 458 | id: AUTHOR_ID 459 | }); 460 | 461 | book.save().subscribe(); 462 | 463 | const saveRequest = httpMock.expectOne(expectedUrl); 464 | const obj = saveRequest.request.body.data; 465 | expect(obj.attributes.title).toEqual(BOOK_TITLE); 466 | expect(obj.id).toBeUndefined(); 467 | expect(obj.type).toBe('books'); 468 | expect(obj.relationships).toBeDefined(); 469 | expect(obj.relationships.author.data.id).toBe(AUTHOR_ID); 470 | 471 | saveRequest.flush(null, {status: 204, statusText: 'No Content'}); 472 | }); 473 | 474 | it('should use correct key for BelongsTo-relationship', () => { 475 | const expectedUrl = `${BASE_URL}/${API_VERSION}/books`; 476 | const CHAPTER_ID = '1'; 477 | const book = datastore.createRecord(Book, { 478 | title: BOOK_TITLE 479 | }); 480 | 481 | book.firstChapter = new Chapter(datastore, { 482 | id: CHAPTER_ID 483 | }); 484 | 485 | book.save().subscribe(); 486 | 487 | const saveRequest = httpMock.expectOne(expectedUrl); 488 | const obj = saveRequest.request.body.data; 489 | expect(obj.relationships).toBeDefined(); 490 | expect(obj.relationships.firstChapter).toBeUndefined(); 491 | expect(obj.relationships['first-chapter']).toBeDefined(); 492 | expect(obj.relationships['first-chapter'].data.id).toBe(CHAPTER_ID); 493 | 494 | saveRequest.flush({}); 495 | }); 496 | 497 | it('should use correct key for ToMany-relationship', () => { 498 | const expectedUrl = `${BASE_URL}/${API_VERSION}/books`; 499 | const CHAPTER_ID = '1'; 500 | const book = datastore.createRecord(Book, { 501 | title: BOOK_TITLE 502 | }); 503 | 504 | book.importantChapters = [new Chapter(datastore, { 505 | id: CHAPTER_ID 506 | })]; 507 | 508 | book.save().subscribe(); 509 | 510 | const saveRequest = httpMock.expectOne(expectedUrl); 511 | const obj = saveRequest.request.body.data; 512 | expect(obj.relationships).toBeDefined(); 513 | expect(obj.relationships.importantChapters).toBeUndefined(); 514 | expect(obj.relationships['important-chapters']).toBeDefined(); 515 | expect(obj.relationships['important-chapters'].data.length).toBe(1); 516 | 517 | saveRequest.flush({}); 518 | }); 519 | }); 520 | 521 | describe('updateRecord', () => { 522 | it('should update author with 200 response (no data)', () => { 523 | const expectedUrl = `${BASE_URL}/${API_VERSION}/authors/${AUTHOR_ID}`; 524 | const author = new Author(datastore, { 525 | id: AUTHOR_ID, 526 | attributes: { 527 | date_of_birth: parseISO(AUTHOR_BIRTH), 528 | name: AUTHOR_NAME 529 | } 530 | }); 531 | author.name = 'Rowling'; 532 | author.date_of_birth = parseISO('1965-07-31'); 533 | 534 | author.save().subscribe((val) => { 535 | expect(val.name).toEqual(author.name); 536 | }); 537 | 538 | httpMock.expectNone(`${BASE_URL}/${API_VERSION}/authors`); 539 | const saveRequest = httpMock.expectOne({method: 'PATCH', url: expectedUrl}); 540 | const obj = saveRequest.request.body.data; 541 | expect(obj.attributes.name).toEqual('Rowling'); 542 | expect(obj.attributes.dob).toEqual(parseISO('1965-07-31').toISOString()); 543 | expect(obj.id).toBe(AUTHOR_ID); 544 | expect(obj.type).toBe('authors'); 545 | expect(obj.relationships).toBeUndefined(); 546 | 547 | saveRequest.flush({}); 548 | }); 549 | 550 | it('should not update invalid mixed HasMany relationship of author', () => { 551 | const expectedUrl = `${BASE_URL}/${API_VERSION}/authors/${AUTHOR_ID}`; 552 | const author = new Author(datastore, { 553 | id: AUTHOR_ID, 554 | attributes: { 555 | date_of_birth: parseISO(AUTHOR_BIRTH), 556 | name: AUTHOR_NAME 557 | } 558 | }); 559 | const crimeBook = datastore.createRecord(CrimeBook, { 560 | id: 124, 561 | title: `Crime book - ${BOOK_TITLE}`, 562 | }); 563 | const originalModelEndpointUrl = crimeBook.modelConfig.modelEndpointUrl; 564 | crimeBook.modelConfig.modelEndpointUrl = 'crimeBooks'; 565 | 566 | author.books = [datastore.createRecord(Book, { 567 | id: 123, 568 | title: BOOK_TITLE, 569 | }), crimeBook]; 570 | 571 | author.save().subscribe(); 572 | 573 | const saveRequest = httpMock.expectOne({method: 'PATCH', url: expectedUrl}); 574 | const obj = saveRequest.request.body.data; 575 | expect(obj.id).toBe(AUTHOR_ID); 576 | expect(obj.type).toBe('authors'); 577 | expect(obj.relationships).toBeUndefined(); 578 | 579 | saveRequest.flush({}); 580 | crimeBook.modelConfig.modelEndpointUrl = originalModelEndpointUrl; 581 | }); 582 | 583 | it('should update valid mixed HasMany relationship of author', () => { 584 | const expectedUrl = `${BASE_URL}/${API_VERSION}/authors/${AUTHOR_ID}`; 585 | const author = new Author(datastore, { 586 | id: AUTHOR_ID, 587 | attributes: { 588 | date_of_birth: parseISO(AUTHOR_BIRTH), 589 | name: AUTHOR_NAME, 590 | firstNames: ['John', 'Ronald', 'Reuel'] 591 | } 592 | }); 593 | 594 | author.books = [datastore.createRecord(Book, { 595 | id: 123, 596 | title: BOOK_TITLE, 597 | }), datastore.createRecord(CrimeBook, { 598 | id: 125, 599 | title: `Crime book - ${BOOK_TITLE}`, 600 | })]; 601 | 602 | author.save().subscribe(); 603 | 604 | const saveRequest = httpMock.expectOne({method: 'PATCH', url: expectedUrl}); 605 | const obj = saveRequest.request.body.data; 606 | expect(obj.id).toBe(AUTHOR_ID); 607 | expect(obj.type).toBe('authors'); 608 | expect(obj.relationships).toBeDefined(); 609 | expect(obj.relationships.books.data.length).toBe(2); 610 | 611 | saveRequest.flush({}); 612 | }); 613 | 614 | it('should update author with 204 response', () => { 615 | const expectedUrl = `${BASE_URL}/${API_VERSION}/authors/${AUTHOR_ID}`; 616 | const author = new Author(datastore, { 617 | id: AUTHOR_ID, 618 | attributes: { 619 | date_of_birth: parseISO(AUTHOR_BIRTH), 620 | name: AUTHOR_NAME 621 | } 622 | }); 623 | author.name = 'Rowling'; 624 | author.date_of_birth = parseISO('1965-07-31'); 625 | 626 | author.save().subscribe((val) => { 627 | expect(val.name).toEqual(author.name); 628 | }); 629 | 630 | httpMock.expectNone(`${BASE_URL}/${API_VERSION}/authors`); 631 | const saveRequest = httpMock.expectOne({method: 'PATCH', url: expectedUrl}); 632 | const obj = saveRequest.request.body.data; 633 | expect(obj.attributes.name).toEqual('Rowling'); 634 | expect(obj.attributes.dob).toEqual(parseISO('1965-07-31').toISOString()); 635 | expect(obj.id).toBe(AUTHOR_ID); 636 | expect(obj.type).toBe('authors'); 637 | expect(obj.relationships).toBeUndefined(); 638 | 639 | saveRequest.flush(null, {status: 204, statusText: 'No Content'}); 640 | }); 641 | 642 | it('should integrate server updates on 200 response', () => { 643 | const expectedUrl = `${BASE_URL}/${API_VERSION}/authors/${AUTHOR_ID}`; 644 | const author = new Author(datastore, { 645 | id: AUTHOR_ID, 646 | attributes: { 647 | date_of_birth: parseISO(AUTHOR_BIRTH), 648 | name: AUTHOR_NAME 649 | } 650 | }); 651 | author.name = 'Rowling'; 652 | author.date_of_birth = parseISO('1965-07-31'); 653 | 654 | author.save().subscribe((val) => { 655 | expect(val.name).toEqual('Potter'); 656 | }); 657 | 658 | httpMock.expectNone(`${BASE_URL}/${API_VERSION}/authors`); 659 | const saveRequest = httpMock.expectOne({method: 'PATCH', url: expectedUrl}); 660 | const obj = saveRequest.request.body.data; 661 | expect(obj.attributes.name).toEqual('Rowling'); 662 | expect(obj.attributes.dob).toEqual(parseISO('1965-07-31').toISOString()); 663 | expect(obj.id).toBe(AUTHOR_ID); 664 | expect(obj.type).toBe('authors'); 665 | expect(obj.relationships).toBeUndefined(); 666 | 667 | saveRequest.flush({ 668 | data: { 669 | id: obj.id, 670 | attributes: { 671 | name: 'Potter', 672 | } 673 | } 674 | }); 675 | }); 676 | 677 | it('should remove empty ToMany-relationships', () => { 678 | const expectedUrl = `${BASE_URL}/${API_VERSION}/authors/${AUTHOR_ID}`; 679 | const BOOK_NUMBER = 2; 680 | const DATA = getAuthorData('books', BOOK_NUMBER); 681 | const author = new Author(datastore, DATA); 682 | 683 | author.books = []; 684 | 685 | author.save().subscribe(); 686 | 687 | httpMock.expectNone(`${BASE_URL}/${API_VERSION}/authors`); 688 | const saveRequest = httpMock.expectOne({method: 'PATCH', url: expectedUrl}); 689 | const obj = saveRequest.request.body.data; 690 | expect(obj.relationships).toBeDefined(); 691 | expect(obj.relationships.books).toBeDefined(); 692 | expect(obj.relationships.books.data).toBeDefined(); 693 | expect(obj.relationships.books.data.length).toBe(0); 694 | 695 | saveRequest.flush({}); 696 | 697 | }); 698 | }); 699 | }); 700 | --------------------------------------------------------------------------------