Search code examples
angularcontentfulangular-transfer-state

Angular Universal with Contentful and TransferState


I am having some real issue with contentful and angular universal. I have tried so many things to try to get the content to load and be rendered in View Page Source but everything I have tried has failed. The closest I have got is with using the TransferState which I found with a tutorial covering API calls.

I created this class:

import { Injectable } from '@angular/core';
import { TransferState } from '@angular/platform-browser';
import { Observable, from } from 'rxjs';
import { HttpClient } from '@angular/common/http';
import { tap } from 'rxjs/operators';

@Injectable({
  providedIn: 'root',
})
export class TransferHttpService {
  constructor(
    private transferHttp: TransferState,
    private httpClient: HttpClient
  ) {}
  get(url, options?): Observable<any> {
    return this.getData(url, options, () => {
      return this.httpClient.get(url, options);
    });
  }
  post(url, body, options?): Observable<any> {
    return this.getData(url, options, () => {
      return this.httpClient.post(url, body, options);
    });
  }
  delete(url, options?): Observable<any> {
    return this.getData(url, options, () => {
      return this.httpClient.delete(url, options);
    });
  }
  put(url, body, options?): Observable<any> {
    return this.getData(url, options, () => {
      return this.httpClient.put(url, body, options);
    });
  }
  getData(url, options, callback: () => Observable<any>): Observable<any> {
    const optionsString = options ? JSON.stringify(options) : '';
    let key = `${url + optionsString}`;
    try {
      return this.resolveData(key);
    } catch (e) {
      console.log('In catch', key);
      return callback().pipe(
        tap((data) => {
          console.log('cache set', key);
          this.setCache(key, data);
        })
      );
    }
  }
  resolveData(key) {
    let resultData: any;
    if (this.hasKey(key)) {
      resultData = this.getFromCache(key);
      console.log('got cache', key);
    } else {
      throw new Error();
    }
    return from(Promise.resolve(resultData));
  }
  setCache(key, value) {
    this.transferHttp.set(key, value);
  }
  getFromCache(key) {
    return this.transferHttp.get(key, null); // null set as default value
  }
  hasKey(key) {
    return this.transferHttp.hasKey(key);
  }
}

And then where I injected HttpClient, I replaced with my new class (TransferHttpService) which works fine. When using contentful, I updated my old service to this:

import { Injectable } from '@angular/core';
import { createClient, Entry } from 'contentful';
import { documentToHtmlString } from '@contentful/rich-text-html-renderer';
import { Observable, from } from 'rxjs';

import { environment } from '@environments/environment';
import { Content, Page, Menu, MenuItem, Footer, Image } from '@models';
import { TransferHttpService } from './transfer-http.service';

@Injectable({
  providedIn: 'root',
})
export class ContentfulService {
  private client = createClient({
    space: environment.space,
    accessToken: environment.cdaAccessToken,
  });

  constructor(private http: TransferHttpService) {}

  getMetadata(): Observable<any> {
    return this.http.getData('metadata', null, () => {
      return from(
        this.client
          .getEntries({
            content_type: 'metadata',
          })
          .then((response) => response.items[0])
      );
    });
  }

  getPages(): Observable<Page[]> {
    return this.http.getData('pages', null, () => {
      return from(
        this.client
          .getEntries({
            content_type: 'page',
            include: 3,
          })
          .then((response) => {
            let pages = response.items.map((item) =>
              this.createPage(item, this.createContent)
            );
            return pages;
          })
      );
    });
  }

  getNavigation(): Observable<Menu> {
    return this.http.getData('navigation', null, () => {
      return from(
        this.client
          .getEntries({
            content_type: 'navigationMenu',
          })
          .then((response) => this.createMenu(response.items[0]))
      );
    });
  }

  getFooter(): Observable<Footer> {
    return this.http.getData('footer', null, () => {
      return from(
        this.client
          .getEntries({
            content_type: 'footer',
            include: 2,
          })
          .then((response) => this.createFooter(response.items[0]))
      );
    });
  }

  public createImage(component: any): Image {
    if (!component) return;
    return {
      title: component.fields.title,
      url: component.fields.file.url,
      alt: component.fields.file.fileName,
    };
  }

  private createFooter(component: any): Footer {
    return {
      title: component.fields.title,
      htmlContent: documentToHtmlString(component.fields.content),
      careersText: component.fields.careersText,
      careersLink: component.fields.careersLink,
      cookiePolicyLink: component.fields.cookiePolicyLink,
      privacyPolicyLink: component.fields.privacyPolicyLink,
      termsLink: component.fields.termsLink,
    };
  }

  private createMenu(menu: any): Menu {
    return {
      title: menu.fields.title,
      links: menu.fields.links.map(this.createMenuItem),
    };
  }

  private createMenuItem(item: any): MenuItem {
    return {
      path: item.fields.path,
      text: item.fields.linkText,
    };
  }

  private createPage(page: Entry<any>, createContent: any): Page {
    return {
      title: page.fields['title'],
      slug: page.fields['path'],
      linkText: page.fields['linkText'],
      content: page.fields['content'].map(createContent),
    };
  }

  private createContent(component: Entry<any>): Content {
    return {
      type: component.sys.contentType.sys.id,
      fields: component.fields,
    };
  }
}

As you can see I am executing the createClient getEntries promise to an observable after I have handled the response, which I then pass to the TransferHttpService to the getData method which is responsible for setting/getting the cached response.

I suspect this is where the issue lies but the header and footer always seem to be cached.


I have been playing around with this and I got better results when I changed my TransferHttpService to this:

import { Injectable, Inject, PLATFORM_ID } from '@angular/core';
import { HttpClient, HttpHeaders, HttpParams } from '@angular/common/http';
import {
  TransferState,
  StateKey,
  makeStateKey,
} from '@angular/platform-browser';
import { Observable, from } from 'rxjs';
import { tap } from 'rxjs/operators';
import { isPlatformBrowser, isPlatformServer } from '@angular/common';

@Injectable({ providedIn: 'root' })
export class TransferHttpService {
  constructor(
    protected transferState: TransferState,
    private httpClient: HttpClient,
    @Inject(PLATFORM_ID) private platformId: Object
  ) {}

  request<T>(
    method: string,
    uri: string | Request,
    options?: {
      body?: any;
      headers?:
        | HttpHeaders
        | {
            [header: string]: string | string[];
          };
      reportProgress?: boolean;
      observe?: 'response';
      params?:
        | HttpParams
        | {
            [param: string]: string | string[];
          };
      responseType?: 'json';
      withCredentials?: boolean;
    }
  ): Observable<T> {
    // tslint:disable-next-line:no-shadowed-variable
    return this.getData<T>(
      method,
      uri,
      options,
      (method: string, url: string, options: any) => {
        return this.httpClient.request<T>(method, url, options);
      }
    );
  }

  /**
   * Performs a request with `get` http method.
   */
  get<T>(
    url: string,
    options?: {
      headers?:
        | HttpHeaders
        | {
            [header: string]: string | string[];
          };
      observe?: 'response';
      params?:
        | HttpParams
        | {
            [param: string]: string | string[];
          };
      reportProgress?: boolean;
      responseType?: 'json';
      withCredentials?: boolean;
    }
  ): Observable<T> {
    // tslint:disable-next-line:no-shadowed-variable
    return this.getData<T>(
      'get',
      url,
      options,
      (_method: string, url: string, options: any) => {
        return this.httpClient.get<T>(url, options);
      }
    );
  }

  /**
   * Performs a request with `post` http method.
   */
  post<T>(
    url: string,
    body: any,
    options?: {
      headers?:
        | HttpHeaders
        | {
            [header: string]: string | string[];
          };
      observe?: 'response';
      params?:
        | HttpParams
        | {
            [param: string]: string | string[];
          };
      reportProgress?: boolean;
      responseType?: 'json';
      withCredentials?: boolean;
    }
  ): Observable<T> {
    // tslint:disable-next-line:no-shadowed-variable
    return this.getPostData<T>(
      'post',
      url,
      body,
      options,
      // tslint:disable-next-line:no-shadowed-variable
      (_method: string, url: string, body: any, options: any) => {
        return this.httpClient.post<T>(url, body, options);
      }
    );
  }

  /**
   * Performs a request with `put` http method.
   */
  put<T>(
    url: string,
    _body: any,
    options?: {
      headers?:
        | HttpHeaders
        | {
            [header: string]: string | string[];
          };
      observe?: 'body';
      params?:
        | HttpParams
        | {
            [param: string]: string | string[];
          };
      reportProgress?: boolean;
      responseType?: 'json';
      withCredentials?: boolean;
    }
  ): Observable<T> {
    // tslint:disable-next-line:no-shadowed-variable
    return this.getPostData<T>(
      'put',
      url,
      _body,
      options,
      (_method: string, url: string, _body: any, options: any) => {
        return this.httpClient.put<T>(url, _body, options);
      }
    );
  }

  /**
   * Performs a request with `delete` http method.
   */
  delete<T>(
    url: string,
    options?: {
      headers?:
        | HttpHeaders
        | {
            [header: string]: string | string[];
          };
      observe?: 'response';
      params?:
        | HttpParams
        | {
            [param: string]: string | string[];
          };
      reportProgress?: boolean;
      responseType?: 'json';
      withCredentials?: boolean;
    }
  ): Observable<T> {
    // tslint:disable-next-line:no-shadowed-variable
    return this.getData<T>(
      'delete',
      url,
      options,
      (_method: string, url: string, options: any) => {
        return this.httpClient.delete<T>(url, options);
      }
    );
  }

  /**
   * Performs a request with `patch` http method.
   */
  patch<T>(
    url: string,
    body: any,
    options?: {
      headers?:
        | HttpHeaders
        | {
            [header: string]: string | string[];
          };
      observe?: 'response';
      params?:
        | HttpParams
        | {
            [param: string]: string | string[];
          };
      reportProgress?: boolean;
      responseType?: 'json';
      withCredentials?: boolean;
    }
  ): Observable<T> {
    // tslint:disable-next-line:no-shadowed-variable
    return this.getPostData<T>(
      'patch',
      url,
      body,
      options,
      // tslint:disable-next-line:no-shadowed-variable
      (
        _method: string,
        url: string,
        body: any,
        options: any
      ): Observable<any> => {
        return this.httpClient.patch<T>(url, body, options);
      }
    );
  }

  /**
   * Performs a request with `head` http method.
   */
  head<T>(
    url: string,
    options?: {
      headers?:
        | HttpHeaders
        | {
            [header: string]: string | string[];
          };
      observe?: 'response';
      params?:
        | HttpParams
        | {
            [param: string]: string | string[];
          };
      reportProgress?: boolean;
      responseType?: 'json';
      withCredentials?: boolean;
    }
  ): Observable<T> {
    // tslint:disable-next-line:no-shadowed-variable
    return this.getData<T>(
      'head',
      url,
      options,
      (_method: string, url: string, options: any) => {
        return this.httpClient.head<T>(url, options);
      }
    );
  }

  /**
   * Performs a request with `options` http method.
   */
  options<T>(
    url: string,
    options?: {
      headers?:
        | HttpHeaders
        | {
            [header: string]: string | string[];
          };
      observe?: 'response';
      params?:
        | HttpParams
        | {
            [param: string]: string | string[];
          };
      reportProgress?: boolean;
      responseType?: 'json';
      withCredentials?: boolean;
    }
  ): Observable<T> {
    // tslint:disable-next-line:no-shadowed-variable
    return this.getData<T>(
      'options',
      url,
      options,
      // tslint:disable-next-line:no-shadowed-variable
      (_method: string, url: string, options: any) => {
        return this.httpClient.options<T>(url, options);
      }
    );
  }

  // tslint:disable-next-line:max-line-length
  getData<T>(
    method: string,
    uri: string | Request,
    options: any,
    callback: (
      method: string,
      uri: string | Request,
      options: any
    ) => Observable<any>
  ): Observable<T> {
    let url = uri;

    if (typeof uri !== 'string') {
      url = uri.url;
    }

    const tempKey = url + (options ? JSON.stringify(options) : '');
    const key = makeStateKey<T>(tempKey);
    try {
      return this.resolveData<T>(key);
    } catch (e) {
      console.log('in catch', key);
      return callback(method, uri, options).pipe(
        tap((data: T) => {
          if (isPlatformBrowser(this.platformId)) {
            // Client only code.
            // nothing;
          }
          if (isPlatformServer(this.platformId)) {
            console.log('set cache', key);
            this.setCache<T>(key, data);
          }
        })
      );
    }
  }

  private getPostData<T>(
    _method: string,
    uri: string | Request,
    body: any,
    options: any,
    callback: (
      method: string,
      uri: string | Request,
      body: any,
      options: any
    ) => Observable<any>
  ): Observable<T> {
    let url = uri;

    if (typeof uri !== 'string') {
      url = uri.url;
    }

    const tempKey =
      url +
      (body ? JSON.stringify(body) : '') +
      (options ? JSON.stringify(options) : '');
    const key = makeStateKey<T>(tempKey);

    try {
      return this.resolveData<T>(key);
    } catch (e) {
      return callback(_method, uri, body, options).pipe(
        tap((data: T) => {
          if (isPlatformBrowser(this.platformId)) {
            // Client only code.
            // nothing;
          }
          if (isPlatformServer(this.platformId)) {
            this.setCache<T>(key, data);
          }
        })
      );
    }
  }

  private resolveData<T>(key: StateKey<T>): Observable<T> {
    const data = this.getFromCache<T>(key);
    console.log(data);

    if (!data) {
      throw new Error();
    }

    if (isPlatformBrowser(this.platformId)) {
      console.log('get cache', key);
      // Client only code.
      this.transferState.remove(key);
    }
    if (isPlatformServer(this.platformId)) {
      console.log('we are the server');
      // Server only code.
    }

    return from(Promise.resolve<T>(data));
  }

  private setCache<T>(key: StateKey<T>, data: T): void {
    return this.transferState.set<T>(key, data);
  }

  private getFromCache<T>(key: StateKey<T>): T {
    return this.transferState.get<T>(key, null);
  }
}

after spending a few hours on this I have found that contentful only works when there is an API call to my server, which is odd.....


Argh! I swear, this should not be this difficult. If I have a component like this:

import { Component, OnInit } from '@angular/core';

import { Page } from '@models';
import { ContentfulService, TransferHttpService } from '@services';
import { environment } from '@environments/environment';

@Component({
  templateUrl: './pages.component.html',
  styleUrls: ['./pages.component.scss'],
})
export class PagesComponent implements OnInit {
  public pages: Page[];

  constructor(
    private brandService: TransferHttpService,
    private contentfulService: ContentfulService
  ) {}

  ngOnInit(): void {
    this.listPages();
    this.listBrands();
  }

  private listPages(): void {
    this.contentfulService
      .getPages()
      .subscribe((pages: any) => (this.pages = pages));
  }

  private listBrands(): void {
    this.brandService
      .get(`${environment.apiUrl}/brands/simple`)
      .subscribe((response: any) => {
        console.log(response);
      });
  }
}

when I run my local server npm run dev:ssr I get this in the console:

enter image description here

Which is perfect. Everything is in my View Page Source. BUT! If I remove the listBrands method (because it's not needed, I was just using it as a test). I get this:

enter image description here

Which is not good; nothing is cached and my View Page Source has no header, footer, page or metadata.....

I can't understand what is going on. Why does it work when I have another API call to a different server?


Solution

  • I managed to figure this out, contentful uses HttpClient but does give you the option of using an adaptor. It mentions axios but I don't really know what that is. I spent a good few hours playing around and found that I could use my own TransferHttpService in the adaptor like this:

    private client = createClient({
      space: environment.space,
      accessToken: environment.cdaAccessToken,
      adapter: async (config: any) => {
        config.adapter = null;
        const response = await this.http
          .request(config.method, `${config.baseURL}/${config.url}`, {
            headers: {
              Accept: config.headers['Accept'],
              Authorization: config.headers['Authorization'],
              'Content-Type': config.headers['Content-Type'],
              'X-Contentful-User-Agent':
                config.headers['X-Contentful-User-Agent'],
            },
            params: config.params,
          })
          .toPromise();
        return {
          data: response,
        };
      },
    });
    

    The TransferHttpService looks like this:

    import { Injectable, Inject, PLATFORM_ID } from '@angular/core';
    import { HttpClient, HttpHeaders, HttpParams } from '@angular/common/http';
    import {
      TransferState,
      StateKey,
      makeStateKey,
    } from '@angular/platform-browser';
    import { Observable, from } from 'rxjs';
    import { tap } from 'rxjs/operators';
    import { isPlatformBrowser, isPlatformServer } from '@angular/common';
    
    @Injectable({ providedIn: 'root' })
    export class TransferHttpService {
      constructor(
        protected transferState: TransferState,
        private httpClient: HttpClient,
        @Inject(PLATFORM_ID) private platformId: Object
      ) {}
    
      request<T>(
        method: string,
        uri: string | Request,
        options?: {
          body?: any;
          headers?:
            | HttpHeaders
            | {
                [header: string]: string | string[];
              };
          reportProgress?: boolean;
          observe?: 'response';
          params?:
            | HttpParams
            | {
                [param: string]: string | string[];
              };
          responseType?: 'json';
          withCredentials?: boolean;
        }
      ): Observable<T> {
        // tslint:disable-next-line:no-shadowed-variable
        return this.getData<T>(
          method,
          uri,
          options,
          (method: string, url: string, options: any) => {
            return this.httpClient.request<T>(method, url, options);
          }
        );
      }
    
      /**
       * Performs a request with `get` http method.
       */
      get<T>(
        url: string,
        options?: {
          headers?:
            | HttpHeaders
            | {
                [header: string]: string | string[];
              };
          observe?: 'response';
          params?:
            | HttpParams
            | {
                [param: string]: string | string[];
              };
          reportProgress?: boolean;
          responseType?: 'json';
          withCredentials?: boolean;
        }
      ): Observable<T> {
        // tslint:disable-next-line:no-shadowed-variable
        return this.getData<T>(
          'get',
          url,
          options,
          (_method: string, url: string, options: any) => {
            return this.httpClient.get<T>(url, options);
          }
        );
      }
    
      /**
       * Performs a request with `post` http method.
       */
      post<T>(
        url: string,
        body: any,
        options?: {
          headers?:
            | HttpHeaders
            | {
                [header: string]: string | string[];
              };
          observe?: 'response';
          params?:
            | HttpParams
            | {
                [param: string]: string | string[];
              };
          reportProgress?: boolean;
          responseType?: 'json';
          withCredentials?: boolean;
        }
      ): Observable<T> {
        // tslint:disable-next-line:no-shadowed-variable
        return this.getPostData<T>(
          'post',
          url,
          body,
          options,
          // tslint:disable-next-line:no-shadowed-variable
          (_method: string, url: string, body: any, options: any) => {
            return this.httpClient.post<T>(url, body, options);
          }
        );
      }
    
      /**
       * Performs a request with `put` http method.
       */
      put<T>(
        url: string,
        _body: any,
        options?: {
          headers?:
            | HttpHeaders
            | {
                [header: string]: string | string[];
              };
          observe?: 'body';
          params?:
            | HttpParams
            | {
                [param: string]: string | string[];
              };
          reportProgress?: boolean;
          responseType?: 'json';
          withCredentials?: boolean;
        }
      ): Observable<T> {
        // tslint:disable-next-line:no-shadowed-variable
        return this.getPostData<T>(
          'put',
          url,
          _body,
          options,
          (_method: string, url: string, _body: any, options: any) => {
            return this.httpClient.put<T>(url, _body, options);
          }
        );
      }
    
      /**
       * Performs a request with `delete` http method.
       */
      delete<T>(
        url: string,
        options?: {
          headers?:
            | HttpHeaders
            | {
                [header: string]: string | string[];
              };
          observe?: 'response';
          params?:
            | HttpParams
            | {
                [param: string]: string | string[];
              };
          reportProgress?: boolean;
          responseType?: 'json';
          withCredentials?: boolean;
        }
      ): Observable<T> {
        // tslint:disable-next-line:no-shadowed-variable
        return this.getData<T>(
          'delete',
          url,
          options,
          (_method: string, url: string, options: any) => {
            return this.httpClient.delete<T>(url, options);
          }
        );
      }
    
      /**
       * Performs a request with `patch` http method.
       */
      patch<T>(
        url: string,
        body: any,
        options?: {
          headers?:
            | HttpHeaders
            | {
                [header: string]: string | string[];
              };
          observe?: 'response';
          params?:
            | HttpParams
            | {
                [param: string]: string | string[];
              };
          reportProgress?: boolean;
          responseType?: 'json';
          withCredentials?: boolean;
        }
      ): Observable<T> {
        // tslint:disable-next-line:no-shadowed-variable
        return this.getPostData<T>(
          'patch',
          url,
          body,
          options,
          // tslint:disable-next-line:no-shadowed-variable
          (
            _method: string,
            url: string,
            body: any,
            options: any
          ): Observable<any> => {
            return this.httpClient.patch<T>(url, body, options);
          }
        );
      }
    
      /**
       * Performs a request with `head` http method.
       */
      head<T>(
        url: string,
        options?: {
          headers?:
            | HttpHeaders
            | {
                [header: string]: string | string[];
              };
          observe?: 'response';
          params?:
            | HttpParams
            | {
                [param: string]: string | string[];
              };
          reportProgress?: boolean;
          responseType?: 'json';
          withCredentials?: boolean;
        }
      ): Observable<T> {
        // tslint:disable-next-line:no-shadowed-variable
        return this.getData<T>(
          'head',
          url,
          options,
          (_method: string, url: string, options: any) => {
            return this.httpClient.head<T>(url, options);
          }
        );
      }
    
      /**
       * Performs a request with `options` http method.
       */
      options<T>(
        url: string,
        options?: {
          headers?:
            | HttpHeaders
            | {
                [header: string]: string | string[];
              };
          observe?: 'response';
          params?:
            | HttpParams
            | {
                [param: string]: string | string[];
              };
          reportProgress?: boolean;
          responseType?: 'json';
          withCredentials?: boolean;
        }
      ): Observable<T> {
        // tslint:disable-next-line:no-shadowed-variable
        return this.getData<T>(
          'options',
          url,
          options,
          // tslint:disable-next-line:no-shadowed-variable
          (_method: string, url: string, options: any) => {
            return this.httpClient.options<T>(url, options);
          }
        );
      }
    
      // tslint:disable-next-line:max-line-length
      getData<T>(
        method: string,
        uri: string | Request,
        options: any,
        callback: (
          method: string,
          uri: string | Request,
          options: any
        ) => Observable<any>
      ): Observable<T> {
        let url = uri;
    
        if (typeof uri !== 'string') {
          url = uri.url;
        }
    
        const tempKey = url + (options ? JSON.stringify(options) : '');
        const key = makeStateKey<T>(tempKey);
        try {
          return this.resolveData<T>(key);
        } catch (e) {
          //console.log('in catch', key);
          return callback(method, uri, options).pipe(
            tap((data: T) => {
              if (isPlatformBrowser(this.platformId)) {
                // Client only code.
                // nothing;
              }
              if (isPlatformServer(this.platformId)) {
                //console.log('set cache', key);
                this.setCache<T>(key, data);
              }
            })
          );
        }
      }
    
      private getPostData<T>(
        _method: string,
        uri: string | Request,
        body: any,
        options: any,
        callback: (
          method: string,
          uri: string | Request,
          body: any,
          options: any
        ) => Observable<any>
      ): Observable<T> {
        let url = uri;
    
        if (typeof uri !== 'string') {
          url = uri.url;
        }
    
        const tempKey =
          url +
          (body ? JSON.stringify(body) : '') +
          (options ? JSON.stringify(options) : '');
        const key = makeStateKey<T>(tempKey);
    
        try {
          return this.resolveData<T>(key);
        } catch (e) {
          return callback(_method, uri, body, options).pipe(
            tap((data: T) => {
              if (isPlatformBrowser(this.platformId)) {
                // Client only code.
                // nothing;
              }
              if (isPlatformServer(this.platformId)) {
                this.setCache<T>(key, data);
              }
            })
          );
        }
      }
    
      private resolveData<T>(key: StateKey<T>): Observable<T> {
        const data = this.getFromCache<T>(key);
    
        if (!data) {
          throw new Error();
        }
    
        if (isPlatformBrowser(this.platformId)) {
          //console.log('get cache', key);
          // Client only code.
          this.transferState.remove(key);
        }
        if (isPlatformServer(this.platformId)) {
          //console.log('we are the server');
          // Server only code.
        }
    
        return from(Promise.resolve<T>(data));
      }
    
      private setCache<T>(key: StateKey<T>, data: T): void {
        return this.transferState.set<T>(key, data);
      }
    
      private getFromCache<T>(key: StateKey<T>): T {
        return this.transferState.get<T>(key, null);
      }
    }
    

    And that was it; once I had done that, everything started working.