Working with external APIs is a fundamental part of modern web development, but it often involves tedious boilerplate: writing request/response types, implementing retry logic, handling authentication flows, and creating mocks for testing. AI coding assistants have transformed this workflow, enabling developers to generate production-ready API clients in minutes instead of hours.
In this comprehensive guide, we'll explore how to leverage AI for API integration across REST, GraphQL, and gRPC protocols. You'll learn to generate type-safe wrappers, implement robust error handling, create comprehensive SDK documentation, and build API mocks for testing--all with practical, working code examples.
Generating Type-Safe REST API Clients
REST APIs remain the most common integration pattern. Let's start by exploring how AI can generate fully typed clients from OpenAPI specifications.
From OpenAPI Spec to TypeScript Client
The most reliable approach is to provide your OpenAPI/Swagger specification as context. Here's a prompt pattern that consistently produces high-quality results:
// Prompt to AI:
// "Generate a type-safe TypeScript API client from this OpenAPI spec.
// Use fetch, include retry logic with exponential backoff,
// and generate Zod schemas for runtime validation."
// Example OpenAPI spec (partial)
const openApiSpec = {
"openapi": "3.0.0",
"paths": {
"/users/{id}": {
"get": {
"operationId": "getUserById",
"parameters": [
{ "name": "id", "in": "path", "required": true, "schema": { "type": "string" } }
],
"responses": {
"200": {
"content": {
"application/json": {
"schema": { "$ref": "#/components/schemas/User" }
}
}
}
}
}
}
},
"components": {
"schemas": {
"User": {
"type": "object",
"properties": {
"id": { "type": "string" },
"email": { "type": "string", "format": "email" },
"name": { "type": "string" },
"createdAt": { "type": "string", "format": "date-time" }
},
"required": ["id", "email", "name"]
}
}
}
};
Here's the type-safe client that AI generates from this spec:
// Generated API Client with Type Safety
import { z } from 'zod';
// Zod schemas for runtime validation
export const UserSchema = z.object({
id: z.string(),
email: z.string().email(),
name: z.string(),
createdAt: z.string().datetime().optional()
});
export type User = z.infer<typeof UserSchema>;
// Error types for type-safe error handling
export class ApiError extends Error {
constructor(
message: string,
public statusCode: number,
public responseBody?: unknown
) {
super(message);
this.name = 'ApiError';
}
}
export class NetworkError extends Error {
constructor(message: string, public cause?: Error) {
super(message);
this.name = 'NetworkError';
}
}
export class ValidationError extends Error {
constructor(message: string, public zodError: z.ZodError) {
super(message);
this.name = 'ValidationError';
}
}
// Configuration interface
interface ApiClientConfig {
baseUrl: string;
apiKey?: string;
timeout?: number;
maxRetries?: number;
onRequestStart?: (endpoint: string) => void;
onRequestEnd?: (endpoint: string, duration: number) => void;
}
// Retry configuration
interface RetryConfig {
maxRetries: number;
baseDelay: number;
maxDelay: number;
retryableStatuses: number[];
}
const DEFAULT_RETRY_CONFIG: RetryConfig = {
maxRetries: 3,
baseDelay: 1000,
maxDelay: 30000,
retryableStatuses: [429, 500, 502, 503, 504]
};
// The main API client class
export class UserApiClient {
private config: ApiClientConfig;
private retryConfig: RetryConfig;
constructor(config: ApiClientConfig) {
this.config = {
timeout: 30000,
maxRetries: 3,
...config
};
this.retryConfig = {
...DEFAULT_RETRY_CONFIG,
maxRetries: config.maxRetries ?? DEFAULT_RETRY_CONFIG.maxRetries
};
}
// Exponential backoff with jitter
private calculateDelay(attempt: number, retryAfter?: number): number {
if (retryAfter) {
return retryAfter * 1000;
}
const exponentialDelay = this.retryConfig.baseDelay * Math.pow(2, attempt);
const jitter = Math.random() * 0.3 * exponentialDelay;
return Math.min(exponentialDelay + jitter, this.retryConfig.maxDelay);
}
// Core fetch with retry logic
private async fetchWithRetry<T>(
endpoint: string,
options: RequestInit,
schema: z.ZodSchema<T>
): Promise<T> {
const url = `${this.config.baseUrl}${endpoint}`;
let lastError: Error | null = null;
for (let attempt = 0; attempt <= this.retryConfig.maxRetries; attempt++) {
const startTime = Date.now();
this.config.onRequestStart?.(endpoint);
try {
const controller = new AbortController();
const timeoutId = setTimeout(
() => controller.abort(),
this.config.timeout
);
const response = await fetch(url, {
...options,
signal: controller.signal,
headers: {
'Content-Type': 'application/json',
...(this.config.apiKey && {
'Authorization': `Bearer ${this.config.apiKey}`
}),
...options.headers
}
});
clearTimeout(timeoutId);
this.config.onRequestEnd?.(endpoint, Date.now() - startTime);
if (!response.ok) {
const shouldRetry = this.retryConfig.retryableStatuses
.includes(response.status);
if (shouldRetry && attempt < this.retryConfig.maxRetries) {
const retryAfter = response.headers.get('Retry-After');
const delay = this.calculateDelay(
attempt,
retryAfter ? parseInt(retryAfter) : undefined
);
await this.sleep(delay);
continue;
}
const errorBody = await response.text();
throw new ApiError(
`API request failed: ${response.status}`,
response.status,
this.tryParseJson(errorBody)
);
}
const data = await response.json();
// Runtime validation with Zod
const parseResult = schema.safeParse(data);
if (!parseResult.success) {
throw new ValidationError(
'Response validation failed',
parseResult.error
);
}
return parseResult.data;
} catch (error) {
lastError = error as Error;
if (error instanceof ApiError || error instanceof ValidationError) {
throw error;
}
if (attempt < this.retryConfig.maxRetries) {
const delay = this.calculateDelay(attempt);
await this.sleep(delay);
continue;
}
}
}
throw new NetworkError(
`Request failed after ${this.retryConfig.maxRetries} retries`,
lastError ?? undefined
);
}
private sleep(ms: number): Promise<void> {
return new Promise(resolve => setTimeout(resolve, ms));
}
private tryParseJson(text: string): unknown {
try {
return JSON.parse(text);
} catch {
return text;
}
}
// Generated API methods
async getUserById(id: string): Promise<User> {
return this.fetchWithRetry(
`/users/${encodeURIComponent(id)}`,
{ method: 'GET' },
UserSchema
);
}
async createUser(data: Omit<User, 'id' | 'createdAt'>): Promise<User> {
return this.fetchWithRetry(
'/users',
{
method: 'POST',
body: JSON.stringify(data)
},
UserSchema
);
}
async updateUser(id: string, data: Partial<Omit<User, 'id'>>): Promise<User> {
return this.fetchWithRetry(
`/users/${encodeURIComponent(id)}`,
{
method: 'PATCH',
body: JSON.stringify(data)
},
UserSchema
);
}
async deleteUser(id: string): Promise<void> {
await this.fetchWithRetry(
`/users/${encodeURIComponent(id)}`,
{ method: 'DELETE' },
z.object({})
);
}
}
// Usage example
const client = new UserApiClient({
baseUrl: 'https://api.example.com/v1',
apiKey: process.env.API_KEY,
onRequestStart: (endpoint) => console.log(`Starting: ${endpoint}`),
onRequestEnd: (endpoint, duration) => console.log(`Completed: ${endpoint} in ${duration}ms`)
});
// Fully typed API calls
const user = await client.getUserById('user-123');
console.log(user.email); // TypeScript knows this is a string
AI-Generated GraphQL Clients
GraphQL's introspection capabilities make it particularly well-suited for AI-assisted client generation. Here's how to leverage AI for type-safe GraphQL clients.
Generating Types from GraphQL Schema
// Prompt: "Generate TypeScript types and a client from this GraphQL schema.
// Include query/mutation builders with proper typing."
// GraphQL schema provided to AI
const graphqlSchema = `
type User {
id: ID!
email: String!
name: String!
posts: [Post!]!
createdAt: DateTime!
}
type Post {
id: ID!
title: String!
content: String!
author: User!
published: Boolean!
createdAt: DateTime!
}
type Query {
user(id: ID!): User
users(limit: Int, offset: Int): [User!]!
post(id: ID!): Post
posts(authorId: ID, published: Boolean): [Post!]!
}
type Mutation {
createUser(input: CreateUserInput!): User!
createPost(input: CreatePostInput!): Post!
updatePost(id: ID!, input: UpdatePostInput!): Post!
deletePost(id: ID!): Boolean!
}
input CreateUserInput {
email: String!
name: String!
}
input CreatePostInput {
title: String!
content: String!
authorId: ID!
}
input UpdatePostInput {
title: String
content: String
published: Boolean
}
`;
// AI-generated TypeScript types
export interface User {
id: string;
email: string;
name: string;
posts: Post[];
createdAt: string;
}
export interface Post {
id: string;
title: string;
content: string;
author: User;
published: boolean;
createdAt: string;
}
export interface CreateUserInput {
email: string;
name: string;
}
export interface CreatePostInput {
title: string;
content: string;
authorId: string;
}
export interface UpdatePostInput {
title?: string;
content?: string;
published?: boolean;
}
// Type-safe GraphQL client
export class GraphQLClient {
private endpoint: string;
private headers: Record<string, string>;
constructor(endpoint: string, options?: { headers?: Record<string, string> }) {
this.endpoint = endpoint;
this.headers = {
'Content-Type': 'application/json',
...options?.headers
};
}
private async execute<T>(
query: string,
variables?: Record<string, unknown>
): Promise<T> {
const response = await fetch(this.endpoint, {
method: 'POST',
headers: this.headers,
body: JSON.stringify({ query, variables })
});
const result = await response.json();
if (result.errors) {
throw new GraphQLError(result.errors);
}
return result.data;
}
// Query builders with full type safety
async getUser(
id: string,
fields: (keyof User)[] = ['id', 'email', 'name']
): Promise<Pick<User, typeof fields[number]> | null> {
const fieldSelection = fields.join('\n');
const query = `
query GetUser($id: ID!) {
user(id: $id) {
${fieldSelection}
}
}
`;
const result = await this.execute<{ user: User | null }>(query, { id });
return result.user;
}
async getUsers(
options?: { limit?: number; offset?: number },
fields: (keyof User)[] = ['id', 'email', 'name']
): Promise<User[]> {
const fieldSelection = fields.join('\n');
const query = `
query GetUsers($limit: Int, $offset: Int) {
users(limit: $limit, offset: $offset) {
${fieldSelection}
}
}
`;
const result = await this.execute<{ users: User[] }>(query, options);
return result.users;
}
async getUserWithPosts(id: string): Promise<User & { posts: Post[] } | null> {
const query = `
query GetUserWithPosts($id: ID!) {
user(id: $id) {
id
email
name
posts {
id
title
content
published
createdAt
}
createdAt
}
}
`;
const result = await this.execute<{ user: User | null }>(query, { id });
return result.user;
}
// Mutation methods
async createUser(input: CreateUserInput): Promise<User> {
const mutation = `
mutation CreateUser($input: CreateUserInput!) {
createUser(input: $input) {
id
email
name
createdAt
}
}
`;
const result = await this.execute<{ createUser: User }>(mutation, { input });
return result.createUser;
}
async createPost(input: CreatePostInput): Promise<Post> {
const mutation = `
mutation CreatePost($input: CreatePostInput!) {
createPost(input: $input) {
id
title
content
published
author {
id
name
}
createdAt
}
}
`;
const result = await this.execute<{ createPost: Post }>(mutation, { input });
return result.createPost;
}
async updatePost(id: string, input: UpdatePostInput): Promise<Post> {
const mutation = `
mutation UpdatePost($id: ID!, $input: UpdatePostInput!) {
updatePost(id: $id, input: $input) {
id
title
content
published
}
}
`;
const result = await this.execute<{ updatePost: Post }>(mutation, { id, input });
return result.updatePost;
}
async deletePost(id: string): Promise<boolean> {
const mutation = `
mutation DeletePost($id: ID!) {
deletePost(id: $id)
}
`;
const result = await this.execute<{ deletePost: boolean }>(mutation, { id });
return result.deletePost;
}
}
class GraphQLError extends Error {
constructor(public errors: Array<{ message: string; path?: string[] }>) {
super(errors.map(e => e.message).join(', '));
this.name = 'GraphQLError';
}
}
gRPC Client Generation with AI
For gRPC services, AI can help generate client wrappers that simplify the proto-generated code and add useful abstractions.
// Prompt: "Generate a TypeScript gRPC client wrapper from this proto definition.
// Include streaming support, deadline handling, and metadata management."
// Proto definition provided to AI
/*
syntax = "proto3";
package userservice;
service UserService {
rpc GetUser(GetUserRequest) returns (User);
rpc ListUsers(ListUsersRequest) returns (stream User);
rpc CreateUser(CreateUserRequest) returns (User);
rpc WatchUserUpdates(WatchRequest) returns (stream UserUpdate);
}
message User {
string id = 1;
string email = 2;
string name = 3;
int64 created_at = 4;
}
message GetUserRequest {
string id = 1;
}
message ListUsersRequest {
int32 page_size = 1;
string page_token = 2;
}
message CreateUserRequest {
string email = 1;
string name = 2;
}
message WatchRequest {
repeated string user_ids = 1;
}
message UserUpdate {
string user_id = 1;
string field = 2;
string old_value = 3;
string new_value = 4;
int64 timestamp = 5;
}
*/
// AI-generated gRPC client wrapper
import * as grpc from '@grpc/grpc-js';
import { UserServiceClient } from './generated/userservice_grpc_pb';
import {
User,
GetUserRequest,
ListUsersRequest,
CreateUserRequest,
WatchRequest,
UserUpdate
} from './generated/userservice_pb';
export interface GrpcClientConfig {
address: string;
credentials?: grpc.ChannelCredentials;
defaultDeadlineMs?: number;
metadata?: Record<string, string>;
}
export interface StreamOptions {
onData: (data: unknown) => void;
onError?: (error: Error) => void;
onEnd?: () => void;
}
export class UserServiceClientWrapper {
private client: UserServiceClient;
private defaultDeadlineMs: number;
private defaultMetadata: grpc.Metadata;
constructor(config: GrpcClientConfig) {
const credentials = config.credentials ?? grpc.credentials.createInsecure();
this.client = new UserServiceClient(config.address, credentials);
this.defaultDeadlineMs = config.defaultDeadlineMs ?? 30000;
this.defaultMetadata = new grpc.Metadata();
if (config.metadata) {
Object.entries(config.metadata).forEach(([key, value]) => {
this.defaultMetadata.set(key, value);
});
}
}
private getDeadline(customDeadlineMs?: number): Date {
const deadline = customDeadlineMs ?? this.defaultDeadlineMs;
return new Date(Date.now() + deadline);
}
private mergeMetadata(custom?: Record<string, string>): grpc.Metadata {
const metadata = new grpc.Metadata();
// Copy default metadata
this.defaultMetadata.getMap()
for (const [key, values] of Object.entries(this.defaultMetadata.getMap())) {
metadata.set(key, values as string);
}
// Merge custom metadata
if (custom) {
Object.entries(custom).forEach(([key, value]) => {
metadata.set(key, value);
});
}
return metadata;
}
// Unary call with promise wrapper
async getUser(
id: string,
options?: { deadlineMs?: number; metadata?: Record<string, string> }
): Promise<UserDTO> {
return new Promise((resolve, reject) => {
const request = new GetUserRequest();
request.setId(id);
this.client.getUser(
request,
this.mergeMetadata(options?.metadata),
{ deadline: this.getDeadline(options?.deadlineMs) },
(error, response) => {
if (error) {
reject(this.handleError(error));
return;
}
resolve(this.userToDTO(response!));
}
);
});
}
// Server streaming with async iterator
async *listUsers(
pageSize: number = 100,
pageToken?: string,
options?: { deadlineMs?: number; metadata?: Record<string, string> }
): AsyncIterable<UserDTO> {
const request = new ListUsersRequest();
request.setPageSize(pageSize);
if (pageToken) {
request.setPageToken(pageToken);
}
const stream = this.client.listUsers(
request,
this.mergeMetadata(options?.metadata),
{ deadline: this.getDeadline(options?.deadlineMs) }
);
for await (const user of this.streamToAsyncIterable<User>(stream)) {
yield this.userToDTO(user);
}
}
// Bidirectional streaming
watchUserUpdates(
userIds: string[],
callbacks: {
onUpdate: (update: UserUpdateDTO) => void;
onError?: (error: Error) => void;
onEnd?: () => void;
},
options?: { metadata?: Record<string, string> }
): { cancel: () => void } {
const request = new WatchRequest();
request.setUserIdsList(userIds);
const stream = this.client.watchUserUpdates(
request,
this.mergeMetadata(options?.metadata)
);
stream.on('data', (update: UserUpdate) => {
callbacks.onUpdate(this.updateToDTO(update));
});
stream.on('error', (error) => {
callbacks.onError?.(this.handleError(error));
});
stream.on('end', () => {
callbacks.onEnd?.();
});
return {
cancel: () => stream.cancel()
};
}
async createUser(
email: string,
name: string,
options?: { deadlineMs?: number; metadata?: Record<string, string> }
): Promise<UserDTO> {
return new Promise((resolve, reject) => {
const request = new CreateUserRequest();
request.setEmail(email);
request.setName(name);
this.client.createUser(
request,
this.mergeMetadata(options?.metadata),
{ deadline: this.getDeadline(options?.deadlineMs) },
(error, response) => {
if (error) {
reject(this.handleError(error));
return;
}
resolve(this.userToDTO(response!));
}
);
});
}
// Helper to convert gRPC stream to async iterable
private async *streamToAsyncIterable<T>(
stream: grpc.ClientReadableStream<T>
): AsyncIterable<T> {
const queue: (T | Error | null)[] = [];
let resolve: (() => void) | null = null;
stream.on('data', (data: T) => {
queue.push(data);
resolve?.();
});
stream.on('error', (error) => {
queue.push(error);
resolve?.();
});
stream.on('end', () => {
queue.push(null);
resolve?.();
});
while (true) {
if (queue.length === 0) {
await new Promise<void>(r => { resolve = r; });
}
const item = queue.shift();
if (item === null) {
return;
}
if (item instanceof Error) {
throw this.handleError(item);
}
yield item;
}
}
// Error handling
private handleError(error: grpc.ServiceError | Error): Error {
if ('code' in error) {
const grpcError = error as grpc.ServiceError;
return new GrpcError(
grpcError.message,
grpcError.code,
grpcError.metadata
);
}
return error;
}
// DTO conversions
private userToDTO(user: User): UserDTO {
return {
id: user.getId(),
email: user.getEmail(),
name: user.getName(),
createdAt: new Date(user.getCreatedAt())
};
}
private updateToDTO(update: UserUpdate): UserUpdateDTO {
return {
userId: update.getUserId(),
field: update.getField(),
oldValue: update.getOldValue(),
newValue: update.getNewValue(),
timestamp: new Date(update.getTimestamp())
};
}
// Cleanup
close(): void {
this.client.close();
}
}
// TypeScript DTOs
export interface UserDTO {
id: string;
email: string;
name: string;
createdAt: Date;
}
export interface UserUpdateDTO {
userId: string;
field: string;
oldValue: string;
newValue: string;
timestamp: Date;
}
class GrpcError extends Error {
constructor(
message: string,
public code: grpc.status,
public metadata?: grpc.Metadata
) {
super(message);
this.name = 'GrpcError';
}
}
Implementing Authentication Flows
Authentication is one area where AI-generated code needs careful review. Here's a production-ready pattern for OAuth2 with token refresh:
// AI-generated OAuth2 authentication handler
export class OAuth2AuthHandler {
private accessToken: string | null = null;
private refreshToken: string | null = null;
private tokenExpiry: Date | null = null;
private refreshPromise: Promise<void> | null = null;
constructor(
private config: {
clientId: string;
clientSecret: string;
tokenEndpoint: string;
refreshThresholdMs?: number;
}
) {}
async getAccessToken(): Promise<string> {
// Check if we need to refresh
if (this.shouldRefresh()) {
await this.refreshAccessToken();
}
if (!this.accessToken) {
throw new Error('No access token available. Call authenticate() first.');
}
return this.accessToken;
}
private shouldRefresh(): boolean {
if (!this.tokenExpiry) return true;
const threshold = this.config.refreshThresholdMs ?? 60000; // 1 minute default
return Date.now() >= this.tokenExpiry.getTime() - threshold;
}
async authenticate(code: string, redirectUri: string): Promise<void> {
const response = await fetch(this.config.tokenEndpoint, {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded'
},
body: new URLSearchParams({
grant_type: 'authorization_code',
code,
redirect_uri: redirectUri,
client_id: this.config.clientId,
client_secret: this.config.clientSecret
})
});
if (!response.ok) {
throw new AuthenticationError('Failed to exchange code for tokens');
}
const data = await response.json();
this.updateTokens(data);
}
private async refreshAccessToken(): Promise<void> {
// Prevent concurrent refresh attempts
if (this.refreshPromise) {
return this.refreshPromise;
}
if (!this.refreshToken) {
throw new AuthenticationError('No refresh token available');
}
this.refreshPromise = this.performRefresh();
try {
await this.refreshPromise;
} finally {
this.refreshPromise = null;
}
}
private async performRefresh(): Promise<void> {
const response = await fetch(this.config.tokenEndpoint, {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded'
},
body: new URLSearchParams({
grant_type: 'refresh_token',
refresh_token: this.refreshToken!,
client_id: this.config.clientId,
client_secret: this.config.clientSecret
})
});
if (!response.ok) {
this.clearTokens();
throw new AuthenticationError('Failed to refresh access token');
}
const data = await response.json();
this.updateTokens(data);
}
private updateTokens(data: {
access_token: string;
refresh_token?: string;
expires_in: number;
}): void {
this.accessToken = data.access_token;
if (data.refresh_token) {
this.refreshToken = data.refresh_token;
}
this.tokenExpiry = new Date(Date.now() + data.expires_in * 1000);
}
private clearTokens(): void {
this.accessToken = null;
this.refreshToken = null;
this.tokenExpiry = null;
}
// Create a fetch wrapper that automatically handles auth
createAuthenticatedFetch(): typeof fetch {
return async (input: RequestInfo | URL, init?: RequestInit) => {
const token = await this.getAccessToken();
const headers = new Headers(init?.headers);
headers.set('Authorization', `Bearer ${token}`);
return fetch(input, {
...init,
headers
});
};
}
}
class AuthenticationError extends Error {
constructor(message: string) {
super(message);
this.name = 'AuthenticationError';
}
}
AI-Generated API Mocks for Testing
Testing requires realistic API mocks. AI can generate comprehensive mocks from your API specifications:
// Prompt: "Generate MSW handlers and realistic test data factories
// from this API client interface"
import { http, HttpResponse, delay } from 'msw';
import { setupServer } from 'msw/node';
import { faker } from '@faker-js/faker';
// Data factories for realistic test data
export const userFactory = {
create: (overrides?: Partial<User>): User => ({
id: faker.string.uuid(),
email: faker.internet.email(),
name: faker.person.fullName(),
createdAt: faker.date.past().toISOString(),
...overrides
}),
createMany: (count: number, overrides?: Partial<User>): User[] =>
Array.from({ length: count }, () => userFactory.create(overrides))
};
export const postFactory = {
create: (overrides?: Partial<Post>): Post => ({
id: faker.string.uuid(),
title: faker.lorem.sentence(),
content: faker.lorem.paragraphs(3),
published: faker.datatype.boolean(),
authorId: faker.string.uuid(),
createdAt: faker.date.past().toISOString(),
...overrides
}),
createMany: (count: number, overrides?: Partial<Post>): Post[] =>
Array.from({ length: count }, () => postFactory.create(overrides))
};
// MSW handlers with realistic behavior
export const createApiHandlers = (options?: {
latency?: number | [number, number];
errorRate?: number;
}) => {
const { latency = [50, 200], errorRate = 0 } = options ?? {};
const getLatency = () => {
if (Array.isArray(latency)) {
return faker.number.int({ min: latency[0], max: latency[1] });
}
return latency;
};
const shouldError = () => Math.random() < errorRate;
// In-memory database for stateful testing
const db = {
users: new Map<string, User>(),
posts: new Map<string, Post>()
};
// Seed initial data
userFactory.createMany(10).forEach(user => db.users.set(user.id, user));
postFactory.createMany(20).forEach(post => db.posts.set(post.id, post));
return [
// GET /users/:id
http.get('*/api/users/:id', async ({ params }) => {
await delay(getLatency());
if (shouldError()) {
return HttpResponse.json(
{ error: 'Internal Server Error' },
{ status: 500 }
);
}
const user = db.users.get(params.id as string);
if (!user) {
return HttpResponse.json(
{ error: 'User not found' },
{ status: 404 }
);
}
return HttpResponse.json(user);
}),
// GET /users
http.get('*/api/users', async ({ request }) => {
await delay(getLatency());
if (shouldError()) {
return HttpResponse.json(
{ error: 'Internal Server Error' },
{ status: 500 }
);
}
const url = new URL(request.url);
const limit = parseInt(url.searchParams.get('limit') ?? '10');
const offset = parseInt(url.searchParams.get('offset') ?? '0');
const users = Array.from(db.users.values())
.slice(offset, offset + limit);
return HttpResponse.json({
data: users,
pagination: {
total: db.users.size,
limit,
offset,
hasMore: offset + limit < db.users.size
}
});
}),
// POST /users
http.post('*/api/users', async ({ request }) => {
await delay(getLatency());
if (shouldError()) {
return HttpResponse.json(
{ error: 'Internal Server Error' },
{ status: 500 }
);
}
const body = await request.json() as Partial<User>;
// Validation
if (!body.email || !body.name) {
return HttpResponse.json(
{ error: 'email and name are required' },
{ status: 400 }
);
}
const user = userFactory.create(body);
db.users.set(user.id, user);
return HttpResponse.json(user, { status: 201 });
}),
// PATCH /users/:id
http.patch('*/api/users/:id', async ({ params, request }) => {
await delay(getLatency());
const user = db.users.get(params.id as string);
if (!user) {
return HttpResponse.json(
{ error: 'User not found' },
{ status: 404 }
);
}
const updates = await request.json() as Partial<User>;
const updatedUser = { ...user, ...updates };
db.users.set(user.id, updatedUser);
return HttpResponse.json(updatedUser);
}),
// DELETE /users/:id
http.delete('*/api/users/:id', async ({ params }) => {
await delay(getLatency());
const deleted = db.users.delete(params.id as string);
if (!deleted) {
return HttpResponse.json(
{ error: 'User not found' },
{ status: 404 }
);
}
return new HttpResponse(null, { status: 204 });
}),
// Rate limiting simulation
http.all('*/api/*', async ({ request }) => {
const rateLimitHit = faker.number.int(100) < 5; // 5% rate limit
if (rateLimitHit) {
return HttpResponse.json(
{ error: 'Rate limit exceeded' },
{
status: 429,
headers: {
'Retry-After': '60',
'X-RateLimit-Remaining': '0',
'X-RateLimit-Reset': String(Date.now() + 60000)
}
}
);
}
})
];
};
// Test utilities
export const setupMockServer = (options?: Parameters<typeof createApiHandlers>[0]) => {
const handlers = createApiHandlers(options);
return setupServer(...handlers);
};
// Usage in tests
describe('UserApiClient', () => {
const server = setupMockServer({ latency: 0 });
beforeAll(() => server.listen());
afterEach(() => server.resetHandlers());
afterAll(() => server.close());
it('should fetch a user by ID', async () => {
const client = new UserApiClient({ baseUrl: 'http://test/api' });
const user = await client.getUserById('test-id');
expect(user).toHaveProperty('id');
expect(user).toHaveProperty('email');
});
it('should handle 404 errors', async () => {
const client = new UserApiClient({ baseUrl: 'http://test/api' });
await expect(client.getUserById('non-existent'))
.rejects.toThrow(ApiError);
});
it('should retry on 500 errors', async () => {
const errorServer = setupMockServer({ errorRate: 0.5 });
errorServer.listen();
const client = new UserApiClient({
baseUrl: 'http://test/api',
maxRetries: 3
});
// Should eventually succeed despite errors
const user = await client.getUserById('test-id');
expect(user).toBeDefined();
errorServer.close();
});
});
Generating SDK Documentation
AI can generate comprehensive documentation from your API client code:
// Prompt: "Generate TSDoc comments and a README from this API client.
// Include usage examples, error handling guidance, and configuration options."
/**
* UserApiClient - A type-safe client for the User API
*
* @example Basic Usage
* ```typescript
* const client = new UserApiClient({
* baseUrl: 'https://api.example.com/v1',
* apiKey: process.env.API_KEY
* });
*
* // Fetch a user
* const user = await client.getUserById('user-123');
* console.log(user.email);
*
* // Create a user
* const newUser = await client.createUser({
* email: 'john@example.com',
* name: 'John Doe'
* });
* ```
*
* @example Error Handling
* ```typescript
* try {
* const user = await client.getUserById('user-123');
* } catch (error) {
* if (error instanceof ApiError) {
* console.error(`API Error ${error.statusCode}: ${error.message}`);
* if (error.statusCode === 404) {
* // Handle not found
* }
* } else if (error instanceof ValidationError) {
* console.error('Response validation failed:', error.zodError);
* } else if (error instanceof NetworkError) {
* console.error('Network error after retries:', error.message);
* }
* }
* ```
*
* @example Custom Configuration
* ```typescript
* const client = new UserApiClient({
* baseUrl: 'https://api.example.com/v1',
* apiKey: process.env.API_KEY,
* timeout: 60000, // 60 seconds
* maxRetries: 5,
* onRequestStart: (endpoint) => {
* console.log(`Starting request to ${endpoint}`);
* },
* onRequestEnd: (endpoint, duration) => {
* metrics.recordApiLatency(endpoint, duration);
* }
* });
* ```
*
* @see {@link ApiClientConfig} for all configuration options
* @see {@link UserSchema} for response validation schema
*/
export class UserApiClient {
/**
* Creates a new UserApiClient instance
*
* @param config - Client configuration options
* @param config.baseUrl - The base URL of the API (required)
* @param config.apiKey - API key for authentication
* @param config.timeout - Request timeout in milliseconds (default: 30000)
* @param config.maxRetries - Maximum retry attempts for failed requests (default: 3)
* @param config.onRequestStart - Callback fired when a request starts
* @param config.onRequestEnd - Callback fired when a request completes
*
* @throws {Error} If baseUrl is not provided
*/
constructor(config: ApiClientConfig) {
// ...
}
/**
* Fetches a user by their unique identifier
*
* @param id - The unique identifier of the user
* @returns Promise resolving to the User object
*
* @throws {ApiError} When the API returns a non-2xx response
* @throws {ValidationError} When the response doesn't match expected schema
* @throws {NetworkError} When the request fails after all retries
*
* @example
* ```typescript
* const user = await client.getUserById('user-123');
* console.log(`Found user: ${user.name} (${user.email})`);
* ```
*/
async getUserById(id: string): Promise<User> {
// ...
}
/**
* Creates a new user
*
* @param data - The user data to create
* @param data.email - User's email address (must be unique)
* @param data.name - User's display name
* @returns Promise resolving to the created User object
*
* @throws {ApiError} With status 400 if email is already taken
* @throws {ApiError} With status 422 if validation fails
*
* @example
* ```typescript
* const user = await client.createUser({
* email: 'jane@example.com',
* name: 'Jane Doe'
* });
* console.log(`Created user with ID: ${user.id}`);
* ```
*/
async createUser(data: Omit<User, 'id' | 'createdAt'>): Promise<User> {
// ...
}
}
AI-Generated vs Hand-Written: Quality Comparison
Let's honestly compare AI-generated SDKs with hand-written ones:
| Aspect | AI-Generated | Hand-Written |
|---|---|---|
| Speed | Minutes to generate | Hours to days |
| Type Safety | Excellent with good prompts | Depends on author skill |
| Edge Cases | Often misses subtle cases | Better coverage with experience |
| Error Handling | Generic patterns | API-specific handling |
| Documentation | Comprehensive but generic | Context-aware, targeted |
| Maintenance | Easy to regenerate | Manual updates required |
The best approach combines both: use AI to generate the initial client and boilerplate, then manually refine authentication flows, edge case handling, and performance-critical paths.
Key Takeaways
Remember These Points
- Provide schema context: Give AI your OpenAPI spec, GraphQL schema, or proto definition for best results
- Request Zod validation: Always ask for runtime validation to catch API response changes
- Specify retry patterns: Ask for exponential backoff with jitter and Retry-After header support
- Include authentication: Request token refresh handling and concurrent request deduplication
- Generate mocks too: Use AI to create MSW handlers and test data factories alongside the client
- Review auth code carefully: Authentication flows need human review for security implications
- Iterate on edge cases: AI may miss timeout handling, partial responses, and streaming edge cases
Conclusion
AI has transformed API integration from tedious boilerplate work to rapid client generation. By providing good schema context and specifying patterns for retry logic, authentication, and error handling, you can generate production-quality API clients in minutes.
The key is knowing what to delegate to AI (type generation, boilerplate, mock data) versus what requires human attention (security flows, edge cases, performance optimization). Use the patterns in this guide as starting points, then refine based on your specific API's quirks and requirements.
For related topics, see our guides on API Documentation Drift and API Rate Limiting.