From OpenAPI to type safe react-query hooks

Learn how we build predictable API communications with the OpenAPI codegen.

Written by

Fabien Bernard

Published on

April 12, 2022

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 do not consider spending time reverse engineering APIs just to know what kind of data is available to be either fun or 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 crafted 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.

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

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

Execute npx openapi-codegen gen github and voilà!

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)

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".

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!

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.

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.

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

githububContext.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 at the generated useSearchUsers:

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,
    }
  );
};

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.

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!

Start free,
pay as you grow

Xata provides the best free plan in the industry. It is production ready by default and doesn't pause or cool-down. Take your time to build your business and upgrade when you're ready to scale.

Free plan includes
  • 10 database branches
  • High availability
  • 15 GB data storage
  • 15 GB search engine storage
  • 2 GB file attachments
  • 250 AI queries per month
Start freeExplore all plans
Free plan includes
  • 10 database branches
  • High availability
  • 15 GB data storage
  • 15 GB search engine storage
  • 2 GB file attachments
  • 250 AI queries per month
Add column to table

Copyright © 2024 Xatabase Inc.
All rights reserved.

Product

RoadmapFeature requestsPricingStatusAI solutionsFile attachments