d1g1tinc/fairlight

View on GitHub
src/api/request-manager/index.tsx

Summary

Maintainability
A
1 hr
Test Coverage
import Observable from 'zen-observable'
import PushStream from 'zen-push'

import {DEFAULT_FETCH_POLICY, DEFAULT_REQUEST_METHOD} from '../constants'
import {ApiError} from '../errors'
import {GenericCache} from '../generic-cache'
import {apiRequestId, applyHeaders, cloneHeaders} from '../lib'
import {ApiRequestFetcher} from '../request-fetcher'
import {
  ApiHeaders,
  ApiParseResponseJson,
  ApiRequestMethod,
  ApiRequestOptions,
  ApiRequestParams,
  ApiSerializeRequestJson,
  RequestBody,
  RequestFetcher,
  RequestFetcherResponse,
  ResponseBody
} from '../typings'

function identity<T>(value: T): T {
  return value
}

/**
 * The request manager is responsible for managing API requests.
 *
 * Responsibilities include:
 * - Implementing `getRequestBody` which is called by `Api`
 * - Caching in-progress GET requests
 * - Detecting a JSON request body by stringifying and setting the appropriate Content-Type headers
 * - Serializing / parsing JSON requests & responses
 */
export class ApiRequestManager {
  baseUrl: string

  private responseBodyStream = new PushStream<
    [ApiRequestParams, ResponseBody]
  >()

  private errorStream = new PushStream<Error>()

  private requestFetcher: RequestFetcher

  private serializeRequestJson: ApiSerializeRequestJson

  private parseResponseJson: ApiParseResponseJson

  private defaultHeaders: ApiHeaders = {}

  private inProgressRequestCache = new GenericCache<{
    id: symbol
    requestPromise: Promise<ResponseBody | null>
  }>()

  constructor(params: {
    /**
     * Base URL of API to prefix all requests with
     */
    baseUrl?: string
    /**
     * When provided, all API JSON request bodies will be run
     * through this transformation function before the API request
     */
    serializeRequestJson?: ApiSerializeRequestJson
    /**
     * When provided, all API JSON response bodies will be run
     * through this transformation function before returning the response
     */
    parseResponseJson?: ApiParseResponseJson
    requestFetcher?: RequestFetcher
  }) {
    this.baseUrl = params.baseUrl || ''
    this.requestFetcher = params.requestFetcher || new ApiRequestFetcher()
    this.serializeRequestJson = params.serializeRequestJson || identity
    this.parseResponseJson = params.parseResponseJson || identity
  }

  /**
   * Returns a promise that resolves with the response body.
   *
   * If there is an in-progress request, and `options.deduplicate` is `true`,
   * it will return the same promise.
   *
   * On a successful fetch, it will cache the response unless `fetchPolicy` is `'no-cache'`
   */
  getResponseBody = <TResponseBody extends ResponseBody>(
    params: ApiRequestParams<ApiRequestMethod, TResponseBody>,
    options: ApiRequestOptions
  ): Promise<TResponseBody | null> => {
    const paramsKey = apiRequestId(params)

    // return cached promise if it exists
    const cachedRequest = this.inProgressRequestCache.get(paramsKey)
    const deduplicate = options.deduplicate ?? defaultDeduplicate(params)
    if (cachedRequest && deduplicate) {
      // if user hasn't explicitly requested a new fetch,
      // return the in-progress fetch promise
      return cachedRequest.requestPromise as Promise<TResponseBody>
    }

    const id = Symbol()
    const fetchPromise = this.createRequestPromise(params, options, id)

    this.inProgressRequestCache.set(paramsKey, {
      id,
      requestPromise: fetchPromise
    })

    return fetchPromise as Promise<TResponseBody>
  }

  private async createRequestPromise(
    params: ApiRequestParams,
    options: ApiRequestOptions,
    id: symbol
  ): Promise<ResponseBody | null> {
    const paramsId = apiRequestId(params)

    try {
      return await this.fetchResponseBody(params, options)
    } finally {
      const cachedRequest = this.inProgressRequestCache.get(paramsId)

      if (cachedRequest && cachedRequest.id === id) {
        this.inProgressRequestCache.del(paramsId)
      }
    }
  }

  /**
   * Configuring an error handler to be called on error
   */
  get onReceivedResponseBody(): Observable<[ApiRequestParams, ResponseBody]> {
    return this.responseBodyStream.observable
  }

  /**
   * Configuring an error handler to be called on error
   */
  get onError(): Observable<Error> {
    return this.errorStream.observable
  }

  /**
   * Returns `true` if a `GET` request matches params
   */
  requestInProgress = (params: ApiRequestParams): boolean => {
    return this.inProgressRequestCache.has(apiRequestId(params))
  }

  /**
   * Set default headers to be passed to all API requests.
   * Useful for setting an authentication token.
   *
   * @param key Header key
   * @param value Header value
   */
  setDefaultHeader = (key: string, value: string): void => {
    this.defaultHeaders = applyHeaders(this.defaultHeaders, {[key]: value})
  }

  private fetchResponseBody = async (
    params: ApiRequestParams,
    options: ApiRequestOptions
  ): Promise<ResponseBody | null> => {
    const {fetchPolicy = DEFAULT_FETCH_POLICY} = options
    try {
      const {body, headers} = this.getRequestHeadersAndBody(params)

      const response = await this.requestFetcher.getResponse({
        method: params.method || DEFAULT_REQUEST_METHOD,
        url: `${this.baseUrl}${params.url}`,
        body,
        headers,
        responseType: params.responseType,
        successCodes: params.successCodes
      })

      const parsedResponse: RequestFetcherResponse = {
        ...response,
        body:
          response.bodyType === 'json'
            ? this.parseResponseJson(response.body as object, params)
            : response.body
      }

      this.maybeThrowApiError(params, parsedResponse)

      const {body: responseBody} = parsedResponse

      if (fetchPolicy !== 'no-cache') {
        this.responseBodyStream.next([params, responseBody as ResponseBody])
      }

      return responseBody
    } catch (error) {
      this.errorStream.next(error)
      throw error
    }
  }

  /**
   * Modifies request headers and body to use for the request.
   * - Headers are merged with any defined default headers.
   * - For JSON request bodies, it is serialized to a string and
   *   sets the appropriate 'Content-Type' header.
   */
  private getRequestHeadersAndBody(
    params: ApiRequestParams
  ): {headers: ApiHeaders; body: BodyInit | undefined} {
    let headers: ApiHeaders = cloneHeaders(this.defaultHeaders)

    if (params.headers) {
      headers = applyHeaders(headers, params.headers)
    }

    let body: BodyInit | undefined

    if (
      params.method &&
      ['POST', 'PATCH', 'PUT', 'DELETE'].includes(params.method)
    ) {
      const paramBody = (params as {body: RequestBody}).body

      if (
        paramBody &&
        ![Blob, FormData, URLSearchParams, ReadableStream].some(
          (bodyType) => paramBody instanceof bodyType
        ) &&
        typeof paramBody !== 'string'
      ) {
        // prepare JSON body and add header
        body = JSON.stringify(
          this.serializeRequestJson(paramBody as object, params)
        )
        headers['content-type'] = 'application/json'
      } else {
        body = paramBody as BodyInit
      }
    }

    return {headers, body}
  }

  /**
   * Throws `ApiError` if the response status does not match a passed success code.
   * If no success codes are passed and the fetch response is not 'ok', throws `ApiError`
   */
  private maybeThrowApiError(
    params: ApiRequestParams,
    {status, body, bodyType}: RequestFetcherResponse
  ): void {
    if (Array.isArray(params.successCodes)) {
      if (params.successCodes.every((code) => status !== code)) {
        throw new ApiError(
          params.method ?? DEFAULT_REQUEST_METHOD,
          params.url,
          status,
          body,
          bodyType
        )
      }
    } else if (status < 200 || status > 299) {
      throw new ApiError(
        params.method ?? DEFAULT_REQUEST_METHOD,
        params.url,
        status,
        body,
        bodyType
      )
    }
  }
}

function defaultDeduplicate(params: ApiRequestParams) {
  return params.method === 'GET' ? true : false
}