Learn how we build predictable API communications with the OpenAPI codegen.
Written by
Fabien Bernard
Published on
April 12, 2022
Hey there, you are on the Xata engineering blog. Xata is a serverless data platform that makes developing on top of PostgreSQL, with TypeScript, really easy. Sign up today!
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.
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:
url
and types
baked into the component to avoid any human mistakes.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
Url
github
as namespacereact-query components
src/github
as folderExecute 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:
baseUrl
(a default is provided)and in GithubContext.ts
you can:
queryKeyFn
)fetcherOptions
)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
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.
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:
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 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!
Join our community of subscribers to stay up to date with the latest news, tips and thought leadership, delivered directly to your inbox.