Skip to content

Generate SWR hooks from OpenAPI schemas

License

Notifications You must be signed in to change notification settings

reside-eng/swr-openapi

 
 

Repository files navigation

swr-openapi

Generate swr hooks using OpenAPI schemas

npm license

Installation

npm install swr-openapi swr openapi-fetch

Setup

Follow openapi-typescript directions to generate TypeScript definitions for each service being used.

Here is an example of types being generated for a service via the command line:

npx openapi-typescript "https://sandwiches.example/openapi/json" --output ./types/sandwich-schema.ts

Initialize an openapi-fetch client and create any desired hooks.

// sandwich-api.ts
import createClient from "openapi-fetch";
import { createQueryHook } from "swr-openapi";
import type { paths as SandwichPaths } from "./types/sandwich-schema";

const client = createClient<SandwichPaths>(/* ... */);

const useSandwiches = createQueryHook(client, "sandwich-api");

const { data, error, isLoading, isValidating, mutate } = useSandwiches(
  "/sandwich/{id}", // <- Fully typed paths!
  {
    params: {
      path: {
        id: "123", // <- Fully typed params!
      },
    },
  },
);

Wrapper hooks are provided 1:1 for each hook exported by SWR.

API Reference

Hook Builders

import createClient from "openapi-fetch";

import {
  createQueryHook,
  createImmutableHook,
  createInfiniteHook,
  createMutateHook,
} from "swr-openapi";

import { paths as SomeApiPaths } from "./some-api";

const client = createClient<SomeApiPaths>(/* ... */);
const prefix = "some-api";

export const useQuery = createQueryHook(client, prefix);
export const useImmutable = createImmutableHook(client, prefix);
export const useInfinite = createInfiniteHook(client, prefix);
export const useMutate = createMutateHook(
  client,
  prefix,
  _.isMatch, // Or any comparision function
);

Parameters

Each builder hook accepts the same initial parameters:

  • client: An OpenAPI fetch client.
  • prefix: A prefix unique to the fetch client.

createMutateHook also accepts a third parameter:

  • compare: A function to compare fetch options).

Returns

useQuery

This hook is a typed wrapper over useSWR.

const useQuery = createQueryHook(/* ... */);

const { data, error, isLoading, isValidating, mutate } = useQuery(
  path,
  init,
  config,
);
How useQuery works

useQuery is a very thin wrapper over useSWR. Most of the code involves TypeScript generics that are transpiled away.

The prefix supplied in createQueryHook is joined with path and init to form the key passed to SWR.

prefix is only used to help ensure uniqueness for SWR's cache, in the case that two or more API clients share an identical path (e.g. /api/health). It is not included in actual GET requests.

Then, GET is invoked with path and init. Short and sweet.

function useQuery(path, ...[init, config]) {
  return useSWR(
    init !== null ? [prefix, path, init] : null,
    async ([_prefix, path, init]) => {
      const res = await client.GET(path, init);
      if (res.error) {
        throw res.error;
      }
      return res.data;
    },
    config,
  );
}

Parameters

Returns

useImmutable

This hook has the same contracts as useQuery. However, instead of wrapping useSWR, it wraps useSWRImmutable. This immutable hook disables automatic revalidations but is otherwise identical to useSWR.

const useImmutable = createImmutableHook(/* ... */);

const { data, error, isLoading, isValidating, mutate } = useImmutable(
  path,
  init,
  config,
);

Parameters

Identical to useQuery parameters.

Returns

Identical to useQuery returns.

useInfinite

This hook is a typed wrapper over useSWRInfinite.

const useInfinite = createInfiniteHook(/* ... */);

const { data, error, isLoading, isValidating, mutate, size, setSize } =
  useInfinite(path, getInit, config);
How useInfinite works

Just as useQuery is a thin wrapper over useSWR, useInfinite is a thin wrapper over useSWRInfinite.

Instead of using static fetch options as part of the SWR key, useInfinite is given a function (getInit) that should dynamically determines the fetch options based on the current page index and the data from a previous page.

function useInfinite(path, getInit, config) {
  const fetcher = async ([_, path, init]) => {
    const res = await client.GET(path, init);
    if (res.error) {
      throw res.error;
    }
    return res.data;
  };
  const getKey = (index, previousPageData) => {
    const init = getInit(index, previousPageData);
    if (init === null) {
      return null;
    }
    const key = [prefix, path, init];
    return key;
  };
  return useSWRInfinite(getKey, fetcher, config);
}

Parameters

  • path: Any endpoint that supports GET requests.
  • getInit: A function that returns the fetch options for a given page (learn more).
  • config: (optional) SWR infinite options.

Returns

getInit

This function is similar to the getKey parameter accepted by useSWRInfinite, with some slight alterations to take advantage of Open API types.

Parameters

  • pageIndex: The zero-based index of the current page to load.
  • previousPageData:
    • undefined (if on the first page).
    • The fetched response for the last page retrieved.

Returns

  • Fetch options for the next page to load.
  • null if no more pages should be loaded.

Examples

Example using limit and offset
useInfinite("/something", (pageIndex, previousPageData) => {
  // No more pages
  if (previousPageData && !previousPageData.hasMore) {
    return null;
  }

  // First page
  if (!previousPageData) {
    return {
      params: {
        query: {
          limit: 10,
        },
      },
    };
  }

  // Next page
  return {
    params: {
      query: {
        limit: 10,
        offset: 10 * pageIndex,
      },
    },
  };
});
Example using cursors
useInfinite("/something", (pageIndex, previousPageData) => {
  // No more pages
  if (previousPageData && !previousPageData.nextCursor) {
    return null;
  }

  // First page
  if (!previousPageData) {
    return {
      params: {
        query: {
          limit: 10,
        },
      },
    };
  }

  // Next page
  return {
    params: {
      query: {
        limit: 10,
        cursor: previousPageData.nextCursor,
      },
    },
  };
});

useMutate

useMutate is a wrapper around SWR's global mutate function. It provides a type-safe mechanism for updating and revalidating SWR's client-side cache for specific endpoints.

Like global mutate, this mutate wrapper accepts three parameters: key, data, and options. The latter two parameters are identical to those in bound mutate. key can be either a path alone, or a path with fetch options.

The level of specificity used when defining the key will determine which cached requests are updated. If only a path is provided, any cached request using that path will be updated. If fetch options are included in the key, the compare function will determine if a cached request's fetch options match the key's fetch options.

const mutate = useMutate();

await mutate([path, init], data, options);
How useMutate works
function useMutate() {
  const { mutate } = useSWRConfig();
  return useCallback(
    ([path, init], data, opts) => {
      return mutate(
        (key) => {
          if (!Array.isArray(key) || ![2, 3].includes(key.length)) {
            return false;
          }
          const [keyPrefix, keyPath, keyOptions] = key;
          return (
            keyPrefix === prefix &&
            keyPath === path &&
            (init ? compare(keyOptions, init) : true)
          );
        },
        data,
        opts,
      );
    },
    [mutate, prefix, compare],
  );
}

Parameters

  • key:
    • path: Any endpoint that supports GET requests.
    • init: (optional) Partial fetch options for the chosen endpoint.
  • data: (optional)
    • Data to update the client cache.
    • An async function for a remote mutation.
  • options: (optional) SWR mutate options.

Returns

  • A promise containing an array, where each array item is either updated data for a matched key or undefined.

SWR's mutate signature specifies that when a matcher function is used, the return type will be an array. Since our wrapper uses a key matcher function, it will always return an array type.

compare

When calling createMutateHook, a function must be provided with the following contract:

type Compare = (init: any, partialInit: object) => boolean;

This function is used to determine whether or not a cached request should be updated when mutate is called with fetch options.

My personal recommendation is to use lodash's isMatch:

Performs a partial deep comparison between object and source to determine if object contains equivalent property values.

const useMutate = createMutateHook(client, "<unique-key>", isMatch);

const mutate = useMutate();

await mutate([
  "/path",
  {
    params: {
      query: {
        version: "beta",
      },
    },
  },
]);

// ✅ Would be updated
useQuery("/path", {
  params: {
    query: {
      version: "beta",
    },
  },
});

// ✅ Would be updated
useQuery("/path", {
  params: {
    query: {
      version: "beta",
      other: true,
      example: [1, 2, 3],
    },
  },
});

// ❌ Would not be updated
useQuery("/path", {
  params: {
    query: {},
  },
});

// ❌ Would not be updated
useQuery("/path");

// ❌ Would not be updated
useQuery("/path", {
  params: {
    query: {
      version: "alpha",
    },
  },
});

// ❌ Would not be updated
useQuery("/path", {
  params: {
    query: {
      different: "items",
    },
  },
});

Footnotes

  1. When an endpoint has required params, init will be required, otherwise init will be optional.

About

Generate SWR hooks from OpenAPI schemas

Resources

License

Stars

Watchers

Forks

Releases

No releases published

Packages

No packages published

Languages

  • TypeScript 97.6%
  • JavaScript 2.4%