Architecting an angular CLI project – Core (Part 2)

In the previous article I talked about creating the app and the basic folder structure we’re going to use for this series. Now we’re going to start “coding some life” into our project. Let’s start with the models folder. Since our app will communicate with a REST backend (ASP.NET Core 2) let’s start with the token response class that will handle the user’s login information. Let’s create a file named token-response.model.ts.

This file name follows (as almost everything else in this tutorial) the angular official guidelines mentioned in the first article, be sure to check it out!

import { BaseModel } from '../abstractions/base.model';

export class TokenResponseModel extends BaseModel {
    access_token: string = '';
    token_type: string = '';
    expires_in: number  = 0;
    valid_until: Date;
    error: string = '';
    error_description: string = '';
}

Now, to handle URL parameters we’re going to create a class:

import { BaseModel } from '../abstractions/base.model';

export class UrlParameterModel extends BaseModel {
    constructor(
        public key: string,
        public value: Object) {
        super();
    }
}

To handle key/value results we’ll use:

import { BaseModel } from '../abstractions/base.model';

export class KeyValueModel extends BaseModel {
    Key: string = '';
    Value: any = undefined;
}

Then a search request and response to handle inputs/outputs used for search:

import { BaseModel } from '../abstractions/base.model';

export class SearchRequestModel extends BaseModel {
    constructor(
        public Description?: string
    ) {
        super();
    }
}
import { BaseModel } from "../abstractions/base.model";

export class SearchResponseModel<T extends BaseModel> {
    Records: Array<T>;
    PageSize: number;
    TotalRecords: number;
}

For now we’re done with this folder, but it’ll be used to store the model classes of our app. Let’s go to the security folder for now. For our current needs we’re going to have one single file inside this folder to save the user’s security information:

import { Injectable } from '@angular/core';
import { LocalStorageService } from 'angular-2-local-storage';
import { TokenResponseModel } from '../models/token-response.model';

const UserToken = "UserToken";

@Injectable()
export class SecurityStorage {
    constructor(
        private localStorageService: LocalStorageService
    ) { }

    saveUserToken(userToken: TokenResponseModel): void {
        this.localStorageService.set(UserToken, userToken);
    }

    getUserToken(): TokenResponseModel {
        return this.localStorageService.get<TokenResponseModel>(UserToken);
    }

    save<T>(data: T, key: string): void {
        this.localStorageService.set(key, data);
    }

    get<T>(key: string): T {
        return this.localStorageService.get<T>(key);
    }

    clear(): void {
        this.localStorageService.clearAll();
    }
}

Moving on, we have the utils folder. Here we can add some tools our app will need, like:

import { BehaviorSubject } from 'rxjs/BehaviorSubject';
import { Injectable } from '@angular/core';
import { Observable } from 'rxjs/Observable';
import 'rxjs/add/operator/share';
import 'rxjs/add/operator/startWith';

@Injectable()
export class Globals {
    public isMakingRequest: BehaviorSubject<boolean> = new BehaviorSubject<boolean>(false);

    setIsMakingRequest(isMakingRequest: boolean): void {
        this.isMakingRequest.next(isMakingRequest);
    }
}

This file handles information globally accessible, in essence it is a service, but for organization purposes it is treated as a “different” file. We are also going to create a service injector to be used for some services within our app:

import { Injector } from '@angular/core';

export class ServiceLocator {
    static injector: Injector;
}

Now, let’s go to the services folder. This folder is used to store all the services of the app, I’m only going to add the basic ones: logger, request, session, alert and security. Your app will very likely need a few others, like notification, modal, etc…

export class LoggerService {
    log(msg: any): void {
        console.log(msg);
    }

    error(msg: any): void {
        console.error(msg);
    }

    warn(msg: any): void {
        console.warn(msg);
    }
}

This is obviously a very simple service only for demonstration purposes. In a real world app you’d want to create your own logic or use a library like angular2-logger. This service is just a wrapper for whatever option you decide to go with. Next we have the alert service:

import { Injectable } from '@angular/core';

@Injectable()
export class AlertService {
    showAlert(message: string, title?: string): void {
        alert(message);
    }

    showInfo(message: string, title?: string): void {
        alert(message);
    }

    showError(message: string, title?: string): void {
        alert(message);
    }

    showSuccess(message: string, title?: string, callback?: any): void {
        alert(message);
    }

    showConfirm(message: string, title?: string, callback?: any): void {
        prompt(message);
    }
}

Again a simple wrapper, moving on to the session service. This service will be used to “simulate” session data available throughout the app:

import { Injectable } from '@angular/core';

@Injectable()
export class SessionService {
    private isSessionLoaded: boolean = false;
    private sessionTestData: string;

    constructor() {
        if (!this.isSessionLoaded)
            this.loadSession();
    }

    getSessionTestData(): string {
        return this.sessionTestData;
    }

    loadSession(): void {
        if (this.isSessionLoaded)
            return;

        // load your session data here
        this.sessionTestData = 'session test data';
    }

    clearSession(): void {
        this.sessionTestData = undefined;
        this.isSessionLoaded = false;
    }

    reloadSession(): void {
        this.clearSession();
        this.loadSession();
    }
}

Now the request service, this service will handle all communications with our backend API:

import { Injectable } from '@angular/core';
import { Http, Response, Headers, RequestOptions } from '@angular/http';
import { Observable } from 'rxjs/Observable';
import { TokenResponseModel } from '../models/token-response.model';
import { UrlParameterModel } from '../models/url-parameter.model';
import { SecurityStorage } from '../security/security.storage';
import { AlertService } from './alert.service';
import { LoggerService } from './logger.service';
import { environment } from '../../environments/environment';
import { Globals } from '../utils/globals';

import 'rxjs/add/operator/map';
import 'rxjs/add/operator/catch';
import 'rxjs/add/operator/finally';
import 'rxjs/add/observable/throw';
import 'rxjs/add/observable/empty';

const ApiUrl: string = `${environment.baseUrl}api/`;

@Injectable()
export class RequestService {
    constructor(
        private http: Http,
        private securityStorage: SecurityStorage,
        private alertService: AlertService,
        private loggerService: LoggerService,
        private globals: Globals
    ) { }

    makeGet<T>(url: string, ...params: UrlParameterModel[]): Observable<T> {
        return this.makeRequest<T>('get', this.getUrl(url, false, params));
    }

    makePost<T>(url: string, data?: Object): Observable<T> {
        return this.makeRequest<T>('post', this.getUrl(url), data);
    }

    makePut<T>(url: string, data?: Object): Observable<T> {
        return this.makeRequest<T>('put', this.getUrl(url), data);
    }

    makeDelete(url: string): void {
        this.makeRequest('delete', this.getUrl(url));
    }

    makeFilePost<T>(url: string, data?: Object): Observable<T> {
        return this.makeRequest<T>('post', this.getUrl(url), data, true);
    }

    getToken(username: string, password: string): Observable<TokenResponseModel> {
        let headers = new Headers({ 'Content-Type': 'application/json' });
        let options = new RequestOptions({ headers: headers });

        return this.http.post(this.getUrl('token', true),
            `username=${username}&password=${encodeURI(password)}&grant_type=password`, options)
            .map((res: Response) => res.json())
            .catch((error: any) => {
                this.loggerService.error(error);
                this.alertService.showAlert("Couldn't login");

                return Observable.empty();
            })
            .finally(() => {
                this.globals.setIsMakingRequest(false);
            });
    }

    private makeRequest<T>(type: string, url: string, data?: Object, hasFile: boolean = false): Observable<T> {
        let request: Observable<Response>;
        let bodyString = data != null ? JSON.stringify(data) : "";
        let headers = hasFile ? new Headers() : new Headers({ 'Content-Type': 'application/json' });
        let userToken = this.securityStorage.getUserToken();

        if (userToken && new Date(userToken.valid_until as any).getTime() >= new Date().getTime())
            headers.append('Authorization', `Bearer ${userToken.access_token}`);

        let options = new RequestOptions({ headers: headers });

        if (hasFile) {
            request = this.http.post(url, data, options);
        } else {
            switch (type) {
                case "get":
                    request = this.http.get(url, options);
                    break;
                case "post":
                    request = this.http.post(url, bodyString, options);
                    break;
                case "put":
                    request = this.http.put(url, bodyString, options);
                    break;
                case "delete":
                    request = this.http.delete(url, options);
                    break;
            }
        }

        return request
            .map((res: Response) => res.json())
            .catch((error: any) => {
                this.loggerService.error(error);

                if (error.status == 500) {
                    this.alertService.showError(error._body);
                } else if (error.status == 588) {
                    this.alertService.showAlert(error._body);
                }

                return Observable.empty();
            })
            .finally(() => {
                this.globals.setIsMakingRequest(false);
            });
    }

    private getUrl(url: string, isBase: boolean = false, params: UrlParameterModel[] = []): string {
        let currentUrl: string = (isBase ? environment.baseUrl : ApiUrl) + url;
        let isFirstRun: boolean = true;

        if (params.length > 0) {
            for (let param of params) {
                if (isFirstRun) {
                    isFirstRun = false;
                    currentUrl += `?${param.key}=${param.value}`;
                } else {
                    currentUrl += `&${param.key}=${param.value}`;
                }
            }
        }

        return currentUrl;
    }
}

Let’s take a moment to understand this file.

  • On line 18 we make use of the  baseUrl property to create the URL to access the backend.
  • From line 30 to 48 we have exposed methods to make the common REST requests (POST, GET, etc).
  • From line 50 to 66 we have the  getToken method, it tries to login the user and retrieve its token.
  • From line 68 to 114 the makeRequest method enters into the action to use Http to create requests and handle their responses.
  • On line 70 we create the body for requests, should they exist.
  • On line 71 we inform the request will send a JSON.
  • On line 72 we get the user’s token, if any.
  • On lines 74 and 75 we set the authorization header of every request to the token of the logged user, if it exists.
  • On lines 79 to 96 we create the actual requests.
  • On lines 101 to 109 we handle the error, should it be thrown.
  • On line 103 we check for errors the server couldn’t handle and on line 105 we handle business exceptions, errors the server handled and were re-thrown to the user as warnings (in this example the status code 588 is a custom status code that I chose to return all my business exceptions. You can handle this scenario in different manners, like sending a property to your exception return).
  • Finally from 116 to 132 we generate the URL using the get parameters, if any

Moving on, we have the security service, this service will login the user and save the user’s data locally:

import { Injectable } from '@angular/core';
import { Router } from '@angular/router';
import { SecurityStorage } from '../security/security.storage';
import { TokenResponseModel } from '../models/token-response.model';
import { RequestService } from './request.service';
import { AlertService } from './alert.service';
import { SessionService } from './session.service';

@Injectable()
export class SecurityService {
    redirectUrl: string = '';

    constructor(private requestService: RequestService,
        private securityStorage: SecurityStorage,
        private alertService: AlertService,
        private sessionService: SessionService,
        private router: Router
    ) { }

    login(username: string, password: string): void {
        this.requestService.getToken(username, password).subscribe(tokenResponse => {
            if (tokenResponse.error || tokenResponse.error_description) {
                this.alertService.showError(tokenResponse.error_description);
                return;
            }

            var validUntil = new Date();

            validUntil.setSeconds(validUntil.getSeconds() + tokenResponse.expires_in);
            tokenResponse.valid_until = validUntil;

            this.securityStorage.saveUserToken(tokenResponse);

            // se redirectUrl estiver preenchido, redirecionar o usuário
            if (this.redirectUrl)
                this.router.navigate([this.redirectUrl]);
            else
                this.router.navigate(['home']);
        });
    }

    logout(): void {
        this.securityStorage.clear();
        this.sessionService.clearSession();
        this.router.navigate(['login']);
    }

    isLoggedIn(): boolean {
        var userToken = this.securityStorage.getUserToken();

        if (userToken != null)
            return new Date(userToken.valid_until as any).getTime() >= new Date().getTime();

        return false;
    }

    getToken(): string {
        return this.securityStorage.getUserToken().access_token;
    }
}
  • From line 20 to 40 we login the user and save the user’s token locally for the subsequent requests. We also redirect the user to the previous route, if any.
  • On line 42 we logout the user.
  • From line 48 to 55 we validate if the user is logged in, verifying the validity of the token as well.
  • On line 57 we return the current token for the logged in user.

Let’s move on now to the abstractions folder. This folder will handle all the base classes for the app. Since most of my models have some common properties I usually create a base class for them, one with entity related fields (Id and ChangedProperties) and another base to define all DTOs/models:

export abstract class BaseModel { }
import { BaseModel } from './base.model';

export abstract class BaseEntityModel extends BaseModel {
    Id: number = 0;
    ChangedProperties: string[];
}

Then we create a base class for all our stores:

import { RequestService } from '../services/request.service';
import { ServiceLocator } from '../utils/service-locator';

export abstract class BaseStore {
    protected baseController: string = '';
    protected requestService: RequestService;

    protected constructor() {
        this.requestService = ServiceLocator.injector.get(RequestService);
    }

    protected getUrl(action: string): string {
        return `${this.baseController}/${action}`;
    }
}

A store is a class that will retrieve data and/or make operations. These stores should be the main form (not only!) of communication with the “outside world” from your app. From that base store we have another one that will handle basic search functionalities found on some screens:

import { BaseModel } from './base.model';
import { BaseStore } from './base.store';
import { SearchRequestModel } from '../models/search-request.model';
import { SearchResponseModel } from '../models/search-response.model';
import { UrlParameterModel } from '../models/url-parameter.model';
import { KeyValueModel } from '../models/key-value.model';
import { Observable } from 'rxjs/Observable';

export abstract class BaseSearchStore<T extends BaseModel> extends BaseStore {
    protected constructor() { super(); }

    search(description?: string): Observable<SearchResponseModel<T>> {
        return this.requestService.makePost<SearchResponseModel<T>>(this.getUrl('search'), new SearchRequestModel(description));
    }

    list(description: string = '', baseId: number = null): Observable<SearchResponseModel<KeyValueModel>> {
        return this.requestService.makeGet<SearchResponseModel<KeyValueModel>>(this.getUrl('list'),
            new UrlParameterModel('description', description));
    }
}

And on top of that we have another base store that will handle basic CRUD functionalities:

import { Observable } from 'rxjs/Observable';
import { BaseStore } from './base.store';
import { BaseModel } from '../abstractions/base.model';
import { UrlParameterModel } from '../models/url-parameter.model';
import { RequestService } from '../services/request.service';
import { BaseSearchStore } from './base-search.store';

export class BaseCrudStore<T extends BaseModel> extends BaseSearchStore<T> {
    constructor() { super(); }

    get(id: string): Observable<T> {
        return this.requestService.makeGet<T>(this.getUrl('get'), new UrlParameterModel('id', id));
    }

    add(data: T): Observable<T> {
        return this.requestService.makePost<T>(this.getUrl('add'), data);
    }

    edit(data: T): Observable<T> {
        return this.requestService.makePost<T>(this.getUrl('edit'), data);
    }

    remove(ids: string[]): Observable<boolean> {
        return this.requestService.makePost<boolean>(this.getUrl('remove'), ids);
    }
}

At last, we have one last file in this folder to be the component’s (screens) abstractions:

import { Router, ActivatedRoute, Params } from '@angular/router';
import { BaseModel } from '../abstractions/base.model';
import { BaseCrudStore } from '../abstractions/base-crud.store';
import { AlertService } from '../../core/services/alert.service';

export abstract class BaseComponent<T extends BaseModel> {
    protected recordId?: number = undefined;
    isLoadingData: boolean = false;
    isEdit: boolean = false;
    record: T;
    titleAction: string = '';

    protected constructor(
        protected alertService: AlertService,
        protected router: Router,
        protected activatedRoute: ActivatedRoute,
        protected store: BaseCrudStore<T>,
        protected baseType: { new(): T; }
    ) {
        this.record = new baseType();
        this.activatedRoute.params.subscribe((params: Params) => {
            this.recordId = params['id'] ? +params['id'] : undefined;
            this.isEdit = this.recordId !== undefined;
            this.titleAction = this.isEdit ? 'Update' : 'New';
        });
    }

    abstract afterInit(): void;

    protected init(): void {
        if (this.isEdit) {
            this.isLoadingData = true;
            this.store.get(this.recordId!).subscribe(resp => {
                this.record = resp
                this.isLoadingData = false;
                this.afterInit();
            });
        } else
            this.afterInit();
    }

    protected saveRecord(): void {
        if (this.isEdit)
            this.store.edit(this.record).subscribe(resp => this.showSuccessAndReturn());
        else
            this.store.add(this.record).subscribe(resp => this.showSuccessAndReturn());
    }

    protected showSuccessAndReturn(): void {
        this.alertService.showSuccess('Record successfully saved!', '', () => this.returnToPrevious());
    }

    protected returnToPrevious(): void {
        if (this.isEdit)
            this.router.navigate(['../../search'], { relativeTo: this.activatedRoute });
        else
            this.router.navigate(['../search'], { relativeTo: this.activatedRoute });
    }
}
  • On lines 21 to 25 we set the variables for the component, if it is being edited or not.
  • From lines 31 to 37 we retrieve the record’s data and after that or if not editing call the method afterInit.
  • Then, on line 42 we save the record making the proper request (create/update).
  • The method on line 53 returns to the previous page (in this example we navigate to another route from a search table, for instance)

And this is it for the core folder. Next we’ll discuss the app folder.

Here’s the GitHub repository with the working project: https://github.com/eestein/cli-architecture




No Comments


You can leave the first : )



Leave a Reply

Your email address will not be published. Required fields are marked *