@michalkotas/angular2-jsonapi
TypeScript icon, indicating that this package has built-in type declarations

18.0.1 • Public • Published

Version Status Build Status Coverage Status

Angular 2 JSON API

Fork of the angular2-jsonapi repo. This version is compatible with Angular 14+

A lightweight Angular 2 adapter for JSON API

Table of Contents

Introduction

Why this library? Because JSON API 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.

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:

[
    Post{
        id: 1,
        title: 'My post',
        content: 'My content',
        comments: [
            Comment{
                id: 1,
                // ...
            },
            Comment{
                id: 2,
                // ...
            }
        ]
    },
    // ...
]

Installation

To install this library, run:

$ npm install @michalkotas/angular2-jsonapi --save

Migrate to version 18+

Version 18 does not expose the module because it has been removed. Therefore, you need to remove all imports of the JsonApiModule.

Additionally, ensure that HttpClientModule is provided in your application, as it was previously imported and re-exported by JsonApiModule.

Angular CLI configuration (for CLI 8.1+)

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:

{
  "compilerOptions": {
    "emitDecoratorMetadata": true,
  }
}

Notice for es2015 compilation

Beginning with Angular 8 the default compile target will be es2015 (in tsconfig.json). Make sure to add this line in your src/polyfills.ts as we need it to read metadata from the models:

import 'core-js/proposals/reflect-metadata';

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).

Usage

Configuration

Firstly, create your Datastore service:

  • Extend the JsonApiDatastore class
  • Decorate it with @JsonApiDatastoreConfig, set the baseUrl for your APIs and map your models (Optional: you can set apiVersion, baseUrl will be suffixed with it)
  • Pass the HttpClient depencency to the parent constructor.
import { JsonApiDatastoreConfig, JsonApiDatastore, DatastoreConfig } from '@michalkotas/angular2-jsonapi';

const config: DatastoreConfig = {
  baseUrl: 'http://localhost:8000/v1/',
  models: {
    posts: Post,
    comments: Comment,
    users: User
  }
}

@Injectable()
@JsonApiDatastoreConfig(config)
export class Datastore extends JsonApiDatastore {

    constructor(http: HttpClient) {
        super(http);
    }

}

Then set up your models:

  • Extend the JsonApiModel class
  • Decorate it with @JsonApiModelConfig, passing the type
  • Decorate the class properties with @Attribute
  • Decorate the relationships attributes with @HasMany and @BelongsTo
  • (optional) Define your Metadata
import { JsonApiModelConfig, JsonApiModel, Attribute, HasMany, BelongsTo } from '@michalkotas/angular2-jsonapi';

@JsonApiModelConfig({
    type: 'posts'
})
export class Post extends JsonApiModel {

    @Attribute()
    title: string;

    @Attribute()
    content: string;

    @Attribute({ serializedName: 'created-at' })
    createdAt: Date;

    @HasMany()
    comments: Comment[];
}

@JsonApiModelConfig({
    type: 'comments'
})
export class Comment extends JsonApiModel {

    @Attribute()
    title: string;

    @Attribute()
    created_at: Date;

    @BelongsTo()
    post: Post;

    @BelongsTo()
    user: User;
}

@JsonApiModelConfig({
    type: 'users'
})
export class User extends JsonApiModel {

    @Attribute()
    name: string;
    // ...
}

Finding Records

Querying for Multiple Records

Now, you can use your Datastore in order to query your API with the findAll() method:

  • The first argument is the type of object you want to query.
  • The second argument is the list of params: write them in JSON format and they will be serialized.
  • The returned value is a document which gives access to the metdata and the models.
// ...
constructor(private datastore: Datastore) { }

getPosts(){
    this.datastore.findAll(Post, {
        page: { size: 10, number: 1 },
        filter: {
          title: 'My Post',
        },
    }).subscribe(
        (posts: JsonApiQueryData<Post>) => console.log(posts.getModels())
    );
}

Use peekAll() to retrieve all of the records for a given type that are already loaded into the store, without making a network request:

let posts = this.datastore.peekAll(Post);

Retrieving a Single Record

Use findRecord() to retrieve a record by its type and ID:

this.datastore.findRecord(Post, '1').subscribe(
    (post: Post) => console.log(post)
);

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:

let post = this.datastore.peekRecord(Post, '1');

Creating, Updating and Deleting

Creating Records

You can create records by calling the createRecord() method on the datastore:

  • The first argument is the type of object you want to create.
  • The second is a JSON with the object attributes.
this.datastore.createRecord(Post, {
    title: 'My post',
    content: 'My content'
});

Updating Records

Making changes to records is as simple as setting the attribute you want to change:

this.datastore.findRecord(Post, '1').subscribe(
    (post: Post) => {
        post.title = 'New title';
    }
);

Persisting Records

Records are persisted on a per-instance basis. Call save() on any instance of JsonApiModel and it will make a network request.

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.

Newly created records will be POSTed:

let post = this.datastore.createRecord(Post, {
    title: 'My post',
    content: 'My content'
});

post.save().subscribe();  // => POST to '/posts'

Records that already exist on the backend are updated using the HTTP PATCH verb:

this.datastore.findRecord(Post, '1').subscribe(
    (post: Post) => {
        post.title = 'New title';
        post.save().subscribe();  // => PATCH to '/posts/1'
    }
);

The save() method will return an Observer that you need to subscribe:

post.save().subscribe(
    (post: Post) => console.log(post)
);

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, the request won't go out until something subscribes to the observable.

You can tell if a record has outstanding changes that have not yet been saved by checking its hasDirtyAttributes property.

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.

this.datastore.findRecord(Post, '1').subscribe(
    (post: Post) => {
        console.log(post.title);                // => 'Old title'
        console.log(post.hasDirtyAttributes);   // => false
        post.title = 'New title';
        console.log(post.hasDirtyAttributes);   // => true
        post.rollbackAttributes();
        console.log(post.hasDirtyAttributes);   // => false
        console.log(post.title);                // => 'Old title'
    }
);

Deleting Records

For deleting a record, just call the datastore's method deleteRecord(), passing the type and the id of the record:

this.datastore.deleteRecord(Post, '1').subscribe(() => {
    // deleted!
});

Relationships

Querying records

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:

this.datastore.findAll(Post, {
    page: { size: 10, number: 1},
    include: 'comments'
}).subscribe(
    (document) => {
        console.log(document.getMeta()); // metadata
        console.log(document.getModels()); // models
    }
);

The same, if you want to include relationships when finding a record:

this.datastore.findRecord(Post, '1', {
    include: 'comments,comments.user'
}).subscribe(
    (post: Post) => console.log(post)
);

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 Comments, that have a User that has Posts, that have Comments... etc.

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.

Creating Records

If the object you want to create has a one-to-many relationship, you can do this:

let post = this.datastore.peekRecord(Post, '1');
let comment = this.datastore.createRecord(Comment, {
    title: 'My comment',
    post: post
});
comment.save().subscribe();

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!

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:

comment.save({
    include: 'user'
}).subscribe(
    (comment: Comment) => console.log(comment)
);

Updating Records

You can also update an object that comes from a relationship:

this.datastore.findRecord(Post, '1', {
    include: 'comments'
}).subscribe(
    (post: Post) => {
        let comment: Comment = post.comments[0];
        comment.title = 'Cool';
        comment.save().subscribe((comment: Comment) => {
            console.log(comment);
        });
    }
);

Metadata

Metadata such as links or data for pagination purposes can also be included in the result.

For each model a specific MetadataModel can be defined. To do this, the class name needs to be added in the ModelConfig.

If no MetadataModel is explicitly defined, the default one will be used, which contains an array of links and meta property.

@JsonApiModelConfig({
    type: 'deals',
    meta: JsonApiMetaModel
})
export class Deal extends JsonApiModel

An instance of a class provided in meta property will get the whole response in a constructor.

Datastore config

Datastore config can be specified through the JsonApiDatastoreConfig decorator and/or by setting a config variable of the Datastore class. If an option is specified in both objects, a value from config variable will be taken into account.

@JsonApiDatastoreConfig(config: DatastoreConfig)
export class Datastore extends JsonApiDatastore {
    private customConfig: DatastoreConfig = {
        baseUrl: 'http://something.com'
    }

    constructor() {
        this.config = this.customConfig;
    }
}

config:

  • models - all the models which will be stored in the datastore
  • baseUrl - base API URL
  • apiVersion - optional, a string which will be appended to the baseUrl
  • overrides - used for overriding internal methods to achive custom functionalities
Overrides
  • getDirtyAttributes - determines which model attributes are dirty
  • toQueryString - transforms query parameters to a query string

Model config

@JsonApiModelConfig(options: ModelOptions)
export class Post extends JsonApiModel { }

options:

  • type
  • baseUrl - if not specified, the global baseUrl will be used
  • apiVersion - if not specified, the global apiVersion will be used
  • modelEndpointUrl - if not specified, type will be used instead
  • meta - optional, metadata model

Decorators

Model decorators

  • Attribute(options: AttributeDecoratorOptions)

    • AttributeDecoratorOptions:

      • converter, optional, must implement PropertyConverter interface
      • serializedName, optional

Custom Headers

By default, the library adds these headers, according to the JSON API MIME Types:

Accept: application/vnd.api+json
Content-Type: application/vnd.api+json

You can also add your custom headers to be appended to each http call:

this.datastore.headers = new HttpHeaders({'Authorization': 'Bearer ' + accessToken});

Or you can pass the headers as last argument of any datastore call method:

this.datastore.findAll(Post, {
    include: 'comments'
}, new HttpHeaders({'Authorization': 'Bearer ' + accessToken}));

and in the save() method:

post.save({}, new HttpHeaders({'Authorization': 'Bearer ' + accessToken})).subscribe();

Custom request options

You can add your custom request options to be appended to each http call:

this.datastore.requestOptions = {
    withCredentials: false,
    myOption: 123
}

Error handling

Error handling is done in the subscribe method of the returned Observables. If your server returns valid JSON API Error Objects you can access them in your onError method:

import {ErrorResponse} from "@michalkotas/angular2-jsonapi";

...

this.datastore.findAll(Post).subscribe(
    (posts: Post[]) => console.log(posts),
    (errorResponse) => {
        if (errorResponse instanceof ErrorResponse) {
              // do something with errorResponse
              console.log(errorResponse.errors);
        }
    }
);

It's also possible to handle errors for all requests by overriding handleError(error: any): Observable in the datastore.

Dates

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:

@JsonApiModelConfig({
    type: 'posts'
})
export class Post extends JsonApiModel {

    // ...

    @Attribute()
    created_at: Date;

}

Moreover, it should be noted that the following assumptions have been made:

  • Dates are expected to be received in one of the ISO 8601 formats, as per the JSON API spec recommendation;
  • Dates are always sent in full ISO 8601 format, with local timezone and without milliseconds (example: 2001-02-03T14:05:06+07:00).

Development

To generate all *.js, *.js.map and *.d.ts files:

$ npm run build

To lint all *.ts files:

$ npm run lint

Additional tools

Thanks

This library is inspired by the draft of this never implemented library.

License

MIT

Package Sidebar

Install

npm i @michalkotas/angular2-jsonapi

Weekly Downloads

90

Version

18.0.1

License

MIT

Unpacked Size

302 kB

Total Files

52

Last publish

Collaborators

  • michalkotas