31

I'm currently switching to the new HttpClient of Angular 4.3. One advantage is that I can specify a type info on the GET method and that the returned JSON is parsed into the given type, e.g.

this.http.get<Person> (url).subscribe(...)

But unfortunately, all dates in the JSON are parsed as numbers in the resulting object (probably because the Java Date objects are serialized as numbers in the backend).

With the old Http I used a reviver function when calling JSON.parse() like this:

this.http.get(url)
  .map(response => JSON.parse(response.text(), this.reviver))

and in the reviver function I created date objects from the numbers:

reviver (key, value): any {
  if (value !== null && (key === 'created' || key === 'modified'))
    return new Date(value);

  return value;
}

Is there a similar mechanism with the new HttpClient? Or what is the best practice to do conversion when the JSON is parsed?

msanford
  • 11,803
  • 11
  • 66
  • 93
Ralf Schneider
  • 651
  • 1
  • 8
  • 15

5 Answers5

31

This works for me:

import { Injectable } from '@angular/core';
import { HttpInterceptor, HttpRequest, HttpHandler, HttpEvent, HttpErrorResponse, HttpResponse } from '@angular/common/http';
import { Observable } from 'rxjs/Observable';

@Injectable()
export class ApiInterceptor implements HttpInterceptor {
  private dateRegex = /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2}):(\d{2}(?:\.\d*)?)$/;

  private utcDateRegex = /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2}):(\d{2}(?:\.\d*)?)Z$/;

  constructor() { }

  intercept(request: HttpRequest<any>, next: HttpHandler): Observable<HttpEvent<any>> {
    return next.handle(request)
      .pipe(
        tap((event: HttpEvent<any>) => {
          if (event instanceof HttpResponse) {
            this.convertDates(event.body);
          }
        }
    ));
  }

  private convertDates(object: Object) {
    if (!object || !(object instanceof Object)) {
      return;
    }

    if (object instanceof Array) {
      for (const item of object) {
        this.convertDates(item);
      }
    }

    for (const key of Object.keys(object)) {
      const value = object[key];

      if (value instanceof Array) {
        for (const item of value) {
          this.convertDates(item);
        }
      }

      if (value instanceof Object) {
        this.convertDates(value);
      }

      if (typeof value === 'string' && this.dateRegex.test(value)) {
        object[key] = new Date(value);
      }
    }
  }
}

The advantage of this over the answer of bygrace is that you don't need to parse to json yourself, you just convert the dates after angular is done with the parsing.

This also works with arrays and nested objects. I modified this it should support arrays.

Paul F. Wood
  • 1,453
  • 16
  • 19
Lerner
  • 1,111
  • 13
  • 19
  • I've added a new pattern without the 'T' and it worked for me! :) – JoaoPauloPaschoal Jan 13 '20 at 13:25
  • I got performance issue using this solution. When the response size is ~2MB, it took ~20 seconds to be done. While it took only ~3 seconds to be done when I use JSON.parse(JSON.stringify(res.body), reviver). – Phuc Apr 20 '22 at 08:27
24

Unfortunately there doesn't seem to be a way to pass a reviver to the JSON.parse method that is used within the Angular HttpClient. Here is the source code for where they call JSON.parse: https://github.com/angular/angular/blob/20e1cc049fa632e88dfeb00476455a41b7f42338/packages/common/http/src/xhr.ts#L189

Angular will only parse the response if the response type is set to "json" (you can see this on line 183). If you don't specify the response type then Angular defaults to "json" (https://github.com/angular/angular/blob/20e1cc049fa632e88dfeb00476455a41b7f42338/packages/common/http/src/request.ts#L112).

So you could use a response type of "text" and parse the json yourself. Or you could just be lazy and serialize and then deserialize the response (JSON.parse(JSON.stringify(res.body), reviver)).

Rather than modifying every call you could create an interceptor like the following:

json-interceptor.ts

import { Injectable } from '@angular/core';
import { HttpInterceptor, HttpRequest, HttpHandler, HttpEvent, HttpResponse, HttpErrorResponse } from '@angular/common/http';
import { Observable } from 'rxjs/Rx';
import 'rxjs/add/operator/map';

// https://github.com/angular/angular/blob/master/packages/common/http/src/xhr.ts#L18
const XSSI_PREFIX = /^\)\]\}',?\n/;

/**
 * Provide custom json parsing capabilities for api requests.
 * @export
 * @class JsonInterceptor
 */
@Injectable()
export class JsonInterceptor implements HttpInterceptor {

  /**
   * Custom http request interceptor
   * @public
   * @param {HttpRequest<any>} req
   * @param {HttpHandler} next
   * @returns {Observable<HttpEvent<any>>}
   * @memberof JsonInterceptor
   */
  public intercept(req: HttpRequest<any>, next: HttpHandler): Observable<HttpEvent<any>> {
    if (req.responseType !== 'json') {
      return next.handle(req);
    }
    // convert to responseType of text to skip angular parsing
    req = req.clone({
      responseType: 'text'
    });

    return next.handle(req).map(event => {
      // Pass through everything except for the final response.
      if (!(event instanceof HttpResponse)) {
        return event;
      }
      return this.processJsonResponse(event);
    });
  }

  /**
   * Parse the json body using custom revivers.
   * @private
   * @param {HttpResponse<string>} res
   * @returns{HttpResponse<any>}
   * @memberof JsonInterceptor
   */
  private processJsonResponse(res: HttpResponse<string>): HttpResponse<any> {
      let body = res.body;
      if (typeof body === 'string') {
        const originalBody = body;
        body = body.replace(XSSI_PREFIX, '');
        try {
          body = body !== '' ? JSON.parse(body, (key: any, value: any) => this.reviveUtcDate(key, value)) : null;
        } catch (error) {
          // match https://github.com/angular/angular/blob/master/packages/common/http/src/xhr.ts#L221
          throw new HttpErrorResponse({
            error: { error, text: originalBody },
            headers: res.headers,
            status: res.status,
            statusText: res.statusText,
            url: res.url || undefined,
          });
        }
      }
      return res.clone({ body });
  }

  /**
   * Detect a date string and convert it to a date object.
   * @private
   * @param {*} key json property key.
   * @param {*} value json property value.
   * @returns {*} original value or the parsed date.
   * @memberof JsonInterceptor
   */
  private reviveUtcDate(key: any, value: any): any {
      if (typeof value !== 'string') {
          return value;
      }
      if (value === '0001-01-01T00:00:00') {
          return null;
      }
      const match = /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2}):(\d{2}(?:\.\d*)?)Z$/.exec(value);
      if (!match) {
          return value;
      }
      return new Date(value);
  }
}

Then you have to provide it in your module:

*.module.ts

import { HTTP_INTERCEPTORS } from '@angular/common/http';
import { JsonInterceptor } from '...';

@NgModule({
    providers: [
        {
            provide: HTTP_INTERCEPTORS,
            useClass: JsonInterceptor,
            multi: true
        }
    ]
})
...

In the example I tried to mimic how angular was doing the parsing as much as possible. They don't seem to export HttpJsonParseError so I couldn't cast the error to that type. It probably isn't perfect but I hope it gets the idea across.

Here is a running example (look in the console to see the date parsed): https://stackblitz.com/edit/json-interceptor

I created a feature request here: https://github.com/angular/angular/issues/21079

bygrace
  • 5,868
  • 1
  • 31
  • 58
  • 29
    I can't believe there isn't a better way to implement a JSON API with date/times. – Ryan Dec 19 '17 at 23:31
  • 1
    Upvoted everything I could. It may help someone to know that this interceptor needs to be registered to work. This link helped me do so: https://medium.com/@ryanchenkie_40935/angular-authentication-using-the-http-client-and-http-interceptors-2f9d1540eb8 – evilkos Dec 21 '17 at 09:30
  • I've also found that after registering this interceptor, my global error handler stopped displaying the error message from the payload, because error responses are now not parsed. To fix this I needed to modify the end of the `intercept` method like so `return next.handle(req).catch((e, c) => { e.error = JSON.parse(e.error); return Observable.throw(e); }).map(event => { ... ` That's just a tip, can be improved obviously to also revive dates at least. – evilkos Dec 21 '17 at 09:32
  • @evilkos Good point. I added an example of registering the interceptor. – bygrace Dec 21 '17 at 12:08
  • @evilkos Are you saying that the HttpClient was parsing the errors as json for you? I don't see anything in their onError that would do that. Also the payload could be invalid json. So you may want to handle that in your global error handler. – bygrace Dec 21 '17 at 12:16
  • @bygrace Without the interceptor, the HttpClient was parsing the body AND the error, yes. I figured that from the error handling code which broke right after registering the interceptor. But maybe I'm wrong somehow, I haven't looked through their source code. And yep, the error could be invalid json, but so can be the body. – evilkos Dec 22 '17 at 08:19
  • For rxjs 6+ you have to use `pipe` when using `map`, like so: `next.handle(req).pipe(map(event => { ... }))` – Mendy Dec 03 '19 at 21:11
9

Similar to Jonas Stensved's answer, but using pipes:

import { map } from "rxjs/operators";

this.http.get(url)
  .pipe(
    map(response => {
      response.mydate = new Date(response.mydate);
      return response;
    })

Note the different import syntax of the map operator.

Pipes were introduced in RxJS 5.5. They facilitate import handling, code readability, and reduce bundle size. See Understanding Operator Imports.

Markus Pscheidt
  • 6,853
  • 5
  • 55
  • 76
7

You still can, but you need to import the map()-operator from rxjs like this:

import 'rxjs/add/operator/map';

Then you can, just as Diego pointed out, use the map like this:

return this.http.get<BlogPost>(url)
.map(x => {
x.published = new Date(String(x.published));
    return x;
})
[...]
Jonas Stensved
  • 14,378
  • 5
  • 51
  • 80
3

You can use:

this.http.get(url, { responseType: 'text' })
    .map(r => JSON.parse(r, this.reviver))
    .subscribe(...)

Update for rxjs 6+

this.http.get(url, { responseType: 'text' })
    .pipe(map(r => JSON.parse(r, this.reviver)))
    .subscribe(...)
Diego Maninetti
  • 365
  • 2
  • 10