API Code Generation

API Code Generation #

This document describes how we generate type-safe API clients in the webapp using Orval, and how to use the generated code with React Query to make API calls.

Overview #

We use Orval to generate TypeScript clients from our OpenAPI specification. This provides several benefits:

  • Type Safety: Full TypeScript types for requests and responses
  • DRY: No need to manually maintain API client code
  • Developer Experience: Autocomplete and type checking for API calls
  • Integration with React Query: Generated hooks for data fetching with all the benefits of React Query

Run the code generation #

mise run codegen

The codegen will run automatically when you run mise dev or mise ci.

Setup #

The Orval configuration is located in apps/webapp/orval.config.ts. This configuration specifies:

import { defineConfig } from "orval";

export default defineConfig({
  bonsapi: {
    input: "../bonsapi/docs/openapi/index.yaml",
    output: {
      mode: "tags-split",
      clean: true,
      target: "./src/shared/lib/api/_generated/",
      client: "react-query",
      mock: true,
      override: {
        mutator: {
          path: "./src/shared/lib/api/axios-instance.ts",
          name: "axiosInstance",
        },
      },
    },
    hooks: {
      afterAllFilesWrite: "pnpm webapp format:fix",
    },
  },
});

Key points in this configuration:

  1. OpenAPI source from the BonsAPI project
  2. Output organized by API tags for better organization
  3. React Query integration for hooks
  4. MSW mock generation for testing
  5. Custom Axios instance for API requests
  6. Auto-formatting after generation

Generated Files #

When you run the code generation process, Orval generates several files:

  • src/shared/lib/api/_generated/bonsAPI.schemas.ts: TypeScript type definitions for all API schemas
  • src/shared/lib/api/_generated/document/document.ts: API client for document-related endpoints
  • src/shared/lib/api/_generated/default/default.ts: API client for other endpoints
  • MSW handlers for mocking in tests and development (e.g., document.msw.ts)

Axios Configuration #

The Axios instance used by the generated clients is configured in src/shared/lib/api/axios-instance.ts:

import type { AxiosError, AxiosRequestConfig } from "axios";
import Axios from "axios";

const NODE_ENV = process.env.NODE_ENV;

const BASE_URL =
  NODE_ENV === "development"
    ? `http://${process.env.NEXT_PUBLIC_BONSAPI_HOST}`
    : `https://${process.env.NEXT_PUBLIC_BONSAPI_HOST}`;

export const AXIOS_INSTANCE = Axios.create({ baseURL: BASE_URL });

export const axiosInstance = <T>(
  config: AxiosRequestConfig,
  options?: AxiosRequestConfig
): Promise<T> => {
  const source = Axios.CancelToken.source();
  const promise = AXIOS_INSTANCE({
    ...config,
    ...options,
    cancelToken: source.token,
  }).then(({ data }) => data as T);

  // @ts-expect-error - cancel is a method on the promise
  promise.cancel = () => {
    source.cancel("Query was cancelled");
  };

  return promise;
};

export type ErrorType<e> = AxiosError<e>;
export type BodyType<BodyData> = BodyData;

This configuration automatically uses the appropriate base URL based on the environment and provides cancellation support for requests.

How to Use the Generated API Clients #

Basic Usage with React Query #

To make an API call, import the generated hook from the appropriate file. Here’s an example of fetching documents:

import { useGetDocuments } from "@/shared/lib/api/_generated/document/document";

function DocumentList() {
  const { data: documents, isLoading, error } = useGetDocuments();

  if (isLoading) return <div>Loading...</div>;
  if (error) return <div>Error: {error.message}</div>;

  return (
    <div>
      {documents?.map((doc) => (
        <div key={doc.id}>{doc.title}</div>
      ))}
    </div>
  );
}

Making Requests with Parameters #

For endpoints that require parameters, you can pass them to the hook:

import { useGetDocumentById } from "@/shared/lib/api/_generated/document/document";

function DocumentDetail({ id }) {
  const { data: document, isLoading } = useGetDocumentById({
    pathParams: { id },
  });

  if (isLoading) return <div>Loading...</div>;

  return (
    <div>
      <h1>{document?.title}</h1>
      <p>{document?.content}</p>
    </div>
  );
}

Creating or Updating Resources #

For mutations (POST, PUT, PATCH), the generated hooks return mutation functions from React Query:

import { useCreateDocument } from "@/shared/lib/api/_generated/document/document";

function DocumentForm() {
  const createDocument = useCreateDocument();

  const handleSubmit = async (formData) => {
    try {
      // Note the structure with 'data' property as shown in the generated code
      const result = await createDocument.mutateAsync({
        data: {
          files: formData.files,
          entity_id: formData.entityId,
        },
      });
      console.log("Document created:", result);
    } catch (error) {
      console.error("Failed to create document:", error);
    }
  };

  return <form onSubmit={handleSubmit}>{/* Form fields */}</form>;
}

Working with File Uploads #

For endpoints that handle file uploads (like document creation), the generated code automatically handles FormData conversion:

import { useCreateDocument } from "@/shared/lib/api/_generated/document/document";

function FileUploadForm() {
  const [files, setFiles] = useState<File[]>([]);
  const createDocument = useCreateDocument();

  const handleFileChange = (e) => {
    setFiles(Array.from(e.target.files));
  };

  const handleSubmit = async (e) => {
    e.preventDefault();

    try {
      const result = await createDocument.mutateAsync({
        data: {
          files,
          entity_id: "invoice-123",
        },
      });
      // Handle success
    } catch (error) {
      // Handle error
    }
  };

  return (
    <form onSubmit={handleSubmit}>
      <input type="file" multiple onChange={handleFileChange} />
      <button type="submit">Upload</button>
    </form>
  );
}

Query Options #

You can pass React Query options to customize behavior:

const { data } = useGetDocuments({
  query: {
    enabled: isAuthenticated,
    staleTime: 5 * 60 * 1000, // 5 minutes
    refetchOnWindowFocus: false,
  },
});

Regenerating API Clients #

When the API schema changes, you need to regenerate the clients:

cd apps/webapp
pnpm orval

This will fetch the latest OpenAPI spec and regenerate all the TypeScript files.

Best Practices #

  1. Use type inference: Let TypeScript infer the types from the generated clients
  2. Handle loading and error states: Always account for loading, error, and success states
  3. Use React Query’s caching: Take advantage of the built-in caching features
  4. Invalidate queries properly: When data changes, invalidate relevant queries
// Example of invalidating queries
import { useQueryClient } from "@tanstack/react-query";
import {
  useCreateDocument,
  getGetDocumentQueryKey,
} from "@/shared/lib/api/_generated/document/document";

function DocumentForm() {
  const queryClient = useQueryClient();
  const createDocument = useCreateDocument();

  const handleSubmit = async (formData) => {
    try {
      await createDocument.mutateAsync({
        data: {
          /* document data */
        },
      });

      // Invalidate relevant queries to refresh the data
      queryClient.invalidateQueries(getGetDocumentQueryKey());
    } catch (error) {
      // Handle error
    }
  };
}

Troubleshooting #

If you encounter issues with the generated code:

  1. Check that you’re using the latest OpenAPI spec
  2. Verify the Orval configuration is correct
  3. Clear the generated files and regenerate them
  4. Ensure your API calls match the expected parameters in the spec
  5. Check for console errors related to the Axios requests