Skip to content

Conversation

@suddjian
Copy link
Member

@suddjian suddjian commented Feb 18, 2021

SUMMARY

This introduces a new hook useApiV1Resource, which I want to put forward as an example of a new state management pattern. This lets a functional component fetch arbitrary api data with just a couple lines of code, with the entire async request lifecycle and component lifecycle taken care of. For now it uses local state, but we can change it to use any other form of storage without changing how the hook is called.

As a micro-POC, I've used the hook to refactor some code to lazy load chart owners instead of depending on bootstrap data. This will save quite a few joins in future stages of the Single Page App project.

TEST PLAN

Unit tests to be written before merge - seeking feedback on hook design first.

ADDITIONAL INFORMATION


function useChartOwnerNames(chartId: string) {
const { result } = useResourceTransform(
useApiV1Resource<Chart>(`/api/v1/chart/${chartId}`),
Copy link
Member

@dpgaspar dpgaspar Feb 18, 2021

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

note: If you want you can just select the columns you want by:
/api/v1/chart/501?q=(columns:!(owners.first_name,owners.last_name,owners.id),keys:!(none))

these parameters in JSON format are:

{
    "columns": [
        "owners.first_name",
        "owners.last_name",
        "owners.id"
    ],
    "keys": [
        "none"
    ]
}

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I expect if more of these requests are being made in the future, and we've changed the useApiResource hooks to use shared storage, it would actually be better for performance to request the entire object. That way if one hook wants owners and another hook wants creators, or name, or whatever, they can all get what they need with one request.

For now though, this is definitely a good add. Thanks for the tip!

@codecov-io
Copy link

codecov-io commented Feb 18, 2021

Codecov Report

Merging #13218 (1a0f976) into master (2ce7982) will increase coverage by 19.89%.
The diff coverage is 69.96%.

Impacted file tree graph

@@             Coverage Diff             @@
##           master   #13218       +/-   ##
===========================================
+ Coverage   53.06%   72.95%   +19.89%     
===========================================
  Files         489      576       +87     
  Lines       17314    20772     +3458     
  Branches     4482     5375      +893     
===========================================
+ Hits         9187    15155     +5968     
+ Misses       8127     5494     -2633     
- Partials        0      123      +123     
Flag Coverage Δ
cypress 58.43% <64.20%> (+5.37%) ⬆️
javascript 62.40% <58.01%> (?)

Flags with carried forward coverage won't be shown. Click here to find out more.

Impacted Files Coverage Δ
.../src/SqlLab/components/EstimateQueryCostButton.jsx 20.83% <ø> (ø)
...end/src/SqlLab/components/ExploreResultsButton.jsx 92.00% <ø> (+84.00%) ⬆️
...et-frontend/src/SqlLab/components/QueryHistory.jsx 50.00% <0.00%> (ø)
...end/src/SqlLab/components/RunQueryActionButton.tsx 64.28% <ø> (+11.50%) ⬆️
...erset-frontend/src/SqlLab/components/SouthPane.jsx 82.05% <ø> (+17.94%) ⬆️
...end/src/SqlLab/components/TemplateParamsEditor.jsx 23.80% <ø> (ø)
superset-frontend/src/chart/Chart.jsx 64.15% <0.00%> (+6.25%) ⬆️
superset-frontend/src/chart/ChartContainer.jsx 100.00% <ø> (ø)
superset-frontend/src/chart/ChartRenderer.jsx 77.02% <0.00%> (+0.31%) ⬆️
...rc/common/components/Collapse/Collapse.stories.tsx 0.00% <0.00%> (ø)
... and 602 more

Continue to review full report at Codecov.

Legend - Click here to learn more
Δ = absolute <relative> (impact), ø = not affected, ? = missing data
Powered by Codecov. Last update c787f46...0f3824e. Read the comment docs.

@ktmud
Copy link
Member

ktmud commented Feb 18, 2021

Hmm, interesting concept. We definitely lack some handy React hooks to fetch API resources. But I'd also like to point out the makeApi util already supports a processResponse option that allows you to transform the response.

It even infers the return type for you:

So we may be able to simplify the hooks a little bit. For example, useChartOwnerNames can be rewritten as:

function useAsyncData<T>(promise: Promise<T>) {
  const [result, setResult] = useState<{ data?: T; error": any }>();
  useEffect(() => {
    promise.then(data => setResult({ data }))
      .catch(error => setResult({ error }));
  }, [promise]);
  return { data, error };
}

function useChartOwnerNames(chartId: number) {
  const fetchChartOwnerNames = makeApi({
    method: 'GET',
    requestType: 'rison',
    endpoint: `/api/v1/chart/${chartId}`,
    processResponse: ({ result } : { result?: Chart }) =>
      results?.owners.map(owner =>
        `${owner.first_name || ''} ${owner.last_name || ''}`.strip() || owner.id) || [],
  );
  return useAsyncData(fetchChartOwnerNames({
    columns: ['owners.first_name', 'owners.last_name', 'owners.id']
  }));
}

If we add support for url match params for endpoint and default base payload (i.e. make column selection part of the base payload), we may even move fetchChartOwnerNames outside of the hook and have it become a general async API caller (and potentially export for other places to use).

@suddjian
Copy link
Member Author

suddjian commented Feb 18, 2021

@ktmud At first I did consider an approach like your useAsyncData example, but I found that there was more that could be done when using a url as a resource identifier instead of passing in an anonymous promise. This may not be obvious right now, since the hook doesn't yet have any extra bells and whistles attached, but this interface will allow us to add on some cool functionality later. Things like de-duplicating requests between components, utilizing local storage, caching results, streaming updates from the server or changes from elsewhere in the app, all without callers of the useApiResource hooks needing to change anything about their usage.

That's also why I opted not to use the processResponse param. I wanted to keep the core api fetching hook as single-minded as possible to maximize future de-duplication potential, and treat the transformation as a sort of composed view of that data.

I do wonder if those cool future add-ons might be better implemented inside the guts of makeApi, though 🤔

@ktmud
Copy link
Member

ktmud commented Feb 19, 2021

@suddjian I'm not against having hooks that do extra cool stuff. I think a powerful API resource manager would be very useful.

I do, however, believe there's value in hiding API endpoints and pre-defined transformations from end users and have a centralized, strongly typed API catalog like this.

makeApi naturally supports this, while the composable hooks approach may lead to more fragmentary code. There are also places where a general async function would be preferred, because hooks can only be used in functional components.

Re: dedupping requests and caching results, I do have some simple solution that I used in our internal Superset:

const promiseCache: Record<
  string,
  { timestamp: number; promise: Promise<any> }
> = {};

/**
 * Cache a promise maker, make sure it doesn't fire multiple times
 * @param api - the promise generator.
 * @param expires - cache TTL in seconds.
 * @param keyPrefix - prefix for the cache key.
 */
export function cached<T extends (...args: any[]) => Promise<any>>(
  api: T,
  {
    expires = 300,
    keyPrefix = '',
  }: {
    expires?: number;
    keyPrefix?: string;
  },
) {
  return function wrapped(...args: Parameters<T>): ReturnType<T> {
    const cacheKey = `${keyPrefix}-${JSON.stringify(args)}`;
    if (
      cacheKey in promiseCache &&
      promiseCache[cacheKey].timestamp + expires * 1000 > Date.now()
    ) {
      return promiseCache[cacheKey].promise as ReturnType<T>;
    }
    const promise = api(...args);
    promiseCache[cacheKey] = {
      promise,
      timestamp: Date.now(),
    };
    promise.catch(() => {
      // don't cache failed responses
      delete promiseCache[cacheKey];
    });
    return promise as ReturnType<T>;
  };
}

export const getValidMetrics = cached(
  makeApi<MinervaValidationApiPaylod, ControlValues['metrics']>({
    method: 'GET',
    endpoint: '/minerva/valid_metrics',
  }),
  { keyPrefix: 'metrics' },
);

It's a very simple wrapper on the generated API caller and handles both caching and deduplication (making the same API call will not fire a request twice).

For now, this is probably good enough for Superset, but if we are thinking of getting fancy with deduplication and caching by entity, I would also like to see us evaluating some existing solutions out there such as REST Hooks before committing to create our own solution for things like shared storage.

return () => {
cancelled = true;
};
}, [endpoint]);
Copy link
Member

@eschutho eschutho Feb 19, 2021

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm trying to follow the logic of this function, which is nested in a few other functions, but basically trying to see if we need this useEffect block. Do you expect this method to be called multiple times with the same endpoint string as an argument? And you're saying that if that happens, you don't want to fetch the resource again, but just return the original one?

Copy link
Member Author

@suddjian suddjian Feb 22, 2021

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That's exactly right! We do need to expect this function to be called multiple times with the same endpoint string, because hooks are called every time the component renders. But we don't want to fire off a new http request every time a render occurs. This useEffect block makes sure that a new request only fires if the caller requests a different resource (when the endpoint changes).

For reference see the React docs on building your own hooks https://reactjs.org/docs/hooks-custom.html

@suddjian
Copy link
Member Author

suddjian commented Feb 22, 2021

I do, however, believe there's value in hiding API endpoints and pre-defined transformations from end users and have a centralized, strongly typed API catalog like this.

makeApi naturally supports this, while the composable hooks approach may lead to more fragmentary code. There are also places where a general async function would be preferred, because hooks can only be used in functional components.

I like the catalog idea! That is very much compatible with this work. I'll add something like that for these hooks as well.

Almost all new frontend code is being written with function components, with some efforts underway to convert class components to functions, and it is also quite easy to write a HOC to patch in hook functionality to class components if needed, so relying on hooks for this sort of logic doesn't bother me at all. I'm actually pretty excited about the possibilities there.

For now, this is probably good enough for Superset, but if we are thinking of getting fancy with deduplication and caching by entity, I would also like to see us evaluating some existing solutions out there such as REST Hooks before committing to create our own solution for things like shared storage.

This is my first time reading about the REST hooks library, looks like a great option! I agree that we should wait to review it until we want to get fancy. Looks like it adds a few new abstractions, which may or may not be right for us when we reach that point. The hooks I've written here have a very simple interface so they should be easy to replace with a library later if we want to.

@eschutho
Copy link
Member

@suddjian are we still thinking of moving api responses into redux and how would that work with this?

export function useApiResourceFullBody<RESULT>(
endpoint: string,
): Resource<RESULT> {
const [resource, setResource] = useState<Resource<RESULT>>(initialState);
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@eschutho Moving state into Redux (or any other sort of provider) could be done by only modifying the internal logic of this hook. Specifically, by calling Redux's useSelector here instead of useState, and using actions instead of calling setResource.

You know what maybe I'll just do that now instead of talking about it 😁

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

awesome!

Copy link
Member Author

@suddjian suddjian Feb 23, 2021

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I implemented redux state management, but I'm not actually convinced that is the best way to share state between these hooks. So I've reverted it. But if you want to see what that looks like, the commit is in the PR history.

export function useChartOwnerNames(chartId: string) {
return useTransformedResource(
useApiV1Resource<Chart>(`/api/v1/chart/${chartId}?q=${ownerNamesQuery}`),
extractOwnerNames,
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

so on a high level, these two args are basically set up the request and then parse the response? I'm trying to see how digestible this code will be to beginners that are trying to use it, too. :)
Small nit, but should we move the url to the top of this file as a constant?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nm, I see that in your notes above..

Copy link
Member Author

@suddjian suddjian Feb 23, 2021

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I tried to use properly formatted doc comments on everything. If you browse the code in an IDE and mouse-over the use of one of these functions, it should tell you what's up

@junlincc junlincc added the SPA label Feb 23, 2021
@ktmud
Copy link
Member

ktmud commented Feb 23, 2021

Another library for consideration: https://react-query.tanstack.com/

@suddjian suddjian merged commit e11d0cb into master Feb 25, 2021
@suddjian suddjian deleted the api-resource-hooks branch February 25, 2021 22:41
@suddjian suddjian restored the api-resource-hooks branch February 25, 2021 22:41
@amitmiran137 amitmiran137 deleted the api-resource-hooks branch March 29, 2021 18:17
allanco91 pushed a commit to allanco91/superset that referenced this pull request May 21, 2021
…apache#13218)

* refactor: introduce api resource hooks, fix error owners

* comment

* vestigial comment

* not a function

* clarification

* vestigial comment

* filter the api for a leaner response

* reorganize code, add resource hook catalog

* better names

* implement state management in redux

* Revert "implement state management in redux"

This reverts commit d64d50a.

* add tests for hooks
@mistercrunch mistercrunch added 🏷️ bot A label used by `supersetbot` to keep track of which PR where auto-tagged with release labels 🚢 1.2.0 First shipped in 1.2.0 labels Mar 12, 2024
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

🏷️ bot A label used by `supersetbot` to keep track of which PR where auto-tagged with release labels preset-io size/L SPA 🚢 1.2.0 First shipped in 1.2.0

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Superset SPA] Remove usage of dashboard bootstrap data owners field from slices on the frontend

8 participants