This is the second part of a two-part series. You should read the first part “Jackson-js: Powerful JavaScript decorators to serialize/deserialize objects into JSON and vice versa (Part 1)”.
In this article, I will give a simple example using the jackson-js
library with Angular 9 for the client side and two examples for the server side: one using Node.js + Express + SQLite3 (with Sequelize 5) and another one using Node.js + LoopBack 4.
Full code examples can be found in this repository.
Client side: Angular 9
For the client side, we will create a very basic Angular 9 application consisting of 2 models (Writer
and Book
), 2 services to communicate with the server side, and a home component that will call these 2 services.
Models
Writer model:
import { JsonProperty, JsonClassType, JsonIdentityInfo, ObjectIdGenerator, JsonIgnoreProperties, JsonManagedReference } from 'jackson-js'; | |
import { Book } from './book.model'; | |
export class Writer { | |
@JsonProperty() | |
id: number; | |
@JsonProperty() | |
firstname: string; | |
@JsonProperty() | |
lastname: string; | |
@JsonProperty() | |
image: string; | |
@JsonProperty() | |
biography: string; | |
@JsonProperty() | |
@JsonClassType({type: () => [Array, [Book]]}) | |
@JsonManagedReference() | |
books: Book[] = []; | |
@JsonProperty() | |
@JsonClassType({type: () => [Date]}) | |
createdAt: Date; | |
@JsonProperty() | |
@JsonClassType({type: () => [Date]}) | |
updatedAt: Date; | |
@JsonProperty() | |
get fullname() { | |
return `${this.firstname} ${this.lastname}`; | |
} | |
} |
Book model:
import { JsonProperty, JsonClassType, JsonIdentityInfo, ObjectIdGenerator, JsonIgnoreProperties, JsonManagedReference } from 'jackson-js'; | |
import { Book } from './book.model'; | |
export class Writer { | |
@JsonProperty() | |
id: number; | |
@JsonProperty() | |
firstname: string; | |
@JsonProperty() | |
lastname: string; | |
@JsonProperty() | |
image: string; | |
@JsonProperty() | |
biography: string; | |
@JsonProperty() | |
@JsonClassType({type: () => [Array, [Book]]}) | |
@JsonManagedReference() | |
books: Book[] = []; | |
@JsonProperty() | |
@JsonClassType({type: () => [Date]}) | |
createdAt: Date; | |
@JsonProperty() | |
@JsonClassType({type: () => [Date]}) | |
updatedAt: Date; | |
@JsonProperty() | |
get fullname() { | |
return `${this.firstname} ${this.lastname}`; | |
} | |
} |
Services
Services that will be used to get/push data from/to the server side.
Writer Service:
import { Injectable } from '@angular/core'; | |
import { Observable, throwError } from 'rxjs'; | |
import { map, catchError } from 'rxjs/operators'; | |
import { HttpClient } from '@angular/common/http'; | |
import { JsonParser, JsonStringifier } from 'jackson-js'; | |
import { Writer } from '../models/writer.model'; | |
@Injectable({ | |
providedIn: 'root' | |
}) | |
export class WriterService { | |
constructor(private http: HttpClient) { } | |
getAllPublic(): Observable<Array<Writer>> { | |
const jsonParser = new JsonParser(); | |
return this.http.get<Array<Writer>>('http://localhost:8000/writers/public').pipe( | |
map(response => { | |
return jsonParser.transform(response, { | |
mainCreator: () => [Array, [Writer]] | |
}); | |
}), | |
catchError(error => { | |
return throwError(error); | |
}) | |
); | |
} | |
getAll(): Observable<Array<Writer>> { | |
const jsonParser = new JsonParser(); | |
return this.http.get<Array<Writer>>('http://localhost:8000/writers').pipe( | |
map(response => { | |
return jsonParser.transform(response, { | |
mainCreator: () => [Array, [Writer]] | |
}); | |
}), | |
catchError(error => { | |
return throwError(error); | |
}) | |
); | |
} | |
add(writer: Writer): Observable<Writer> { | |
const jsonParser = new JsonParser(); | |
const jsonStringifier = new JsonStringifier(); | |
return this.http.post<Writer>('http://localhost:8000/writers', jsonStringifier.transform(writer, { | |
mainCreator: () => [Writer] | |
})).pipe( | |
map(response => { | |
return jsonParser.transform(response, { | |
mainCreator: () => [Writer] | |
}); | |
}), | |
catchError(error => { | |
return throwError(error); | |
}) | |
); | |
} | |
} |
Book Service:
import { Injectable } from '@angular/core'; | |
import { Observable, throwError } from 'rxjs'; | |
import { map, catchError } from 'rxjs/operators'; | |
import { HttpClient } from '@angular/common/http'; | |
import { JsonParser, JsonStringifier } from 'jackson-js'; | |
import { Book } from '../models/book.model'; | |
@Injectable({ | |
providedIn: 'root' | |
}) | |
export class BookService { | |
constructor(private http: HttpClient) { } | |
getAllPublic(): Observable<Array<Book>> { | |
const jsonParser = new JsonParser(); | |
return this.http.get<Array<Book>>('http://localhost:8000/books/public').pipe( | |
map(response => { | |
return jsonParser.transform(response, { | |
mainCreator: () => [Array, [Book]] | |
}); | |
}), | |
catchError(error => { | |
return throwError(error); | |
}) | |
) | |
} | |
getAll(): Observable<Array<Book>> { | |
const jsonParser = new JsonParser(); | |
return this.http.get<Array<Book>>('http://localhost:8000/books').pipe( | |
map(response => { | |
return jsonParser.transform(response, { | |
mainCreator: () => [Array, [Book]] | |
}); | |
}), | |
catchError(error => { | |
return throwError(error); | |
}) | |
) | |
} | |
add(book: Book): Observable<Book> { | |
const jsonParser = new JsonParser(); | |
const jsonStringifier = new JsonStringifier(); | |
return this.http.post<Book>('http://localhost:8000/books', jsonStringifier.transform(book, { | |
mainCreator: () => [Book] | |
})).pipe( | |
map(response => { | |
return jsonParser.transform(response, { | |
mainCreator: () => [Book] | |
}); | |
}), | |
catchError(error => { | |
return throwError(error); | |
}) | |
); | |
} | |
} |
Home Component
A very basic component that will be used to call each method of each service and to print the responses data to the browser console.
import { Component, OnInit } from '@angular/core'; | |
import { BookService } from 'src/app/services/book.service'; | |
import { WriterService } from 'src/app/services/writer.service'; | |
import { Writer } from 'src/app/models/writer.model'; | |
import { Book } from 'src/app/models/book.model'; | |
import { ObjectMapper } from 'jackson-js'; | |
@Component({ | |
selector: 'app-home', | |
templateUrl: './home.component.html', | |
styleUrls: ['./home.component.scss'] | |
}) | |
export class HomeComponent implements OnInit { | |
constructor(private bookService: BookService, | |
private writerService: WriterService) { } | |
ngOnInit(): void { | |
this.writerService.getAllPublic().subscribe((response) => { | |
console.log(response); | |
}); | |
this.bookService.getAllPublic().subscribe((response) => { | |
console.log(response); | |
}); | |
this.writerService.getAll().subscribe((response) => { | |
console.log(response); | |
}); | |
this.bookService.getAll().subscribe((response) => { | |
console.log(response); | |
}); | |
const newBook = new Book(); | |
newBook.title = 'New Book'; | |
newBook.cover = 'https://picsum.photos/640/480?random=1'; | |
newBook.price = 23; | |
const newWriter = new Writer(); | |
newWriter.firstname = 'John'; | |
newWriter.lastname = 'Alfa'; | |
newWriter.books = [newBook]; | |
newWriter.image = 'https://picsum.photos/640/480?random=2'; | |
newWriter.biography = 'Dolorem suscipit ad nesciunt non aut numquam at alias.'; | |
this.writerService.add(newWriter).subscribe((response) => { | |
console.log(response); | |
}); | |
const newBook2 = new Book(); | |
newBook2.title = 'New Book 2'; | |
newBook2.cover = 'https://picsum.photos/640/480?random=3'; | |
newBook2.price = 12; | |
this.bookService.add(newBook2).subscribe((response) => { | |
console.log(response); | |
}); | |
} | |
} |
Server side: Node.js + Express + SQLite3 (with Sequelize 5)
For this case, we will implement a simple Node.js Express application using an in-memory SQLite 3 database (with Sequelize 5), consisting of 2 entities (Writer and Book). This Express application will offer endpoints used by the client side to get/add data from/to the database. Also, it will have 2 Jackson Views has an example: ProfileViews.public
and ProfileViews.registered
.
Models
Because each class model extends the Sequelize.Model class, we need to ignore its enumerable properties using the @JsonIgnoreProperties
decorator, otherwise, serialization won't work properly.
Writer model:
import { JsonProperty, JsonClassType, | |
JsonIgnoreProperties, JsonManagedReference, JsonCreator, JsonCreatorMode, JsonGetter } from 'jackson-js'; | |
import { Book } from './Book'; | |
import Sequelize from 'sequelize'; | |
@JsonIgnoreProperties({value: ['sequelize', 'isNewRecord', '_options', '_changed', | |
'_modelOptions', '_previousDataValues', 'dataValues']}) | |
export class Writer extends Sequelize.Model { | |
@JsonProperty() | |
id: number; | |
@JsonProperty() | |
firstname: string; | |
@JsonProperty() | |
lastname: string; | |
@JsonProperty() | |
biography: string; | |
@JsonProperty() | |
image: string; | |
@JsonProperty() | |
@JsonClassType({type: () => [Array, [Book]]}) | |
@JsonManagedReference({contextGroups: ['writerContextApi']}) | |
@JsonIgnoreProperties({value: ['writer'], contextGroups: ['bookContextApi']}) | |
books: Book[] = []; | |
static init(sequelize) { | |
// @ts-ignore | |
return super.init( | |
{ | |
id: { | |
type: Sequelize.INTEGER, | |
primaryKey: true, | |
autoIncrement: true | |
}, | |
firstname: Sequelize.STRING, | |
lastname: Sequelize.STRING, | |
biography: Sequelize.STRING, | |
image: Sequelize.STRING, | |
}, | |
{sequelize} | |
); | |
} | |
static associate() { | |
this.hasMany(Book, { | |
foreignKey: 'writerId', | |
as: 'books' | |
}); | |
} | |
@JsonCreator({mode: JsonCreatorMode.DELEGATING}) | |
static buildFromJson(writer: Writer) { | |
return Writer.build(writer, { | |
include: [{ | |
all: true | |
}] | |
}); | |
} | |
@JsonProperty() | |
get fullname(): string { | |
return `${this.firstname} ${this.lastname}`; | |
} | |
@JsonGetter() | |
getBooks(): Array<Book> { | |
return (this.get() as Writer).books; | |
} | |
} |
As we can see, the writer model has a static creator method named buildFromJson decorated with the
@JsonCreator
decorator. This method will be used during deserialization in order to create an instance of the Writer class model using Sequelize 5 API.
Also, I defined a getter method for the books property using @JsonGetter
in order to get the associated data (one-to-many relationship with Book
) through the Sequelize get()
method. Without this, during serialization, the books
property will be empty. Another way is to call the Sequelize get()
or toJSON()
method for each model that needs to be serialized before serialization. This is needed because Sequelize wraps all it’s return values in a virtual object that contains metadata, so we unwrap them.
Book model:
import { JsonProperty, JsonClassType, JsonIgnoreProperties, JsonBackReference, | |
JsonView, JsonFormat, JsonFormatShape, JsonCreator, JsonCreatorMode, JsonGetter } from 'jackson-js'; | |
import { Writer } from './Writer'; | |
import Sequelize from 'sequelize'; | |
import { ProfileViews } from '../views'; | |
@JsonIgnoreProperties({value: ['writerId', 'sequelize', 'isNewRecord', '_options', | |
'_changed', '_modelOptions', '_previousDataValues', 'dataValues']}) | |
export class Book extends Sequelize.Model { | |
@JsonProperty() | |
id: number; | |
@JsonProperty() | |
title: string; | |
@JsonProperty() | |
cover: string; | |
@JsonProperty() | |
@JsonClassType({type: () => [Writer]}) | |
@JsonBackReference({contextGroups: ['writerContextApi']}) | |
writer: Writer; | |
@JsonProperty() | |
@JsonView({value: () => [ProfileViews.registered]}) | |
@JsonFormat({shape: JsonFormatShape.STRING, toFixed: 2}) | |
price: number; | |
static init(sequelize) { | |
// @ts-ignore | |
return super.init( | |
{ | |
id: { | |
type: Sequelize.INTEGER, | |
primaryKey: true, | |
autoIncrement: true | |
}, | |
title: Sequelize.STRING, | |
cover: Sequelize.STRING, | |
price: Sequelize.FLOAT | |
}, | |
{sequelize} | |
); | |
} | |
@JsonCreator({mode: JsonCreatorMode.DELEGATING}) | |
static buildFromJson(book: Book) { | |
return Book.build(book, { | |
include: [{ | |
all: true | |
}] | |
}); | |
} | |
static associate() { | |
this.belongsTo(Writer, { | |
foreignKey: 'writerId', | |
as: 'writer' | |
}); | |
} | |
@JsonGetter() | |
getWriter(): Writer { | |
return (this.get() as Book).writer; | |
} | |
} |
Same as
Writer
, we have a static creator method used to build the Book instance and a getter method for the writer property. Also, in the @JsonIgnoreProperties
decorator, I added the writerId
property (that is automatically added by Sequelize) in order to exclude it from the generated JSON content.
Server side: Node.js + LoopBack 4
Instead, for this case, we will implement a simple LoopBack 4 application using a file data source as the database. This application will offer endpoints used by the client side to get/add data from/to the database. Also here, it will have 2 Jackson Views has an example: ProfileViews.public
and ProfileViews.registered
.
Models
LoopBack 4 Entity class doesn't wrap our entities, so we don't need to use @JsonIgnoreProperties
decorator like on Sequelize.
Writer model:
import {Entity, hasMany, model, property} from '@loopback/repository'; | |
import {JsonClassType, JsonIgnoreProperties, JsonManagedReference, JsonProperty} from 'jackson-js'; | |
import {Book, BookWithRelations} from './book.model'; | |
@model({settings: {strict: false}}) | |
export class Writer extends Entity { | |
// Define well-known properties here | |
@property({type: 'number', id: true, generated: false}) | |
@JsonProperty() | |
id: number; | |
@property({type: 'string', required: true}) | |
@JsonProperty() | |
firstname: string; | |
@JsonProperty() | |
@property({type: 'string', required: true}) | |
lastname: string; | |
@property({type: 'string', required: true}) | |
@JsonProperty() | |
biography: string; | |
@property({type: 'string', required: true}) | |
@JsonProperty() | |
image: string; | |
@JsonProperty() | |
@JsonClassType({type: () => [Array, [Book]]}) | |
@JsonManagedReference({contextGroups: ['writerContextApi']}) | |
@JsonIgnoreProperties({value: ['writer'], contextGroups: ['bookContextApi']}) | |
@hasMany(() => Book) | |
books: Book[]; | |
constructor(data?: Partial<Writer>) { | |
super(data); | |
} | |
getId() { | |
return this.id; | |
} | |
@JsonProperty() | |
get fullname(): string { | |
return `${this.firstname} ${this.lastname}`; | |
} | |
} | |
export interface WriterRelations { | |
// describe navigational properties here | |
books: BookWithRelations[]; | |
} | |
export type WriterWithRelations = Writer & WriterRelations; |
Book model:
import {belongsTo, Entity, model, property} from '@loopback/repository'; | |
import {JsonBackReference, JsonClassType, JsonFormat, JsonFormatShape, JsonIgnore, JsonProperty, JsonView} from 'jackson-js'; | |
import {Writer, WriterWithRelations} from '.'; | |
import {ProfileViews} from '../views'; | |
@model({settings: {strict: false}}) | |
export class Book extends Entity { | |
// Define well-known properties here | |
@property({type: 'number', id: true, generated: false}) | |
@JsonProperty() | |
id: number; | |
@property({type: 'string', required: true}) | |
@JsonProperty() | |
title: string; | |
@property({type: 'string', required: true}) | |
@JsonProperty() | |
cover: string; | |
@property({type: 'number', required: true}) | |
@JsonProperty() | |
@JsonView({value: () => [ProfileViews.registered]}) | |
@JsonFormat({shape: JsonFormatShape.STRING, toFixed: 2}) | |
price: number; | |
@JsonProperty() | |
@JsonClassType({type: () => [Writer]}) | |
@JsonBackReference({contextGroups: ['writerContextApi']}) | |
writer: Writer; | |
@JsonIgnore() | |
@belongsTo(() => Writer) | |
writerId: number; | |
constructor(data?: Partial<Book>) { | |
super(data); | |
} | |
getId() { | |
return this.id; | |
} | |
} | |
export interface BookRelations { | |
// describe navigational properties here | |
writer: WriterWithRelations; | |
} | |
export type BookWithRelations = Book & BookRelations; |
LoopBack Custom “object-mapper” Interceptor
LoopBack gives us the ability to create custom interceptors. In this case, I created a global interceptor called object-mapper
that will be used by the controllers to read and write JSON content using the jackson-js
library.
import {ContextTags, globalInterceptor, Interceptor, InvocationContext, InvocationResult, Provider, ValueOrPromise} from '@loopback/context'; | |
import {RestBindings} from '@loopback/rest'; | |
import {JsonParser, JsonStringifier} from 'jackson-js'; | |
import {ClassList, JsonStringifierContext} from 'jackson-js/dist/@types'; | |
import {BookController, WriterController} from '../controllers'; | |
import {ProfileViews} from '../views'; | |
/** | |
* This class will be bound to the application as an `Interceptor` during | |
* `boot` | |
*/ | |
@globalInterceptor('object-mapper', {tags: {[ContextTags.GLOBAL_INTERCEPTOR_SOURCE]: 'ObjectMapper'}}) | |
export class ObjectMapperInterceptor implements Provider<Interceptor> { | |
// eslint-disable-next-line @typescript-eslint/no-explicit-any | |
jsonStringifier: JsonStringifier<any> = new JsonStringifier(); | |
// eslint-disable-next-line @typescript-eslint/no-explicit-any | |
jsonParser: JsonParser<any> = new JsonParser(); | |
constructor() {} | |
/** | |
* This method is used by LoopBack context to produce an interceptor function | |
* for the binding. | |
* | |
* @returns An interceptor function | |
*/ | |
value() { | |
return this.intercept.bind(this); | |
} | |
/** | |
* The logic to intercept an invocation | |
* @param invocationCtx - Invocation context | |
* @param next - A function to invoke next interceptor or the target method | |
*/ | |
async intercept( | |
invocationCtx: InvocationContext, | |
next: () => ValueOrPromise<InvocationResult>, | |
) { | |
// eslint-disable-next-line no-useless-catch | |
try { | |
// Add pre-invocation logic here | |
const httpReq = await invocationCtx.get(RestBindings.Http.REQUEST, {optional: true}); | |
if (httpReq && (httpReq.method === 'POST' || httpReq.method === 'PUT' || httpReq.method === 'PATCH')) { | |
const argModel = invocationCtx.args[0]; | |
// eslint-disable-next-line @typescript-eslint/no-explicit-any | |
let creator: ClassList<any> = [Object]; | |
if (invocationCtx.target instanceof WriterController) { | |
creator = [invocationCtx.target.writerRepository.entityClass]; | |
} else if (invocationCtx.target instanceof BookController) { | |
creator = [invocationCtx.target.bookRepository.entityClass]; | |
} | |
invocationCtx.args[0] = this.jsonParser.transform(argModel, { | |
mainCreator: () => creator | |
}); | |
} | |
const result = await next(); | |
// Add post-invocation logic here | |
if (httpReq && (httpReq.method === 'GET' || httpReq.method === 'POST')) { | |
// eslint-disable-next-line @typescript-eslint/no-explicit-any | |
let creator: ClassList<any> = (result != null) ? [Object] : [result.constructor]; | |
const mapperContext: JsonStringifierContext = {}; | |
if (result instanceof Array) { | |
if (result.length > 0) { | |
creator = [Array, [result[0].constructor]]; | |
} else { | |
creator = [Array, [Object]]; | |
} | |
} | |
mapperContext.mainCreator = () => creator; | |
if (invocationCtx.target instanceof WriterController) { | |
mapperContext.withContextGroups = ['writerContextApi']; | |
} else if (invocationCtx.target instanceof BookController) { | |
mapperContext.withContextGroups = ['bookContextApi']; | |
} | |
if (httpReq.url.endsWith('/public')) { | |
mapperContext.withViews = () => [ProfileViews.public]; | |
} | |
return this.jsonStringifier.transform(result, mapperContext); | |
} | |
return result; | |
} catch (err) { | |
// Add error handling logic here | |
throw err; | |
} | |
} | |
} |
Jackson Context Groups
For both server side examples, I defined 2 context groups: writerContextApi
and bookContextApi
. They are used to enable/disable specific decorators based on which API endpoint the client is calling.
-
writerContextApi
: it is used when the client is calling/writers/*
endpoints to enable the@JsonManagedReference
decorator and disable the@JsonIgnoreProperties
decorator on theWriter.books
property and to enable the@JsonBackReference
decorator on theBook.writer
property; -
bookContextApi
: it is used when the client is calling/books/*
endpoints to enable the@JsonIgnoreProperties
decorator on theWriter.books
property.
RESTful API Endpoints available
Both servers run at http://localhost:8000
and, for each one, I created a function named initDB
that will initialize the database with fake data at server startup.
Endpoints are:
-
/writers/public
: get (“GET”) all writers (with books) usingProfileViews.public
Jackson view; -
/writers
: get (“GET”) all writers (with books) or add (“POST”) a new writer; -
/books/public
: get (“GET”) all books (each one with its writer) usingProfileViews.public
Jackson view; -
/books
: get (“GET”) all books (each one with its writer) or add (“POST”) a new book.
Top comments (0)