airbnb/caravel

View on GitHub
superset-frontend/src/hooks/apiResources/apiResources.ts

Summary

Maintainability
A
1 hr
Test Coverage
/**
 * Licensed to the Apache Software Foundation (ASF) under one
 * or more contributor license agreements.  See the NOTICE file
 * distributed with this work for additional information
 * regarding copyright ownership.  The ASF licenses this file
 * to you under the Apache License, Version 2.0 (the
 * "License"); you may not use this file except in compliance
 * with the License.  You may obtain a copy of the License at
 *
 *   http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing,
 * software distributed under the License is distributed on an
 * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
 * KIND, either express or implied.  See the License for the
 * specific language governing permissions and limitations
 * under the License.
 */

import { makeApi } from '@superset-ui/core';
import { useEffect, useMemo, useRef, useState } from 'react';

export enum ResourceStatus {
  Loading = 'loading',
  Complete = 'complete',
  Error = 'error',
}

/**
 * An object containing the data fetched from the API,
 * as well as loading and error info
 */
export type Resource<T> = LoadingState | CompleteState<T> | ErrorState;

// Trying out something a little different: a separate type per status.
// This should let TypeScript know whether a Resource has a result or error.
// It's possible that I'm expecting too much from TypeScript here.
// If this ends up causing problems, we can change the type to:
//
// export type Resource<T> = {
//   status: ResourceStatus;
//   result: null | T;
//   error: null | Error;
// }

type LoadingState = {
  status: ResourceStatus.Loading;
  result: null;
  error: null;
};

type CompleteState<T> = {
  status: ResourceStatus.Complete;
  result: T;
  error: null;
};

type ErrorState = {
  status: ResourceStatus.Error;
  result: null;
  error: Error;
};

const initialState: LoadingState = {
  status: ResourceStatus.Loading,
  result: null,
  error: null,
};

/**
 * A general-purpose hook to fetch the response from an endpoint.
 * Returns the full response body from the API, including metadata.
 *
 * Note: You likely want {useApiV1Resource} instead of this!
 *
 * TODO Store the state in redux or something, share state between hook instances.
 *
 * TODO Include a function in the returned resource object to refresh the data.
 *
 * A core design decision here is composition > configuration,
 * and every hook should only have one job.
 * Please address new needs with new hooks if possible,
 * rather than adding config options to this hook.
 *
 * @param endpoint The url where the resource is located.
 */
export function useApiResourceFullBody<RESULT>(
  endpoint: string,
): Resource<RESULT> {
  const [resource, setResource] = useState<Resource<RESULT>>(initialState);
  const cancelRef = useRef<() => void>(() => {});

  useEffect(() => {
    // If refresh is implemented, this will need to change.
    // The previous values should stay during refresh.
    setResource(initialState);

    // when this effect runs, the endpoint has changed.
    // cancel any current calls so that state doesn't get messed up.
    cancelRef.current();
    let cancelled = false;
    cancelRef.current = () => {
      cancelled = true;
    };

    const fetchResource = makeApi<{}, RESULT>({
      method: 'GET',
      endpoint,
    });

    fetchResource({})
      .then(result => {
        if (!cancelled) {
          setResource({
            status: ResourceStatus.Complete,
            result,
            error: null,
          });
        }
      })
      .catch(error => {
        if (!cancelled) {
          setResource({
            status: ResourceStatus.Error,
            result: null,
            error,
          });
        }
      });

    // Cancel the request when the component un-mounts
    return () => {
      cancelled = true;
    };
  }, [endpoint]);

  return resource;
}

/**
 * For when you want to transform the result of an api resource hook before using it.
 *
 * @param resource the Resource object returned from useApiV1Resource
 * @param transformFn a callback that transforms the result object into the shape you want.
 * Make sure to use a persistent function for this so it doesn't constantly recalculate!
 */
export function useTransformedResource<IN, OUT>(
  resource: Resource<IN>,
  transformFn: (result: IN) => OUT,
): Resource<OUT> {
  return useMemo(() => {
    if (resource.status !== ResourceStatus.Complete) {
      // While incomplete, there is no result - no need to transform.
      return resource;
    }
    try {
      return {
        ...resource,
        result: transformFn(resource.result),
      };
    } catch (e) {
      return {
        status: ResourceStatus.Error,
        result: null,
        error: e,
      };
    }
  }, [resource, transformFn]);
}

// returns the "result" field from a fetched API v1 endpoint
const extractInnerResult = <T>(responseBody: { result: T }) =>
  responseBody.result;

/**
 * A general-purpose hook to fetch a Superset resource from a v1 API endpoint.
 * Handles request lifecycle and async logic so you don't have to.
 *
 * This returns the data under the "result" field in the API response body.
 * If you need the full response body, use {useFullApiResource} instead.
 *
 * @param endpoint The url where the resource is located.
 */
export function useApiV1Resource<RESULT>(endpoint: string): Resource<RESULT> {
  return useTransformedResource(
    useApiResourceFullBody<{ result: RESULT }>(endpoint),
    extractInnerResult,
  );
}