Adapting Ben Nadel's Angular 2 ApiGateway example to Typescript

As a new user of Angular 2 I am really appreciating the wealth of content the community has put together. Some shining examples include the Thoughtram guys, Victor Savkin, Rob Wormald & the subject of this post - Ben Nadel.

In particular Ben has had a recent series of blog posts where he shares his thoughts around and experience with Angular 2. What makes them great is they aren't really filtered. When he's struggling with a concept he doesn't hide the fact. When something clicks and his original opinion on something changes he shares that too. My only gripe, if you can even call it that, is that he writes all the examples in vanilla Javascript (ES5) code. I'm a .NET guy who is not really a fan of regular javascript (give me types!), so I wanted to tackle one of his recent non-trivial examples and re-write it using Typescript. That example is his post titled - Creating Specialized HTTP Clients In Angular 2 Beta 8. If you haven't read it, stop now and please do so.

...back? Ok, let's get started. To tackle this conversion we'll work from the bottom up:

  1. Add the ApiGateway service class
  2. Add the Friend service
  3. Update the App component
  4. Add in the HTTP error handler
The simplest Angular 2 typescript app possible

As we work I won't bother tackling anything beyond Angular 2 & Typescript. No webpack, bundling, sass, testing, etc...nothing. Hopefully this will let us focus on the Typescript code without adding a bunch of noise just to get a page loaded. The template we'll start from was pulled right from the Angular 2 site, but I've put a copy in my own GitHub repo in case you want to grab it there.

At the moment we have a very simple AppComponent:

import {Component} from 'angular2/core';

@Component({
    selector: 'my-app',
    template: '<h1>My First Angular 2 App</h1>'
})
export class AppComponent { }  

We'll be updating that as we go along. We also have a simple main.ts file that bootstraps everything:

import {bootstrap}    from 'angular2/platform/browser'  
import {AppComponent} from './app.component'

bootstrap(AppComponent);  

With the basics in place let's get started!

Add ApiGateway class

In Ben's example this is the lowest layer of the architecture, and provides the code to talk directly to the remote API, but also exposes a couple of Observables about the state of the API communications. Of all the components this one is the most complex, but here's the adaptation to Typescript.

import {Injectable} from "angular2/core";  
import {Http, Response, RequestOptions, RequestMethod, URLSearchParams} from "angular2/http";  
import {Observable} from "rxjs/Observable";  
import {Subject} from "rxjs/Subject";

// Import the rxjs operators we need (in a production app you'll
//  probably want to import only the operators you actually use)
//
import 'rxjs/Rx';

export class ApiGatewayOptions {  
    method: RequestMethod;
    url: string;
    headers = {};
    params = {};
    data = {};
}


@Injectable()
export class ApiGateway {

    // Define the internal Subject we'll use to push errors
    private errorsSubject = new Subject<any>();

    // Provide the *public* Observable that clients can subscribe to
    errors$: Observable<any>;

    // Define the internal Subject we'll use to push the command count
    private pendingCommandsSubject = new Subject<number>();
    private pendingCommandCount = 0;

    // Provide the *public* Observable that clients can subscribe to
    pendingCommands$: Observable<number>;

    constructor(
        private http: Http
    ) {
        // Create our observables from the subjects
        this.errors$ = this.errorsSubject.asObservable();
        this.pendingCommands$ = this.pendingCommandsSubject.asObservable();

    }

    // I perform a GET request to the API, appending the given params
    // as URL search parameters. Returns a stream.
    get(url: string, params: any): Observable<Response> {
        let options = new ApiGatewayOptions();
        options.method = RequestMethod.Get;
        options.url = url;
        options.params = params;

        return this.request(options);
    }


    // I perform a POST request to the API. If both the params and data
    // are present, the params will be appended as URL search parameters
    // and the data will be serialized as a JSON payload. If only the
    // data is present, it will be serialized as a JSON payload. Returns
    // a stream.
    post(url: string, params: any, data: any): Observable<Response> {
        if (!data) {
            data = params;
            params = {};
        }
        let options = new ApiGatewayOptions();
        options.method = RequestMethod.Post;
        options.url = url;
        options.params = params;
        options.data = data;

        return this.request(options);
    }


    private request(options: ApiGatewayOptions): Observable<any> {

        options.method = (options.method || RequestMethod.Get);
        options.url = (options.url || "");
        options.headers = (options.headers || {});
        options.params = (options.params || {});
        options.data = (options.data || {});

        this.interpolateUrl(options);
        this.addXsrfToken(options);
        this.addContentType(options);

        let requestOptions = new RequestOptions();
        requestOptions.method = options.method;
        requestOptions.url = options.url;
        requestOptions.headers = options.headers;
        requestOptions.search = this.buildUrlSearchParams(options.params);
        requestOptions.body = JSON.stringify(options.data);

        let isCommand = (options.method !== RequestMethod.Get);

        if (isCommand) {
            this.pendingCommandsSubject.next(++this.pendingCommandCount);
        }

        let stream = this.http.request(options.url, requestOptions)
            .catch((error: any) => {
                this.errorsSubject.next(error);
                return Observable.throw(error);
            })
            .map(this.unwrapHttpValue)
            .catch((error: any) => {
                return Observable.throw(this.unwrapHttpError(error));
            })
            .finally(() => {
                if (isCommand) {
                    this.pendingCommandsSubject.next(--this.pendingCommandCount);
                }
            });

        return stream;
    }


    private addContentType(options: ApiGatewayOptions): ApiGatewayOptions {
        if (options.method !== RequestMethod.Get) {
            options.headers["Content-Type"] = "application/json; charset=UTF-8";
        }
        return options;
    }

    private extractValue(collection: any, key: string): any {
        var value = collection[key];
        delete (collection[key]);
        return value;
    }

    private addXsrfToken(options: ApiGatewayOptions): ApiGatewayOptions {
        var xsrfToken = this.getXsrfCookie();
        if (xsrfToken) {
            options.headers["X-XSRF-TOKEN"] = xsrfToken;
        }
        return options;
    }

    private getXsrfCookie(): string {
        var matches = document.cookie.match(/\bXSRF-TOKEN=([^\s;]+)/);
        try {
            return (matches && decodeURIComponent(matches[1]));
        } catch (decodeError) {
            return ("");
        }
    }

    private buildUrlSearchParams(params: any): URLSearchParams {
        var searchParams = new URLSearchParams();
        for (var key in params) {
            searchParams.append(key, params[key])
        }
        return searchParams;
    }

    private interpolateUrl(options: ApiGatewayOptions): ApiGatewayOptions {
        options.url = options.url.replace(
            /:([a-zA-Z]+[\w-]*)/g,
            ($0, token) => {
                // Try to move matching token from the params collection.
                if (options.params.hasOwnProperty(token)) {
                    return (this.extractValue(options.params, token));
                }
                // Try to move matching token from the data collection.
                if (options.data.hasOwnProperty(token)) {
                    return (this.extractValue(options.data, token));
                }
                // If a matching value couldn't be found, just replace
                // the token with the empty string.
                return ("");
            }
        );
        // Clean up any repeating slashes.
        options.url = options.url.replace(/\/{2,}/g, "/");
        // Clean up any trailing slashes.
        options.url = options.url.replace(/\/+$/g, "");

        return options;
    }

    private unwrapHttpError(error: any): any {
        try {
            return (error.json());
        } catch (jsonError) {
            return ({
                code: -1,
                message: "An unexpected error occurred."
            });
        }
    }

    private unwrapHttpValue(value: Response): any {
        return (value.json());
    }
}

A couple things to note include:
1. Always annotate your services with @Injectable() ... and don't forget the trailing parentheses!
2. To use rxjs operators (map, catch, finally, etc) you need to import them!
3. Some folks like to append $ to the name of observable streams to help identify the property as something you can subscribe to. I followed that convention here.
3. With typescript you can easily mark class properties or methods as private to hide them
4. I try to avoid it where I can, but the any type can be your friend when you don't know what type something is (even if it's when you're first writing some code)

Once we have this class in place we need to add it as a provider, and to do so we'll update the code to bootstrap the app in main.ts:

import {bootstrap}    from 'angular2/platform/browser';  
import {HTTP_PROVIDERS} from "angular2/http";

import {AppComponent} from './app.component';  
import {ApiGateway} from "./apiGateway.service";

document.cookie = "XSRF-TOKEN=Dont-Tase-Me-Bro";

bootstrap(AppComponent, [  
    HTTP_PROVIDERS,
    ApiGateway
]);

Note we also need to add in the HTTP_PROVIDERS so that the ApiGateway can have its constructor agruments injected.

Adding in the FriendService

Now the FriendService uses the ApiGateway for the heavy lifting, so it's not quite as complex:

import {Injectable} from "angular2/core";  
import {Observable} from "rxjs/Observable";

import {ApiGateway} from "./apiGateway.service";

export class Friend {  
    id: number;
    name: string;
    description: string;
}


@Injectable()
export class FriendService {

    constructor(
        private apiGateway: ApiGateway
    )
    { }

    getFriend(id: number): Observable<Friend> {
        var stream = this.apiGateway.get(
            "./api/:type/:id.json",
            {
                type: "friends",
                id: id,
                // Adding this to get more data in the query string for
                // the purposes of the demo.
                _cache: (new Date()).getTime()
            }
        )
            .map((value: any) => {
                let friend = new Friend();
                friend.id = value.id;
                friend.name = value.name;
                friend.description = value.description;

                return friend;
            });

        return stream;
    }


    updateFriend(id: number, name: string): Observable<any> {
        var stream = this.apiGateway.post(
            "./api/:type/:id.json",
            {
                type: "friends",
                id: id,
                // Adding this to get more data in the query string for
                // the purposes of the demo.
                _cache: (new Date()).getTime()
            },
            {
                name: name
            }
        );
        return (stream);
    }
}

Here we define the Friend class, and basically have the same code as Ben's javascript version. Now we need to make a similar update again to main.ts to set up the provider for the this service.

import {bootstrap}    from 'angular2/platform/browser';  
import {HTTP_PROVIDERS} from "angular2/http";

import {AppComponent} from './app.component';  
import {ApiGateway} from "./apiGateway.service";  
import {FriendService} from "./friend.service";

document.cookie = "XSRF-TOKEN=Dont-Tase-Me-Bro";

bootstrap(AppComponent, [  
    HTTP_PROVIDERS,
    ApiGateway,
    FriendService
]);
Update the main AppComponent

Now we have the services we need to support the top-level app component. Here's what that component looks like after we convert it:

import {Component, OnInit} from 'angular2/core';  
import {Subscription} from "rxjs/Subscription";

// Import the services we need
//
import {ApiGateway} from "./apiGateway.service";  
import {FriendService, Friend} from "./friend.service";

@Component({
    selector: 'my-app',
    template: `
        <p>
            <a (click)="loadFriend( 1 )">Load Friend 1</a>
            &nbsp;|&nbsp;
            <a (click)="loadFriend( 2 )">Load Friend 2</a>
            &nbsp;|&nbsp;
            <a (click)="loadFriend( 3 )">Load Friend 3</a>
            &nbsp;|&nbsp;
            <a (click)="loadFriend( 4 )">Load Friend 4 (Not Found)</a>
            &nbsp;|&nbsp;
            <a (click)="updateFriend( 1 )">Update Friend 1</a>
        </p>
        <div *ngIf="friend">
            <h3>
                {{ friend.name }}
            </h3>
            <ul>
                <li>
                    <strong>ID</strong>: {{ friend.id }}
                </li>
                <li>
                    <strong>Name</strong>: {{ friend.name }}
                </li>
                <li>
                    <strong>Description</strong>: {{ friend.description }}
                </li>
            </ul>
        </div>    
    `
})
export class AppComponent implements OnInit {

    friend: Friend;

    private currentSubscription: Subscription;

    constructor(
        private friendService: FriendService,
        private apiGateway: ApiGateway
    ) { }


    ngOnInit() {
        this.apiGateway.pendingCommands$.subscribe(
            function handleValue(pendingCount) {
                console.debug("Pending commands:", pendingCount);
            }
        );
    }


    loadFriend(id: number): void {

        // If we have an existing request subscription, cancel it.
        // --
        // NOTE: If the request already completed, there is no harm here.
        if (this.currentSubscription) {
            this.currentSubscription.unsubscribe();
        }

        // Request the new friend and keep track of the response
        // subscription so that we can cancel it in the future.
        this.currentSubscription = this.friendService
            .getFriend(id)
            .subscribe(
                (newFriend: Friend) => {
                    this.friend = newFriend;
                },
                (error: any) => {
                    console.warn("Could not load friend.");
                    console.dir(error);
                });
    }

    updateFriend(id: number): void {
        console.warn("We are about to try a POST (this may not work in your environment).");

        this.friendService
            .updateFriend(id, "Lisa")
            .subscribe(
                () => {},
                () => {}
            );
    }
}

Since we have already set up the providers for the two services we're injecting here there's no updates to main.ts required to get this component working properly.

Adding in the HTTP error handler

The final bit of Ben's demo is his exploration of services that should be created, but are not explicitly used by any components within the application. Now even though this class isn't injected into any components, it is injected so we need to create it just like the other services:

import {Injectable} from "angular2/core";  
import {ApiGateway} from "./apiGateway.service";

@Injectable()
export class HttpErrorHandler {

    constructor(
        private apiGateway: ApiGateway
    ) {
        apiGateway.errors$.subscribe(
            (value: any) => {
                console.group("HttpErrorHandler");
                console.log(value.status, "status code detected.");
                console.dir(value);
                console.groupEnd();
                // If the user made a request that they were not authorized
                // to, it's possible that their session has expired. Let's
                // refresh the page and let the server-side routing move the
                // user to a more appropriate landing page.
                if (value.status === 401) {
                    window.location.reload();
                }
            });
    }
}

Once we have that service in place, we need to update the main.ts file one last time to pull in the following items:

  1. The provide() function so we can set the actions associated with APP_INITIALIZER (here's a nice Throughtram article on DI)
  2. The HttpErrorHandler class we just created

With those changes here's the final version of main.ts:

import {provide, APP_INITIALIZER} from "angular2/core";  
import {bootstrap} from 'angular2/platform/browser';  
import {HTTP_PROVIDERS} from "angular2/http";

import {AppComponent} from './app.component';

import {ApiGateway} from "./apiGateway.service";  
import {FriendService} from "./friend.service";  
import {HttpErrorHandler} from "./HttpErrorHandler";

document.cookie = "XSRF-TOKEN=Dont-Tase-Me-Bro";

bootstrap(AppComponent, [  
    HTTP_PROVIDERS,
    ApiGateway,
    FriendService,
    HttpErrorHandler,
    //
    // Make sure our "unused" services are created via the
    //  APP_INITIALIZER token
    //
    provide(APP_INITIALIZER, {
        useFactory: (httpErrorHandler) => {
            console.info( "HttpErrorHandler initialized." );
        },
        deps: [HttpErrorHandler]
    })
]);
Wrapping up

As a .NET developer I always struggled with reading javascript code for the first time. The concepts aren't too alien, but the lack of types always made it really hard for me to quickly understand some piece of code. Don't even get me started on how many hours I've wasted chasing down strange bugs caused by a simple mis-spelling in my code. In short, for me Typescript is a fantastic development (it's not new I know), and makes Angular 2 so much more accessible for a .NET developer like me.

Here's a link to the final code - https://github.com/sstorie/experiments/tree/master/nadel-api-gateway-ts

I hope you found this article useful, and if so, check out the rest of Ben's series related to Angular 2. It's well worth the read.

comments powered by Disqus