0

I'm facing a problem with the HttpClient of Angular. I will like to know how to make a request with its replay shared to be repeated. Consider the following example code:

import { HttpClient } from '@angular/common/http';
import { Observable } from 'rxjs';
import { shareReplay } from 'rxjs/operators';

/** Node */
export class Node {

  /** Attributes */
  attributes$: Observable<any>;

  constructor(private http: HttpClient, private href: string) {
    this.attributes$ = http.get(href).pipe(shareReplay());
  }

  update(patches: any): Observable<any> {
    const req = this.http.patch(this.href, patches);

    // TODO: subscribe to request and update attributes

    return req;
  }
}

What I'm trying to do is to make the attributes$ observable notify a new value after the PATCH request has been sent to the resource.

Notice that the attributes$ observable only perform the first http request if someone subscribe to it. Accessing the attribute doesn't have any effect as it should be. This is an important feature I will want to keep.

Is there any way to do this?

Thanks in advance

yeiniel
  • 2,416
  • 15
  • 31
  • Separate the request and the result observable, like I show in https://stackoverflow.com/a/41554338/3001761 – jonrsharpe Jul 04 '18 at 16:55
  • @jonrsharpe I review the answer you referenced and I think is correct. With the difference that in this case there is no method to call to perform the first retrieval. The first retrieval is performed the first time the `attributes$` is acceded. May be you can write an answer here with this details in mind? I could answer it myself with the help you give me with the link but I prefer you to take the credit for it. Tank you – yeiniel Jul 04 '18 at 17:04

1 Answers1

0

I end up solving the problem so I will share with you my solution. First start saying that I approach the problem using TDD. So here I will post first the test suite I use

import { HttpClient } from '@angular/common/http';
import {
  HttpClientTestingModule,
  HttpTestingController
} from '@angular/common/http/testing';
import { TestBed, inject } from '@angular/core/testing';
import { bufferCount } from 'rxjs/operators';
import { zip } from 'rxjs';

import { Node } from './node';

const rootHref = '/api/nodes/root';
const rootChildrenHref = '/api/nodes/root/children';

const rootResource = {
  _links: {
    'node-has-children': { href: rootChildrenHref }
  }
};

function expectOneRequest(controller: HttpTestingController, href: string, method: string, body: any) {
  // The following `expectOne()` will match the request's URL and METHOD.
  // If no requests or multiple requests matched that URL
  // `expectOne()` would throw.
  const req = controller.expectOne({
    method,
    url: href
  });

  // Respond with mock data, causing Observable to resolve.
  // Subscribe callback asserts that correct data was returned.
  req.flush(body);
}

describe('CoreModule Node', () => {
  let httpTestingController: HttpTestingController;
  let subject: Node;

  beforeEach(() => {
    TestBed.configureTestingModule({
      imports: [ HttpClientTestingModule ]
    });
  });

  beforeEach(inject([ HttpClient, HttpTestingController ],
    (http: HttpClient, testingController: HttpTestingController) => {
      subject = new Node(http, rootHref);
      httpTestingController = testingController;
    }));

  afterEach(() => {
    // After every test, assert that there are no more pending requests.
    httpTestingController.verify();
  });

  it('should be created', () => {
    expect(subject).toBeTruthy();
  });

  it('#attributes$ is provided', () => {
    expect(subject.attributes$).toBeTruthy();
  });

  it('#attributes$ is observable element', (done: DoneFn) => {
    subject.attributes$.subscribe(root => {
      expect(root).toBeTruthy();
      done();
    });

    expectOneRequest(httpTestingController, rootHref, 'GET', rootResource);
  });

  it('#attributes$ observable is cached', (done: DoneFn) => {
    zip(subject.attributes$, subject.attributes$).subscribe(([root1, root2]) => {
      expect(root1).toBe(root2);
      done();
    });

    expectOneRequest(httpTestingController, rootHref, 'GET', rootResource);
  });

  it('#update() affect attributes$', (done: DoneFn) => {
    // the subscribe at the end of this pipe will trigger a children request
    // but the update() call will trigger a resource request that in turn
    // will trigger a children request. Therefore bufferCount will produce
    // an array of size two.

    subject.attributes$.pipe(bufferCount(2)).subscribe(collection => {
      expect<number>(collection.length).toEqual(2);
    });

    subject.update([]).subscribe(root => {
      expect(root).toBeTruthy();
      done();
    });

    expectOneRequest(httpTestingController, rootHref, 'GET', rootResource);
    expectOneRequest(httpTestingController, rootHref, 'PATCH', {});
    expectOneRequest(httpTestingController, rootHref, 'GET', rootResource);
  });

  it('#update() return observable', (done: DoneFn) => {
    subject.update([]).subscribe(root => {
      expect(root).toBeTruthy();
      done();
    });

    expectOneRequest(httpTestingController, rootHref, 'PATCH', {});
    expectOneRequest(httpTestingController, rootHref, 'GET', rootResource);
  });
});

As you can see the test suite verify that HTTP request calls are made just if someone subscribe to the observable as intended. With this test suite in place I come up with the following implementation of the Node class:

import { HttpClient } from '@angular/common/http';
import { Observable, Subject } from 'rxjs';
import { mergeMap, shareReplay } from 'rxjs/operators';

/** Node
 *
 * A node is the representation of an artifact on the Sorbotics Platform
 * Registry.
 */
export class Node {

  /** Attributes */
  public attributes$: Observable<any>;

  private attributesSubject$: Subject<any> = new Subject();

  private initialFetch = false;

  constructor(private http: HttpClient, private href: string) {

    // the attributes$ observable is a custom implementation that allow us to
    // perform the http request on the first subscription
    this.attributes$ = new Observable(subscriber => {
      /* the Node resource is fetched if this is the first subscription to the
         observable */
      if (!this.initialFetch) {
        this.initialFetch = true;

        this.fetchResource()
          .subscribe(resource => this.attributesSubject$.next(resource));
      }

      // connect this subscriber to the subject
      this.attributesSubject$
        .subscribe(resource => subscriber.next(resource));
    });
  }

  /* Fetch Node resource on the Platform Registry */
  private fetchResource: () => Observable<any> =
    () => this.http.get(this.href)

  /** Update node
   *
   * This method implement the update of the node attributes. Once the update
   * is performed successfully the attributes$ observable will push new values 
   * to subscribed parties.
   *
   * @param patches Set of patches that describe the update.
   */
  public update(patches: any): Observable<any> {
    const req = this.http.patch(this.href, patches)
      .pipe(mergeMap(() => this.fetchResource()), shareReplay(1));

    req.subscribe(resource => this.attributesSubject$.next(resource));

    return req;
  }
}

As you can se I learn from the answer referenced in the comment made by @jonrsharpe but introduce the use of a custom subscription handling at the observable. This way I can delay HTTP request until the first subscription is made.

yeiniel
  • 2,416
  • 15
  • 31