Announcing our $30M series A round. We are featured on Forbes

From OpenAPI to Type-Safe React-Query Hooks

Fabien Bernard
Fabien Bernard
Senior TypeScript EngineerApril 12, 2022 | 9 Minute Read

The Xata engineering team values predictability at all levels of our codebase. This blog post is about how we handle API communications in a predictable way.

Motivation

To us at Xata engineering, we consider spending time reverse engineering APIs just to know what kind of data is available to be neither fun nor valuable. Instead, through OpenAPI we get some insights into the possibilities exposed by some APIs. However, why stop there? Reading an OpenAPI spec and manually writing code is… let's say, also not our thing.

Of course, some libraries exist to generate types from OpenAPI, but then we'd still need to manually connect them to react-query or similar fetchers, all while the generated types aren't very thorough in most cases. Even worse, in case of circular dependencies in a given OpenAPI spec, this becomes less of an option for us.

Given these constraints, we saw potential for innovation. We had to do something! And this is why we started to craft openapi-codegen.

The goals were clear:

  1. Generate human-readable types, so we can review TypesScript files instead of a large OpenAPI YAML file.
  2. Embed all possible documentation, so we can have access to the documentation directly through IntelliSense.
  3. Generate ready-to-use React Hooks, with url and types baked into the component to avoid any human mistakes.
  4. Be as flexible as possible: so far we can generate fetchers & react-query hooks, but let's not restrict the possibilities.

Let's Play

To understand some of the superpowers this generator gives you, let's create a React application that consumes the Github API.

Initialize the Project

For simplicity, let's use vitejs to bootstrap our project!

$ npm create vite@latest

We'll select the framework react with the variant react-ts, this should give us a quick working environment for our application.

Now, let's start the magic!

$ npm i -D @openapi-codegen/{cli,typescript}
$ npx openapi-codegen init
  • Select Url
  • Paste the giphy OpenAPI url: https://api.apis.guru/v2/specs/github.com/1.1.4/openapi.yaml
  • Enter github as namespace
  • Select react-query components
  • Enter src/github as folder

Execute npx openapi-codegen gen github and voilà!

Inspect What We Have

Let's take a bit of time to inspect what we have in ./src/github. The more interesting file is GithubComponents.ts, this is where all our react-query components and fetchers are generated and also our entry point. We have some generated types in Github{Parameters,Schemas,Responses}. These are a 1-1 mapping with what we have in the OpenAPI spec.

To finish, we have two special files: GithubFetcher.ts and GithubContext.ts. These files are generated only once and can be modified/extended to fit your needs.

In GithubFetcher.ts you can:

  • inject the baseUrl (a default is provided)
  • deal with authentication
  • tweak how query strings are handled, especially if you have arrays

and in GithubContext.ts you can:

  • tweak how react-query cache keys are handled (queryKeyFn)
  • inject runtime values (hooks) to the fetcher (fetcherOptions)
  • disable query fetching (queryOptions)

Setup react-query

Of course, so far, we don't even have react-query installed, let's do this and setup our application.

$ npm i react-query

Add the queryClient

// App.tsx
import { QueryClient, QueryClientProvider } from "react-query";

const queryClient = new QueryClient();

function App() {
  return (
    <QueryClientProvider client={queryClient}>
      <Users />
    </QueryClientProvider>
  );
}

function Users() {
  return <div>todo</div>;
}

export default App;

We can run yarn dev and see a white page with "todo".

Start Fetching!

Let's try to fetch some users!

import { QueryClient, QueryClientProvider } from "react-query";
import { useSearchUsers } from "./github/githubComponents";

const queryClient = new QueryClient();

function App() {
  return (
    <QueryClientProvider client={queryClient}>
      <Users />
    </QueryClientProvider>
  );
}

function Users() {
  const { data, error, isLoading } = useSearchUsers({
    queryParams: { q: "fabien" },
  });

  if (error) {
    return (
      <div>
        <p>{error.message}</p>
        <a href={error.documentation_url}>Documentation</a>
      </div>
    );
  }

  if (isLoading) {
    return <div>Loading…</div>;
  }

  return (
    <ul>
      {data?.items.map((item) => (
        <li key={item.id}>{item.login}</li>
      ))}
    </ul>
  );
}

export default App;

And voilà! Without knowing the API, just playing with the autocompletion, I'm able to fetch a list a users! 🥳 The types even give us a hint that error.documentation_url was a thing, quite cool, but let's see if this is actually working!

Error Management

Let's refresh the page many times, until we reach the API rate limit 😅 Eventually, we don't see the error message, nor the documentation link.

This is where GithubFetcher.ts needs to be tweaked! Errors are indeed a bit trickier, as an error can be proper object back from the API or anything else (text response instead of JSON, or something less predictable if the network is down), so we need to make sure we received a known format in our application.

// GithubFetcher.ts
@@ -1,4 +1,5 @@
+import { BasicError } from "./githubSchemas";

@@ -43,8 +43,18 @@ export async function githubFetch<
     }
   );
   if (!response.ok) {
-    // TODO validate & parse the error to fit the generated error types
-    throw new Error("Network response was not ok");
+    let payload: BasicError;
+    try {
+      payload = await response.json();
+    } catch {
+      throw new Error("Network response was not ok");
+    }
+
+    if (typeof payload === "object") {
+      throw payload;
+    } else {
+      throw new Error("Network response was not ok");
+    }
   }
   return await response.json();
 }

Here we need to make sure to validate the error format, since everything is optional in BasicError GitHub API and BasicError have a message: string property, I can safely throw an Error. And just like this, we have nice and type-safe error handling.

Adding Some States

Of course, React is all about state isn't it? So… let's try!

function Users() {
  const [query, setQuery] = useState("");
  const { data, error, isLoading } = useSearchUsers(
    {
      queryParams: { q: query },
    },
    {
      enabled: Boolean(query),
    }
  );

  if (error) {
    return (
      <div>
        <p>{error.message}</p>
        <a href={error.documentation_url}>Documentation</a>
      </div>
    );
  }

  return (
    <div>
      <input value={query} onChange={(e) => setQuery(e.target.value)} />
      {isLoading ? (
        <div>Loading…</div>
      ) : (
        <ul>
          {data?.items.map((item) => (
            <li key={item.id}>{item.login}</li>
          ))}
        </ul>
      )}
    </div>
  );
}

Now I can search for users, and also reach the API rate limit way quicker! 😅 I guess this is a good time to introduce authentication handling in our little application.

Authentication

To keep it simple for our demo purposes, let's take a GitHub developer token and store it in localStorage. This is unsafe and you probably shouldn't do this in production. If the token is not set, ask for it, if it's there, show the data and provide a disconnect button. We will need to access this token at runtime later, with this in mind, let's create a Auth.tsx file, that isolates our authentication logic.

// Auth.tsx
import React, { createContext, useContext, useState } from "react";
import { useQueryClient } from "react-query";

const authContext = createContext<{ token: null | string }>({
  token: null,
});

export function AuthProvider({ children }: { children: React.ReactNode }) {
  const key = "githubToken";
  const [token, setToken] = useState(localStorage.getItem(key));
  const [draftToken, setDraftToken] = useState("");
  const queryClient = useQueryClient();

  return (
    <authContext.Provider value={{ token }}>
      {token ? (
        <>
          {children}
          <button
            onClick={() => {
              setToken(null);
              localStorage.removeItem(key);
              queryClient.clear();
            }}
          >
            Disconnect
          </button>
        </>
      ) : (
        <div>
          <form
            onSubmit={(e) => {
              e.preventDefault();
              setToken(draftToken);
              localStorage.setItem(key, draftToken);
            }}
          >
            <p>Please enter a personal access token</p>
            <input
              value={draftToken}
              onChange={(e) => setDraftToken(e.target.value)}
            ></input>
          </form>
        </div>
      )}
    </authContext.Provider>
  );
}

export const useToken = () => {
  const { token } = useContext(authContext);

  return token;
};

Now, we can wrap our App with the AuthProvider, so the token is available in the entire application:

// App.tsx
function App() {
  return (
    <QueryClientProvider client={queryClient}>
      <AuthProvider>
        <Users />
      </AuthProvider>
    </QueryClientProvider>
  );
}

and inject the token in GithubContext.ts

--- a/src/github/githubContext.ts
+++ b/src/github/githubContext.ts
@@ -1,4 +1,6 @@
 import type { QueryKey, UseQueryOptions } from "react-query";
+import { useToken } from "../useAuth";
 import { QueryOperation } from "./githubComponents";

 export type GithubContext = {
@@ -6,7 +8,9 @@ export type GithubContext = {
     /**
      * Headers to inject in the fetcher
      */
-    headers?: {};
+    headers?: {
+      authorization?: string;
+    };
     /**
      * Query params to inject in the fetcher
      */
@@ -36,14 +40,22 @@ export function useGithubContext<
   TData = TQueryFnData,
   TQueryKey extends QueryKey = QueryKey
 >(
-  _queryOptions?: Omit<
+  queryOptions?: Omit<
     UseQueryOptions<TQueryFnData, TError, TData, TQueryKey>,
     "queryKey" | "queryFn"
   >
 ): GithubContext {
+  const token = useToken();
+
   return {
-    fetcherOptions: {},
-    queryOptions: {},
+    fetcherOptions: {
+      headers: {
+        authorization: token ? `Bearer ${token}` : undefined,
+      },
+    },
+    queryOptions: {
+      enabled: token !== null && (queryOptions?.enabled ?? true),
+    },
     queryKeyFn: (operation) => {
       const queryKey: unknown[] = hasPathParams(operation)
         ? operation.path

And that's it! Now we have everything in place to create an amazing application around github API.

How is this working? Let's have a look of the generated useSearchUsers to understand.

export const useSearchUsers = (
  variables: SearchUsersVariables,
  options?: Omit<
    reactQuery.UseQueryOptions<
      SearchUsersResponse,
      | Responses.NotModified
      | Responses.ValidationFailed
      | Responses.ServiceUnavailable,
      SearchUsersResponse
    >,
    "queryKey" | "queryFn"
  >
) => {
  // 1. we retrieve our custom auth logic
  const { fetcherOptions, queryOptions, queryKeyFn } =
    useGithubContext(options);
  return reactQuery.useQuery<
    SearchUsersResponse,
    | Responses.NotModified
    | Responses.ValidationFailed
    | Responses.ServiceUnavailable,
    SearchUsersResponse
  >(
    queryKeyFn({
      path: "/search/users",
      operationId: "searchUsers",
      variables,
    }),
    // 2. the custom `headers.authorization`, part of `fetcherOptions` is passed to the fetcher
    () => fetchSearchUsers({ ...fetcherOptions, ...variables }),
    {
      ...options,
      ...queryOptions,
    }
  );
};

What About Query Caching?

One of the key features of react-query, is to have a query cache. By default, openapi-codegen provides you with a key cache that fits the URL scheme.

For example:

useSearchUsers( { queryParams: { q: "fabien" } } will produce the following cache key: ["search", "users", { q: "fabien" }].

So if I want to invalidate the users list, I can call:

import { useQueryClient } from "react-query";

const MyComp = () => {
  const queryClient = useQueryClient();

  const onAddUser = () => {
    queryClient.invalidateQueries(["search", "users"]);
  };

  /* ... */
};

Note: This is just a default behavior, if you have more specific needs, you can always tweak GithubContext#queryKeyFn.

Help & Feedback

This project is still relatively early stage (but it strictly follows semver, so if we break the API, you will know it!). We're always happy to have feedback and help you if you have any questions. If you'd like to browse the code or take it for a spin, be sure to check out the repo on GitHub.

Happy coding folks!


Fabien Bernard

Fabien is a senior TypeScript engineer at Xata who is passionate about type-safety and predictable code. He has been creating solutions to problems around brittle code patterns for years. In his free time, he enjoys climbing, woodworking, and other creative projects.