Skip to content

API Package

The @repo/api package provides auto-generated, type-safe API client code based on the backend's OpenAPI specification.

Overview

This package:

  • Watches the backend's OpenAPI specification
  • Generates TypeScript types using openapi-typescript
  • Provides a type-safe API client using openapi-fetch
  • Ensures frontend and backend type compatibility

Structure

packages/api/
├── src/
│   ├── client.ts          # API client configuration
│   ├── enums.ts           # Type-safe enum definitions
│   ├── generate.ts        # Type generation script
│   ├── index.ts           # Main exports
│   └── types.ts           # Additional type utilities

├── lib/                   # Generated files (git-ignored)
│   ├── api.gen.ts         # Generated TypeScript types
│   └── enums.gen.ts       # Generated enums

├── package.json
└── tsconfig.json

Generated Types

The package generates comprehensive types from the OpenAPI spec:

typescript
// lib/api.gen.ts (auto-generated)
export interface paths {
  "/api/courses": {
    get: {
      responses: {
        200: {
          content: {
            "application/json": components["schemas"]["Course"][];
          };
        };
      };
    };
    post: {
      requestBody: {
        content: {
          "application/json": components["schemas"]["CreateCourseRequest"];
        };
      };
      responses: {
        201: {
          content: {
            "application/json": components["schemas"]["Course"];
          };
        };
      };
    };
  };
  "/api/courses/{id}": {
    get: {
      parameters: {
        path: {
          id: number;
        };
      };
      responses: {
        200: {
          content: {
            "application/json": components["schemas"]["Course"];
          };
        };
      };
    };
  };
}

export interface components {
  schemas: {
    Course: {
      id: number;
      name: string;
      code: string;
      description: string | null;
      modules?: components["schemas"]["Module"][];
    };
    CreateCourseRequest: {
      name: string;
      code: string;
      description?: string;
    };
  };
}

API Client

The package exports a configured openapi-fetch client:

typescript
// src/client.ts
import createClient from "openapi-fetch";
import type { paths } from "../lib/api.gen";

export const apiClient = createClient<paths>({
  baseUrl: import.meta.env.VITE_API_URL || "http://localhost:3000",
  headers: {
    "Content-Type": "application/json",
  },
});

// Middleware for authentication
export function setAuthToken(token: string) {
  apiClient.use({
    onRequest: async ({ request }) => {
      request.headers.set("Authorization", `Bearer ${token}`);
      return request;
    },
  });
}

// Middleware for error handling
export function setupErrorHandling(onError: (error: Error) => void) {
  apiClient.use({
    onResponse: async ({ response }) => {
      if (!response.ok) {
        const error = await response.json();
        onError(new Error(error.message || "Request failed"));
      }
      return response;
    },
  });
}

Usage

Basic GET Request

typescript
import { apiClient } from "@repo/api";

// Fetch all courses
const { data, error } = await apiClient.GET("/api/courses");

if (error) {
  console.error("Error fetching courses:", error);
} else {
  // data is typed as Course[]
  data.forEach((course) => {
    console.log(course.name); // TypeScript knows 'name' exists
  });
}

GET with Path Parameters

typescript
const courseId = 123;

const { data, error } = await apiClient.GET("/api/courses/{id}", {
  params: {
    path: { id: courseId },
  },
});

// data is typed as Course
if (data) {
  console.log(data.name);
}

GET with Query Parameters

typescript
const { data, error } = await apiClient.GET("/api/courses", {
  params: {
    query: {
      search: "JavaScript",
      limit: 10,
      offset: 0,
    },
  },
});

POST Request

typescript
const { data, error } = await apiClient.POST("/api/courses", {
  body: {
    name: "Introduction to TypeScript",
    code: "TS101",
    description: "Learn TypeScript fundamentals",
  },
});

// TypeScript validates the body structure
// data is typed as Course

PUT Request

typescript
const { data, error } = await apiClient.PUT("/api/courses/{id}", {
  params: {
    path: { id: 123 },
  },
  body: {
    name: "Updated Course Name",
  },
});

DELETE Request

typescript
const { error } = await apiClient.DELETE("/api/courses/{id}", {
  params: {
    path: { id: 123 },
  },
});

if (!error) {
  console.log("Course deleted successfully");
}

File Upload

typescript
const file = new File(
  [
    /* file data */
  ],
  "document.pdf",
);

const { data, error } = await apiClient.POST("/api/courses/{courseId}/files", {
  params: {
    path: { courseId: 123 },
  },
  body: {
    file, // TypeScript knows this should be a File
    title: "Course Document",
  },
  bodySerializer: (body) => {
    const formData = new FormData();
    formData.append("file", body.file);
    formData.append("title", body.title);
    return formData;
  },
});

Type Generation

Generation Script

typescript
// src/generate.ts
import fs from "node:fs";
import path from "node:path";
import openapiTS, { astToString } from "openapi-typescript";

const srcFile = path.resolve("../../apps/client-backend/lib/swagger.json");
const src = new URL(srcFile, import.meta.url);

async function generate() {
  const ast = await openapiTS(src, {
    alphabetize: true, // Sort for consistency
    enum: true, // Generate enums
    transform(schemaObject, options) {
      // Custom transformers
      // See Type Safety Flow documentation for details
    },
  });

  const contents = astToString(ast);
  fs.writeFileSync("lib/api.gen.ts", contents);
}

// Watch for changes
const watcher = fs.watch(srcFile, { persistent: true }).on("change", generate);

generate();

Custom Transformers

The generator includes custom transformers for:

File Upload Support:

typescript
// Binary format → File/Blob type
{
  "type": "string",
  "format": "binary"
}
// Becomes: File | Blob

Enum Handling:

typescript
// Single-member enums become literal types
enum Status {
  ACTIVE = "ACTIVE",
}
// Becomes: type Status = "ACTIVE"

Nullable Types:

typescript
// OpenAPI nullable preserved
{ "type": "string", "nullable": true }
// Becomes: string | null

Enums

Type-safe enums are re-exported from generated types:

typescript
// src/enums.ts
export {
  UserRole as UserRoleEnum,
  CourseStatus as CourseStatusEnum,
  QuizQuestionType as QuizQuestionTypeEnum,
  // ... other enums
} from "../lib/enums.gen";

Usage:

typescript
import { UserRoleEnum, CourseStatusEnum } from "@repo/api";

const role: UserRoleEnum = "INSTRUCTOR";
const status: CourseStatusEnum = "PUBLISHED";

Type Utilities

Additional type helpers:

typescript
// src/types.ts
import type { components } from "../lib/api.gen";

// Extract a specific schema type
export type Course = components["schemas"]["Course"];
export type User = components["schemas"]["User"];
export type Quiz = components["schemas"]["Quiz"];

// Extract response types
import type { paths } from "../lib/api.gen";

export type GetCoursesResponse =
  paths["/api/courses"]["get"]["responses"][200]["content"]["application/json"];

export type CreateCourseRequest =
  paths["/api/courses"]["post"]["requestBody"]["content"]["application/json"];

// Utility type for API errors
export interface ApiError {
  timestamp: string;
  path: string;
  status: number;
  message: string;
  details?: Record<string, unknown>;
}

Development Workflow

Watch Mode

During development, the package automatically regenerates types:

bash
cd packages/api
pnpm dev

This watches apps/client-backend/lib/swagger.json and regenerates types on changes.

Manual Generation

Force type regeneration:

bash
cd packages/api
tsx src/generate.ts

Integration with Frontend

The frontend automatically gets updated types:

  1. Backend controller changes
  2. TSOA regenerates OpenAPI spec
  3. API package regenerates types
  4. Frontend sees new types (via monorepo linking)
  5. TypeScript shows errors for incompatible code

Error Handling

Typed Error Responses

typescript
const { data, error } = await apiClient.GET("/api/courses/{id}", {
  params: { path: { id: 999 } },
});

if (error) {
  // error contains the response data
  console.error("Status:", error.status);
  console.error("Message:", error.message);

  // Handle specific errors
  if (error.status === 404) {
    console.log("Course not found");
  } else if (error.status === 403) {
    console.log("Access denied");
  }
}

Global Error Handler

typescript
// Setup once in your app
import { setupErrorHandling } from "@repo/api";

setupErrorHandling((error) => {
  // Log to error tracking service
  console.error("API Error:", error);

  // Show toast notification
  toast.error(error.message);
});

Testing

Mocking API Responses

typescript
// In tests
import { apiClient } from "@repo/api";

// Mock the client
vi.mock("@repo/api", () => ({
  apiClient: {
    GET: vi.fn(),
    POST: vi.fn(),
    PUT: vi.fn(),
    DELETE: vi.fn(),
  },
}));

// Setup mock responses
apiClient.GET.mockResolvedValue({
  data: [
    { id: 1, name: "Course 1", code: "CS101" },
    { id: 2, name: "Course 2", code: "CS102" },
  ],
  error: undefined,
});

Configuration

Environment Variables

env
# .env (frontend)
VITE_API_URL=http://localhost:3000

Custom Base URL

typescript
import createClient from "openapi-fetch";
import type { paths } from "@repo/api";

const customClient = createClient<paths>({
  baseUrl: "https://api.example.com",
});

Best Practices

  1. Always check for errors before accessing data
  2. Use type utilities for cleaner code
  3. Set up auth middleware once at app initialization
  4. Handle errors globally with error middleware
  5. Don't modify generated files - they're overwritten
  6. Keep transformers in sync with backend requirements
  7. Test API integration with actual backend

Troubleshooting

Types Not Updating

bash
# Check if watcher is running
ps aux | grep tsx

# Restart generation
cd packages/api
pnpm dev

# Check OpenAPI spec exists
ls -la ../../apps/client-backend/lib/swagger.json

Type Errors After Backend Changes

  1. Ensure backend OpenAPI spec is regenerated
  2. Restart API package watcher
  3. Restart TypeScript server in IDE
  4. Clear build cache if needed

Circular Dependencies

If you encounter circular dependency issues:

  • Define shared types in a separate package
  • Use type-only imports: import type { ... }
  • Avoid importing implementation from generated files