Odyssey #2 Un service API générique pour Angular 2

écrit par Rémy Villain publié le mercredi 1 mars 2017
Nous entrons dans la phase la plus compliquée, la poussée est énorme, les organismes sont mis à rude épreuve, l'accélération n'est pas de tout repos. La mise en orbite sera courte mais très intense. Avant de commencer, abordons rapidement ce que doit faire notre application. Nous allons devoir gérer des cartes redimensionnables à la manière de Post-it que l'on déplace à volonté sur un mur virtuel. Le fonctionnel a peu d'importance, il est un prétexte à l'utilisation d'une librairie JavaScript bien connue que nous devons utiliser dans le vrai projet à l'origine de cette série. Cette librairie s'appelle GridStack. Il ne vous aura pas echappé qu'intégrer cette librairie dans Angular 2 ne sera pas une mince affaire, et bien ne vous en faites pas, un article arrive bientôt sur le sujet. En attendant, allez jeter un oeil sur le GitHub de ng2-gridstack ! Pour bien se mettre dans le bain, commençons par mettre en place un service générique pour appeler l'API (développée grâce à un contrôleur non moins générique). Il faut qu'il soit souple et complet.

L'API

WebApi 2 c'est bonnard ! Il est possible d'écrire des contrôleurs API REST sécurisés aussi facilement que des contrôleurs classiques. Voici le contrôleur générique de notre application : GenrericApiContrôleur
using System;
using System.Collections.Generic;
using System.Linq;
using System.Net;
using System.Net.Http;
using System.Reflection;
using System.Web.Http;
using AngularOdyssey.Models;
using AngularOdyssey.BL.Interfaces;

namespace AngularOdyssey.Controllers.api
{
    public class GenericApiController<T> : ApiController where T : class
    {
        #region Constantes
        public const string START_PARAM = "skip";
        public const string PAGE_SIZE_PARAM = "take";
        public const string SEARCH_PARAM = "search";
        public const string SORT_PARAM = "sort";
        public const string FILTERS_PARAM = "filters";
        #endregion


        protected IRepository<T> repositoryBase;
        protected string includes;

        // GET api/<controller>
        public virtual IEnumerable<T> Get()
        {
            return repositoryBase.Get();
        }

        public virtual PagedListViewModel GetWithParams()
        {
            setIncludes();

            var parameters = Request.GetQueryNameValuePairs();
            int startIndex = 0;
            int nbPages = 1;
            int pageSize = -1;
            string sort = string.Empty;
            bool reverse = false;

            if (String.IsNullOrEmpty(includes))
            {
                includes = "";
            }

            IEnumerable<T> listElements = repositoryBase.Get(null, null, includes);
            foreach (var p in parameters)
            {
                switch (p.Key)
                {
                    case START_PARAM:
                        int.TryParse(p.Value, out startIndex);
                        break;
                    case PAGE_SIZE_PARAM:
                        int.TryParse(p.Value, out pageSize);
                        break;
                    case SEARCH_PARAM:
                        var filters = parameters.Where(param => param.Key == FILTERS_PARAM);
                        if (filters.Any())
                        {
                            listElements = listElements.Where(e => genericWhere(e, p.Value, filters));
                        }
                        else
                        {
                            listElements = listElements.Where(e => genericWhere(e, p.Value, null));
                        }
                        break;
                    case SORT_PARAM:
                        sort = p.Value;
                        if (p.Value.EndsWith("asc"))
                        {
                            sort = sort.Substring(0, sort.Length - 3);
                        }
                        else if (p.Value.EndsWith("desc"))
                        {
                            reverse = true;
                            sort = sort.Substring(0, sort.Length - 4);
                        }
                        break;
                }
            }

            //Sort
            if (sort != string.Empty)
            {
                if (!reverse)
                {
                    listElements = listElements.OrderBy(c => c.GetType().GetProperty(sort).GetValue(c, null));
                }
                else
                {
                    listElements = listElements.OrderByDescending(c => c.GetType().GetProperty(sort).GetValue(c, null));
                }
            }

            //Pages calculation
            int total = listElements.Count();
            if (pageSize > 0)
            {
                nbPages = (total - 1) / pageSize + 1;
                if (startIndex >= total)
                {
                    startIndex = 0;
                }

                //Get Result page
                listElements = listElements.Skip(startIndex).Take(pageSize);
            }

            listElements = listElements.ToList();

            // Return the list of customers
            return new PagedListViewModel
            {
                data = listElements.ToList<object>(),
                total = total
                //Paging = new Paging
                //{
                //    Total = total,
                //    NbOfPages = nbPages,
                //    Limit = pageSize,
                //    Offset = startIndex,
                //    Returned = listElements.Count(),
                //}
            };
        }

        protected void setIncludes()
        {
        }

        /// <summary>
        /// Construction d'un where en fonction de tous les chanmps de type string de la table
        /// </summary>
        /// <param name="objet"></param>
        /// <param name="pValue"></param>
        /// <returns></returns>
        protected static bool genericWhere(T objet, string pValue, IEnumerable<KeyValuePair<string, string>> filters)
        {
            var res = false;
            foreach (PropertyInfo prop in typeof(T).GetProperties())
            {
                if (filters == null || !filters.Any() || filters.Where(f => f.Value.Contains(prop.Name)).Any())
                {
                    if (prop.PropertyType == typeof(string))
                    {
                        string strValue = (string)typeof(T).GetProperty(prop.Name).GetValue(objet, null);
                        if (!string.IsNullOrEmpty(strValue))
                        {
                            if (strValue.ToLower().Contains(pValue.ToLower()))
                            {
                                res = true;
                                break;
                            }
                        }

                    }
                    if (prop.PropertyType == typeof(DateTime) || prop.PropertyType == typeof(DateTime?) || prop.PropertyType == typeof(Nullable<DateTime>))
                    {
                        DateTime? dtValue = (DateTime?)typeof(T).GetProperty(prop.Name).GetValue(objet, null);
                        if (dtValue != null)
                        {
                            DateTime parsedDate = new DateTime();
                            DateTime.TryParse(pValue, out parsedDate);
                            if (!parsedDate.Equals(new DateTime()) && parsedDate.Equals(dtValue))
                            {
                                res = true;
                                break;
                            }
                        }

                    }
                }

            }

            return res;
        }

        // GET api/<controller>/5
        public virtual T Get(string id)
        {
            int intId = int.Parse(id);
            T objet = repositoryBase.GetByID(intId);
            return objet;
        }

        // POST api/<controller>
        public virtual T Post(T objet)
        {
            if (ModelState.IsValid)
            {
                repositoryBase.Insert(objet);
                repositoryBase.SaveChanges();
                return objet;
            }
            return null;
        }

        // PUT api/<controller>/5
        public virtual void Put(string id, T objet)
        {
            int intId = int.Parse(id);
            if (ModelState.IsValid)
            {
                repositoryBase.Update(objet);
                repositoryBase.SaveChanges();

            }
        }
        
        // DELETE api/<controller>/5
        public virtual void Delete(int id)
        {
            T objet = repositoryBase.GetByID(id);
            if (objet != null)
            {
                repositoryBase.Delete(id);
                repositoryBase.SaveChanges();
            }

        }
    }
}
Vous noterez ici l'utilisation du Pattern Repository et de l'inversion de contrôle assuré par Unity. Et là, un contrôleur relativement simple qui servira à gérer nos tableaux de Post-it :
using AngularOdyssey.Models;
using AngularOdyssey.BL.Interfaces;
using System.Globalization;
using System;

namespace AngularOdyssey.Controllers.api
{
    public class PanelsController : GenericApiController<Panel>
    {
        ICardRepository cardRepo;
        CultureInfo culture = new CultureInfo("fr-FR");

        public PanelsController(IPanelRepository repo, ICardRepository cardrepo)
        {
            this.repositoryBase = repo;
            this.cardRepo = cardrepo;
        }
        // PUT api/<controller>/5
        public override void Put(string id, Panel panel)
        {
            panel.LastModified = DateTime.Now;
            base.Put(id, panel);
            if (ModelState.IsValid)
            {
                foreach (Card card in panel.Cards)
                {
                    cardRepo.Update(card);
                }
                cardRepo.SaveChanges();
            }
        }
        // POST api/<controller>
        public override Panel Post(Panel panel)
        {
            panel.CreatedOn = DateTime.Now;
            panel.LastModified = panel.CreatedOn;
            return base.Post(panel);
        }
    }
}
Le dernier point concernant l'API qu'il me semble important d'évoquer est la configuration des routes. En effet entre les routes MVC, celles de la web API et celles propre à Angular il y a facilement moyen de se perdre. Je ne vous laisserais pas dans ce marasme plus longtemps en vous livrant comme ça, de but en blanc, la configuration de nos routes pour l'API :
using System;
using System.Collections.Generic;
using System.Linq;
using System.Net.Http;
using System.Web.Http;
using System.Web.Http.Routing;

namespace AngularOdyssey
{
    public static class WebApiConfig
    {
        public static void Register(HttpConfiguration config)
        {
            // Web API configuration and services

            // Web API routes
            config.MapHttpAttributeRoutes();

            config.Routes.MapHttpRoute(
                name: "Web API RPC",
                routeTemplate: "api/{controller}/{action}",
                defaults: new { },
                constraints: new { action = @"[A-Za-z]+", httpMethod = new HttpMethodConstraint(HttpMethod.Get) }
            );
            config.Routes.MapHttpRoute(
                name: "Web API Resource GUID",
                routeTemplate: "api/{controller}/{id}",
                defaults: new { },
                constraints: new { id = @"^[{(]?[0-9A-F]{8}[-]?([0-9A-F]{4}[-]?){3}[0-9A-F]{12}[)}]?$" }
            );
            config.Routes.MapHttpRoute(
                name: "Web API Resource int",
                routeTemplate: "api/{controller}/{id}",
                defaults: new { },
                constraints: new { id = @"\d+" }
            );
            // GET /api/{resource}
            config.Routes.MapHttpRoute(
                name: "Web API Get All",
                routeTemplate: "api/{controller}",
                defaults: new { action = "Get" },
                constraints: new { httpMethod = new HttpMethodConstraint(HttpMethod.Get) }
                );

            // PUT /api/{resource}
            config.Routes.MapHttpRoute(
                name: "Web API Update",
                routeTemplate: "api/{controller}",
                defaults: new { action = "Put" },
                constraints: new { httpMethod = new HttpMethodConstraint(HttpMethod.Put) }
                );

            // POST /api/{resource}
            config.Routes.MapHttpRoute(
                name: "Web API Post",
                routeTemplate: "api/{controller}",
                defaults: new { action = "Post" },
                constraints: new { httpMethod = new HttpMethodConstraint(HttpMethod.Post) }
                );

            // POST /api/{resource}/{action}
            config.Routes.MapHttpRoute(
                name: "Web API RPC Post",
                routeTemplate: "api/{controller}/{action}",
                defaults: new { },
                constraints: new { action = @"[A-Za-z]+", httpMethod = new HttpMethodConstraint(HttpMethod.Post) }
                );

            config.Routes.MapHttpRoute(
                name: "DefaultApi",
                routeTemplate: "api/{controller}/{id}",
                defaults: new { id = RouteParameter.Optional }
            );

            config.Routes.MapHttpRoute(
                name: "GetWithParamsApi",
                routeTemplate: "api/{controller}/getWithParams",
                defaults: new { method = "getWithParams" }
            );

            config.Routes.MapHttpRoute(
                name: "ExportCsvApi",
                routeTemplate: "api/{controller}/getCsv",
                defaults: new { method = "getCsv" }
            );

            config.Routes.MapHttpRoute(
                name: "ExportXlsxApi",
                routeTemplate: "api/{controller}/getXlsx",
                defaults: new { method = "getXlsx" }
            );

            GlobalConfiguration.Configuration.Formatters.JsonFormatter.SerializerSettings.ReferenceLoopHandling = Newtonsoft.Json.ReferenceLoopHandling.Ignore;
        }
    }
}
N'oubliez pas de jeter un oeil au fichier Global.asax.cs qui contient un petit bout de code nécessaire :
protected void Application_BeginRequest(Object sender, EventArgs e)
        {
            // Gets incoming request path
            var path = Request.Url.AbsolutePath.ToLower();

            // To allow access to api via url during testing (if you're using api controllers) - you may want to remove this in production unless you wish to grant direct access to api calls from client...
            var isApi = path.StartsWith("/api", StringComparison.InvariantCultureIgnoreCase);
            // To allow access to my .net MVCController for login
            var isAccount = path.StartsWith("/account", StringComparison.InvariantCultureIgnoreCase);

            var isBL = path.StartsWith("/__browserLink", StringComparison.InvariantCultureIgnoreCase);

            if (isApi || isAccount || isBL)
            {
                return;
            }

            // Redirects to the RootUrl you specified above if the server can't find anything else
            if (!System.IO.File.Exists(Context.Server.MapPath(path)))
                Context.RewritePath(RootUrl);
        }
 

Le service générique

Ok, nous avons une API qui fonctionne, il ne nous reste plus qu'à l'interroger. Pour cela, écrivons un service générique prévu à cet effet :
// Imports
import { Http, Response, Headers, RequestOptions, URLSearchParams, ResponseContentType } from '@angular/http';
import { Observable } from 'rxjs/Rx';
import { SharedService } from './shared.service';
import { GridDataResult } from '../model/gridDataResult.model';
import * as saveAs from "file-saver"

// Decorator to tell Angular that this class can be injected as a service to another class
export class GenericApiService {

    protected MSG_CREATE_SUCCESS = "Enregistrement effectué";
    protected MSG_UPDATE_SUCCESS = "Enregistrement effectué";
    protected MSG_DELETE_SUCCESS = "Suppression effectuée";

    // Class constructor with Jsonp injected
    constructor(protected http: Http, protected sharedService: SharedService) {
    }

    // Base URL for Petfinder API
    protected apiUrl = "http://" + window.location.host + "/api/";
    protected controllerName = "";

    get(): Observable<any[]> {
        this.sharedService.startLoading();
        // Return response
        return this.http
            .get(this.apiUrl + this.controllerName)
            .map((res: Response) => this.manageSuccess(res))
            .catch((error: any) => this.manageError(error));
    }

    // get a pet based on their id
    getById(id: string): Observable<any> {

        // End point for list of pets:
        const endPoint = '/' + id;

        // Return response
        return this.http
            .get(this.apiUrl + this.controllerName + endPoint)
            .map((res: Response) => this.manageSuccess(res))
            .catch((error: any) => this.manageError(error));
    }

    getForGrid(skip = 0, take = 10, sort = "", searchText?: string): Observable<GridDataResult> {
        this.sharedService.startLoading();
        const endPoint = '/getWithParams';
        let queryStr = `skip=` + skip + `&take=` + take + `&sort=` + sort + `&$count=true`;
        if (searchText) {
            queryStr += '&search=' + searchText;
        }
        // Return response
        return this.http
            .get(this.apiUrl + this.controllerName + endPoint + '?' + queryStr)
            .map((res: Response) => this.manageSuccess(res))
            .catch((error: any) => this.manageError(error));
    }

    downloadCsv(): Observable<string> {
        this.sharedService.startLoading();
        const endPoint = '/getCsv';

        // Return response
        return this.http
            .get(this.apiUrl + this.controllerName + endPoint)
            .map((res: Response) => this.manageSuccess(res))
            .catch((error: any) => this.manageError(error));
    }

    downloadXlsx(): Observable<Response> {
        this.sharedService.startLoading();
        const endPoint = '/getXlsx';
        var headers = new Headers();

        // Return response
        return this.http
            .get(this.apiUrl + this.controllerName + endPoint, { responseType: ResponseContentType.Blob })
            .map((res: Response) => this.manageSuccessText(res))
            .catch((error: any) => this.manageError(error));
    }

    update(id: any, obj: any) {
        this.sharedService.startLoading();
        const endPoint = '/' + id;

        return this.http
            .put(this.apiUrl + this.controllerName + endPoint, obj)
            .map((res: Response) => this.manageSuccess(res, this.MSG_UPDATE_SUCCESS))
            .catch((error: any) => this.manageError(error));
    }

    create(obj: any) {
        this.sharedService.startLoading();
        const endPoint = '/';

        return this.http
            .post(this.apiUrl + this.controllerName + endPoint, obj)
            .map((res: Response) => this.manageSuccess(res, this.MSG_CREATE_SUCCESS))
            .catch((error: any) => this.manageError(error));
    }

    delete(id: any) {
        this.sharedService.startLoading();
        const endPoint = '/' + id;

        return this.http
            .delete(this.apiUrl + this.controllerName + endPoint)
            .map((res: Response) => this.manageSuccess(res, this.MSG_DELETE_SUCCESS))
            .catch((error: any) => this.manageError(error));
    }

    manageError(error: any) {
        this.sharedService.endLoading();
        this.sharedService.errorToast('Erreur Serveur');
        return Observable.throw((error.json ? error.json().error : error) || 'Server error');
    }
    manageSuccess(res: Response, toastMsg?: string) {
        this.sharedService.endLoading();
        if (toastMsg) {
            this.sharedService.successToast(toastMsg);
        }
        return res.json();
    }
    manageSuccessText(res: Response, toastMsg?: string) {
        this.sharedService.endLoading();
        if (toastMsg) {
            this.sharedService.successToast(toastMsg);
        }
        return res;
    }
}
Ce service contient quelques fonctions indispensables. Pour nos panneaux, le service en charge de faire les requêtes http n'en sera que simplifié :
// Imports
import { Injectable } from '@angular/core';
import { Http, Response, Headers, RequestOptions, URLSearchParams } from '@angular/http';
import { Observable } from 'rxjs/Rx';
import { Panel } from '../model/panel.model';
import { GenericApiService } from '../common/genericApi.service';
import { SharedService } from '../common/shared.service';

// Decorator to tell Angular that this class can be injected as a service to another class
@Injectable()
export class PanelService extends GenericApiService {

    constructor(http: Http, sharedService: SharedService) {
        super(http, sharedService);
        this.controllerName = 'panels';
    }

    createCard(obj: any) {
        this.sharedService.startLoading();
        var controllerName = 'cards';
        const endPoint = '/';

        return this.http
            .post(this.apiUrl + controllerName + endPoint, obj)
            .map((res: Response) => this.manageSuccess(res, this.MSG_CREATE_SUCCESS))
            .catch((error: any) => this.manageError(error));
    }

}
 

Utilisation

Ce service n'a plus qu'à être injecté dans le composant adéquat et le tour est joué :
import { Component, OnInit, ViewChild } from '@angular/core';
import { PanelService } from './panel.service';
import { Observable } from 'rxjs/Observable';
import { Panel } from '../model/panel.model';
import { GridDataResult } from '../model/gridDataResult.model';
import { SharedService } from '../common/shared.service';
import { RouterModule, Routes, Router, RouterLink } from '@angular/router';
import { ConfirmationPopoverModule } from 'angular-confirmation-popover';
import * as saveAs from 'file-saver';

@Component({
    moduleId: module.id,
    templateUrl: 'panel.list.component.html'
})

export class PanelListComponent {
    private panels: Panel[];
    
    public confirmClicked: boolean = false;
    public cancelClicked: boolean = false;
    public page: number = 1;
    public itemsPerPage: number = 10;
    public numPages: number = 1;
    public length: number = 0;
    private noResult: boolean = false;

    constructor(private panelService: PanelService, private sharedService: SharedService, private router: Router) {
        //this.loadItems();
    }


    public ngAfterViewInit(): void {
        this.onChangeTable(this.config);
    }

    private loadItems(): void {
        this.panelService.getForGrid((this.page - 1) * this.itemsPerPage, this.itemsPerPage, this.getSorting(), this.config.filtering.filterString)
            .subscribe(
            users => {
                if (users.total <= (this.page - 1) * this.itemsPerPage) {
                    this.page = 1;
                    if (users.total == 0) {
                        this.rows = users.data;
                        this.noResult = true;
                    }
                    else {
                        this.noResult = false;
                    }
                }
                else {
                    this.noResult = false;
                    this.length = users.total;
                    this.rows = users.data;
                }

            }, //Bind to view
            err => {
                //Log errors if any
                console.log(err);
            });

    }

    private getSorting(): string {
        let sorting = "";
        for (let column of this.columns) {
            if (column.sort == "asc" || column.sort == "desc")
                sorting = column.name + column.sort;
        }
        return sorting;
    }

    public rows: Array<any> = [];
    public columns: Array<any> = [
        { title: 'ID', name: 'PanelId' },
        { title: 'Titre', name: 'Title' },
        { title: 'Cartes', name: 'Cards.length' },
        { title: 'Créé le', name: 'CreatedOn' },
        { title: 'Modifié le', name: 'LastModified' },
    ];

    public config: any = {
        sorting: { columns: this.columns },
        filtering: { filterString: '' },
        className: ['table-condensed', 'table-bordered', 'table-clickable']
    }

    public onChangeTable(conf: any, page: any = { page: this.page, itemsPerPage: this.itemsPerPage }): any {
        if (conf.sorting) {
            Object.assign(this.config.sorting, conf.sorting);
        }
        this.page = page.page;
        this.loadItems();
    }


    public onCellClick(data: any): any {
        this.router.navigate(['/panel', data.row.PanelId]);
    }
}
Nous voilà paré pour affronter tout type d'écran de paramétrage ou autre CRUD Only Features. Mais pas seulement, grâce à l'héritage salvateur proposé par TypeScript, on peut très facilement ajouter des méthodes particulières dans chacun des services. Ainsi s'achève ce deuxième épisode de notre Angular Odyssey. Beaucoup de sujets sont en attente, le thème du prochain épisode n'est donc pas encore fixé. Mais j'aime autant vous le dire, il va y avoir de la matière.