بناء أدوات المطورين
بناء مكتبات عملاء API
Project Goal: Create a professional API client library that other developers will love using.
Why Build API Client Libraries?
API clients are valuable because:
- Solve real problems - Make API integration effortless
- High reuse - Used across many projects
- TypeScript-friendly - Types improve DX significantly
- npm distribution - Easy to share and version
API Client Project Prompt
Create a TypeScript API client library for [API Name]:
## Features
- Type-safe request/response handling
- Automatic retry with exponential backoff
- Rate limiting support
- Request/response interceptors
- Error classification
- Authentication handling (API key, OAuth, etc.)
- Pagination helpers
- Comprehensive logging options
## Tech Stack
- TypeScript with strict mode
- fetch or axios for HTTP
- Zod for runtime validation
- tsup for building
- Vitest for testing
## Project Structure
/src /client # Main client class /resources # API resource classes /types # TypeScript types /utils # Utilities (retry, rate-limit) /errors # Custom error classes index.ts # Public exports /examples # Usage examples
## API Endpoints to Support
[List the main API endpoints]
## Authentication Method
[Describe auth: API key, OAuth, Bearer token, etc.]
Project Setup
// package.json
{
"name": "@yourorg/api-client",
"version": "1.0.0",
"description": "Type-safe API client",
"type": "module",
"main": "./dist/index.js",
"module": "./dist/index.js",
"types": "./dist/index.d.ts",
"exports": {
".": {
"import": "./dist/index.js",
"types": "./dist/index.d.ts"
}
},
"files": ["dist"],
"scripts": {
"build": "tsup src/index.ts --format esm --dts --clean",
"dev": "tsup src/index.ts --format esm --dts --watch",
"test": "vitest",
"test:coverage": "vitest run --coverage",
"lint": "eslint src --ext .ts",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"zod": "^3.23.8"
},
"devDependencies": {
"@types/node": "^22.9.0",
"tsup": "^8.3.5",
"typescript": "^5.6.3",
"vitest": "^2.1.5"
},
"peerDependencies": {
"typescript": ">=5.0.0"
}
}
Type Definitions
// src/types/index.ts
import { z } from 'zod';
// API Response schemas
export const userSchema = z.object({
id: z.string(),
email: z.string().email(),
name: z.string(),
avatar: z.string().url().optional(),
createdAt: z.string().datetime(),
updatedAt: z.string().datetime(),
});
export const paginatedResponseSchema = <T extends z.ZodTypeAny>(itemSchema: T) =>
z.object({
data: z.array(itemSchema),
pagination: z.object({
page: z.number(),
pageSize: z.number(),
totalPages: z.number(),
totalItems: z.number(),
hasMore: z.boolean(),
}),
});
export type User = z.infer<typeof userSchema>;
export type PaginatedResponse<T> = {
data: T[];
pagination: {
page: number;
pageSize: number;
totalPages: number;
totalItems: number;
hasMore: boolean;
};
};
// Request types
export interface RequestConfig {
timeout?: number;
retries?: number;
headers?: Record<string, string>;
}
export interface PaginationParams {
page?: number;
pageSize?: number;
sortBy?: string;
sortOrder?: 'asc' | 'desc';
}
// Client configuration
export interface ClientConfig {
apiKey: string;
baseUrl?: string;
timeout?: number;
maxRetries?: number;
debug?: boolean;
onRequest?: (request: Request) => Request | Promise<Request>;
onResponse?: (response: Response) => Response | Promise<Response>;
onError?: (error: Error) => void;
}
Custom Errors
// src/errors/index.ts
export class ApiError extends Error {
constructor(
message: string,
public readonly statusCode: number,
public readonly code: string,
public readonly details?: unknown
) {
super(message);
this.name = 'ApiError';
}
static fromResponse(response: Response, body: unknown): ApiError {
const details = body as { message?: string; code?: string; details?: unknown };
return new ApiError(
details?.message || response.statusText,
response.status,
details?.code || 'UNKNOWN_ERROR',
details?.details
);
}
}
export class AuthenticationError extends ApiError {
constructor(message = 'Authentication failed') {
super(message, 401, 'AUTHENTICATION_ERROR');
this.name = 'AuthenticationError';
}
}
export class RateLimitError extends ApiError {
constructor(
public readonly retryAfter: number,
message = 'Rate limit exceeded'
) {
super(message, 429, 'RATE_LIMIT_ERROR');
this.name = 'RateLimitError';
}
}
export class ValidationError extends ApiError {
constructor(message: string, details?: unknown) {
super(message, 400, 'VALIDATION_ERROR', details);
this.name = 'ValidationError';
}
}
export class NotFoundError extends ApiError {
constructor(resource: string) {
super(`${resource} not found`, 404, 'NOT_FOUND');
this.name = 'NotFoundError';
}
}
HTTP Client Core
// src/client/http.ts
import {
ApiError,
AuthenticationError,
RateLimitError,
} from '../errors/index.js';
import type { ClientConfig, RequestConfig } from '../types/index.js';
export class HttpClient {
private config: Required<Omit<ClientConfig, 'onRequest' | 'onResponse' | 'onError'>> &
Pick<ClientConfig, 'onRequest' | 'onResponse' | 'onError'>;
constructor(config: ClientConfig) {
this.config = {
apiKey: config.apiKey,
baseUrl: config.baseUrl || 'https://api.example.com/v1',
timeout: config.timeout || 30000,
maxRetries: config.maxRetries || 3,
debug: config.debug || false,
onRequest: config.onRequest,
onResponse: config.onResponse,
onError: config.onError,
};
}
async request<T>(
method: string,
path: string,
options: {
body?: unknown;
params?: Record<string, string | number | boolean | undefined>;
config?: RequestConfig;
} = {}
): Promise<T> {
const url = this.buildUrl(path, options.params);
const retries = options.config?.retries ?? this.config.maxRetries;
let lastError: Error | null = null;
for (let attempt = 0; attempt <= retries; attempt++) {
try {
const response = await this.executeRequest(method, url, options);
return response as T;
} catch (error) {
lastError = error as Error;
// Don't retry on auth errors or validation errors
if (
error instanceof AuthenticationError ||
error instanceof ApiError && error.statusCode < 500
) {
throw error;
}
// Handle rate limiting
if (error instanceof RateLimitError) {
if (attempt < retries) {
await this.delay(error.retryAfter * 1000);
continue;
}
}
// Exponential backoff for other errors
if (attempt < retries) {
const backoff = Math.min(1000 * Math.pow(2, attempt), 10000);
this.log(`Retry ${attempt + 1}/${retries} after ${backoff}ms`);
await this.delay(backoff);
}
}
}
this.config.onError?.(lastError!);
throw lastError;
}
private async executeRequest(
method: string,
url: URL,
options: { body?: unknown; config?: RequestConfig }
): Promise<unknown> {
const headers = new Headers({
'Content-Type': 'application/json',
'Authorization': `Bearer ${this.config.apiKey}`,
...options.config?.headers,
});
let request = new Request(url.toString(), {
method,
headers,
body: options.body ? JSON.stringify(options.body) : undefined,
});
// Apply request interceptor
if (this.config.onRequest) {
request = await this.config.onRequest(request);
}
const controller = new AbortController();
const timeout = options.config?.timeout ?? this.config.timeout;
const timeoutId = setTimeout(() => controller.abort(), timeout);
try {
this.log(`${method} ${url.pathname}`);
let response = await fetch(request, { signal: controller.signal });
// Apply response interceptor
if (this.config.onResponse) {
response = await this.config.onResponse(response);
}
const body = await response.json().catch(() => null);
if (!response.ok) {
this.handleErrorResponse(response, body);
}
return body;
} finally {
clearTimeout(timeoutId);
}
}
private handleErrorResponse(response: Response, body: unknown): never {
if (response.status === 401) {
throw new AuthenticationError();
}
if (response.status === 429) {
const retryAfter = parseInt(
response.headers.get('Retry-After') || '60',
10
);
throw new RateLimitError(retryAfter);
}
throw ApiError.fromResponse(response, body);
}
private buildUrl(
path: string,
params?: Record<string, string | number | boolean | undefined>
): URL {
const url = new URL(path, this.config.baseUrl);
if (params) {
Object.entries(params).forEach(([key, value]) => {
if (value !== undefined) {
url.searchParams.set(key, String(value));
}
});
}
return url;
}
private delay(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
private log(message: string): void {
if (this.config.debug) {
console.log(`[API Client] ${message}`);
}
}
// Convenience methods
get<T>(path: string, params?: Record<string, string | number | boolean | undefined>, config?: RequestConfig) {
return this.request<T>('GET', path, { params, config });
}
post<T>(path: string, body?: unknown, config?: RequestConfig) {
return this.request<T>('POST', path, { body, config });
}
put<T>(path: string, body?: unknown, config?: RequestConfig) {
return this.request<T>('PUT', path, { body, config });
}
patch<T>(path: string, body?: unknown, config?: RequestConfig) {
return this.request<T>('PATCH', path, { body, config });
}
delete<T>(path: string, config?: RequestConfig) {
return this.request<T>('DELETE', path, { config });
}
}
Resource Classes
// src/resources/users.ts
import type { HttpClient } from '../client/http.js';
import { userSchema, paginatedResponseSchema } from '../types/index.js';
import type { User, PaginatedResponse, PaginationParams } from '../types/index.js';
export class UsersResource {
constructor(private http: HttpClient) {}
async list(params?: PaginationParams): Promise<PaginatedResponse<User>> {
const response = await this.http.get<unknown>('/users', params);
return paginatedResponseSchema(userSchema).parse(response);
}
async get(id: string): Promise<User> {
const response = await this.http.get<unknown>(`/users/${id}`);
return userSchema.parse(response);
}
async create(data: {
email: string;
name: string;
password: string;
}): Promise<User> {
const response = await this.http.post<unknown>('/users', data);
return userSchema.parse(response);
}
async update(
id: string,
data: Partial<{ email: string; name: string }>
): Promise<User> {
const response = await this.http.patch<unknown>(`/users/${id}`, data);
return userSchema.parse(response);
}
async delete(id: string): Promise<void> {
await this.http.delete(`/users/${id}`);
}
// Pagination helper
async *listAll(pageSize = 100): AsyncGenerator<User, void, unknown> {
let page = 1;
let hasMore = true;
while (hasMore) {
const response = await this.list({ page, pageSize });
for (const user of response.data) {
yield user;
}
hasMore = response.pagination.hasMore;
page++;
}
}
}
Main Client Class
// src/client/index.ts
import { HttpClient } from './http.js';
import { UsersResource } from '../resources/users.js';
import type { ClientConfig } from '../types/index.js';
export class ApiClient {
private http: HttpClient;
// Resources
public readonly users: UsersResource;
constructor(config: ClientConfig) {
this.http = new HttpClient(config);
// Initialize resources
this.users = new UsersResource(this.http);
}
// Factory method for convenience
static create(apiKey: string, options?: Omit<ClientConfig, 'apiKey'>) {
return new ApiClient({ apiKey, ...options });
}
}
// Re-export types and errors
export * from '../types/index.js';
export * from '../errors/index.js';
Public Exports
// src/index.ts
export { ApiClient } from './client/index.js';
export type {
User,
PaginatedResponse,
PaginationParams,
ClientConfig,
RequestConfig,
} from './types/index.js';
export {
ApiError,
AuthenticationError,
RateLimitError,
ValidationError,
NotFoundError,
} from './errors/index.js';
Usage Examples
// examples/basic-usage.ts
import { ApiClient, ApiError, RateLimitError } from '@yourorg/api-client';
// Initialize client
const client = ApiClient.create('your-api-key', {
debug: true,
maxRetries: 3,
});
// Basic CRUD operations
async function main() {
try {
// List users with pagination
const { data: users, pagination } = await client.users.list({
page: 1,
pageSize: 10,
});
console.log(`Found ${pagination.totalItems} users`);
// Get single user
const user = await client.users.get('user-123');
console.log(`User: ${user.name}`);
// Create user
const newUser = await client.users.create({
email: 'new@example.com',
name: 'New User',
password: 'securepassword',
});
// Update user
await client.users.update(newUser.id, { name: 'Updated Name' });
// Iterate all users (handles pagination automatically)
for await (const user of client.users.listAll()) {
console.log(user.email);
}
} catch (error) {
if (error instanceof RateLimitError) {
console.log(`Rate limited. Retry after ${error.retryAfter} seconds`);
} else if (error instanceof ApiError) {
console.error(`API Error: ${error.message} (${error.code})`);
} else {
throw error;
}
}
}
main();
Testing
// src/__tests__/client.test.ts
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { ApiClient, AuthenticationError, RateLimitError } from '../index.js';
describe('ApiClient', () => {
beforeEach(() => {
vi.resetAllMocks();
});
it('creates client with API key', () => {
const client = ApiClient.create('test-key');
expect(client).toBeDefined();
expect(client.users).toBeDefined();
});
it('handles authentication errors', async () => {
global.fetch = vi.fn().mockResolvedValue({
ok: false,
status: 401,
json: () => Promise.resolve({ message: 'Invalid API key' }),
});
const client = ApiClient.create('invalid-key');
await expect(client.users.list()).rejects.toThrow(AuthenticationError);
});
it('handles rate limiting with retry', async () => {
let attempts = 0;
global.fetch = vi.fn().mockImplementation(() => {
attempts++;
if (attempts === 1) {
return Promise.resolve({
ok: false,
status: 429,
headers: new Headers({ 'Retry-After': '1' }),
json: () => Promise.resolve({}),
});
}
return Promise.resolve({
ok: true,
json: () =>
Promise.resolve({
data: [],
pagination: { page: 1, pageSize: 10, totalPages: 0, totalItems: 0, hasMore: false },
}),
});
});
const client = ApiClient.create('test-key', { maxRetries: 2 });
await client.users.list();
expect(attempts).toBe(2);
});
});
Key Takeaways
- Zod validation ensures runtime type safety for API responses
- Error classification helps consumers handle different failure modes
- Automatic retries with exponential backoff improve reliability
- AsyncGenerator simplifies pagination consumption
- Request interceptors enable customization without modifying core logic
بناء مكتبات عملاء API
هدف المشروع: إنشاء مكتبة عميل API احترافية سيحب المطورون الآخرون استخدامها.
لماذا نبني مكتبات عملاء API؟
عملاء API قيّمون لأنهم:
- يحلون مشاكل حقيقية - يجعلون تكامل API بلا جهد
- إعادة استخدام عالية - تُستخدم عبر مشاريع كثيرة
- صديقة لـ TypeScript - الأنواع تحسن تجربة المطور بشكل كبير
- توزيع npm - سهلة المشاركة والإصدار
برومبت مشروع عميل API
أنشئ مكتبة عميل API بـ TypeScript لـ [اسم API]:
## الميزات
- معالجة طلب/استجابة آمنة الأنواع
- إعادة محاولة تلقائية مع تراجع أسي
- دعم تحديد المعدل
- اعتراضات الطلب/الاستجابة
- تصنيف الأخطاء
- معالجة المصادقة (مفتاح API، OAuth، إلخ)
- مساعدات التصفح بالصفحات
- خيارات تسجيل شاملة
## التقنيات
- TypeScript مع الوضع الصارم
- fetch أو axios لـ HTTP
- Zod للتحقق في وقت التشغيل
- tsup للبناء
- Vitest للاختبار
## هيكل المشروع
/src /client # فئة العميل الرئيسية /resources # فئات موارد API /types # أنواع TypeScript /utils # أدوات (إعادة المحاولة، تحديد المعدل) /errors # فئات الأخطاء المخصصة index.ts # الصادرات العامة /examples # أمثلة الاستخدام
## نقاط نهاية API المدعومة
[اذكر نقاط نهاية API الرئيسية]
## طريقة المصادقة
[صف المصادقة: مفتاح API، OAuth، رمز Bearer، إلخ]
تعريفات الأنواع
// src/types/index.ts
import { z } from 'zod';
// مخططات استجابة API
export const userSchema = z.object({
id: z.string(),
email: z.string().email(),
name: z.string(),
avatar: z.string().url().optional(),
createdAt: z.string().datetime(),
updatedAt: z.string().datetime(),
});
export const paginatedResponseSchema = <T extends z.ZodTypeAny>(itemSchema: T) =>
z.object({
data: z.array(itemSchema),
pagination: z.object({
page: z.number(),
pageSize: z.number(),
totalPages: z.number(),
totalItems: z.number(),
hasMore: z.boolean(),
}),
});
export type User = z.infer<typeof userSchema>;
export type PaginatedResponse<T> = {
data: T[];
pagination: {
page: number;
pageSize: number;
totalPages: number;
totalItems: number;
hasMore: boolean;
};
};
الأخطاء المخصصة
// src/errors/index.ts
export class ApiError extends Error {
constructor(
message: string,
public readonly statusCode: number,
public readonly code: string,
public readonly details?: unknown
) {
super(message);
this.name = 'ApiError';
}
static fromResponse(response: Response, body: unknown): ApiError {
const details = body as { message?: string; code?: string; details?: unknown };
return new ApiError(
details?.message || response.statusText,
response.status,
details?.code || 'UNKNOWN_ERROR',
details?.details
);
}
}
export class AuthenticationError extends ApiError {
constructor(message = 'فشلت المصادقة') {
super(message, 401, 'AUTHENTICATION_ERROR');
this.name = 'AuthenticationError';
}
}
export class RateLimitError extends ApiError {
constructor(
public readonly retryAfter: number,
message = 'تم تجاوز حد المعدل'
) {
super(message, 429, 'RATE_LIMIT_ERROR');
this.name = 'RateLimitError';
}
}
فئة العميل الرئيسية
// src/client/index.ts
import { HttpClient } from './http.js';
import { UsersResource } from '../resources/users.js';
import type { ClientConfig } from '../types/index.js';
export class ApiClient {
private http: HttpClient;
// الموارد
public readonly users: UsersResource;
constructor(config: ClientConfig) {
this.http = new HttpClient(config);
// تهيئة الموارد
this.users = new UsersResource(this.http);
}
// طريقة المصنع للراحة
static create(apiKey: string, options?: Omit<ClientConfig, 'apiKey'>) {
return new ApiClient({ apiKey, ...options });
}
}
// إعادة تصدير الأنواع والأخطاء
export * from '../types/index.js';
export * from '../errors/index.js';
أمثلة الاستخدام
// examples/basic-usage.ts
import { ApiClient, ApiError, RateLimitError } from '@yourorg/api-client';
// تهيئة العميل
const client = ApiClient.create('your-api-key', {
debug: true,
maxRetries: 3,
});
// عمليات CRUD الأساسية
async function main() {
try {
// قائمة المستخدمين مع التصفح
const { data: users, pagination } = await client.users.list({
page: 1,
pageSize: 10,
});
console.log(`وُجد ${pagination.totalItems} مستخدم`);
// الحصول على مستخدم واحد
const user = await client.users.get('user-123');
console.log(`المستخدم: ${user.name}`);
// إنشاء مستخدم
const newUser = await client.users.create({
email: 'new@example.com',
name: 'مستخدم جديد',
password: 'كلمة_مرور_آمنة',
});
// تحديث مستخدم
await client.users.update(newUser.id, { name: 'الاسم المحدث' });
// التكرار على جميع المستخدمين (يتعامل مع التصفح تلقائياً)
for await (const user of client.users.listAll()) {
console.log(user.email);
}
} catch (error) {
if (error instanceof RateLimitError) {
console.log(`تم تحديد المعدل. أعد المحاولة بعد ${error.retryAfter} ثانية`);
} else if (error instanceof ApiError) {
console.error(`خطأ API: ${error.message} (${error.code})`);
} else {
throw error;
}
}
}
main();
الاختبار
// src/__tests__/client.test.ts
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { ApiClient, AuthenticationError, RateLimitError } from '../index.js';
describe('ApiClient', () => {
beforeEach(() => {
vi.resetAllMocks();
});
it('ينشئ عميل بمفتاح API', () => {
const client = ApiClient.create('test-key');
expect(client).toBeDefined();
expect(client.users).toBeDefined();
});
it('يتعامل مع أخطاء المصادقة', async () => {
global.fetch = vi.fn().mockResolvedValue({
ok: false,
status: 401,
json: () => Promise.resolve({ message: 'مفتاح API غير صالح' }),
});
const client = ApiClient.create('invalid-key');
await expect(client.users.list()).rejects.toThrow(AuthenticationError);
});
it('يتعامل مع تحديد المعدل مع إعادة المحاولة', async () => {
let attempts = 0;
global.fetch = vi.fn().mockImplementation(() => {
attempts++;
if (attempts === 1) {
return Promise.resolve({
ok: false,
status: 429,
headers: new Headers({ 'Retry-After': '1' }),
json: () => Promise.resolve({}),
});
}
return Promise.resolve({
ok: true,
json: () =>
Promise.resolve({
data: [],
pagination: { page: 1, pageSize: 10, totalPages: 0, totalItems: 0, hasMore: false },
}),
});
});
const client = ApiClient.create('test-key', { maxRetries: 2 });
await client.users.list();
expect(attempts).toBe(2);
});
});
النقاط الرئيسية
- تحقق Zod يضمن سلامة الأنواع في وقت التشغيل لاستجابات API
- تصنيف الأخطاء يساعد المستهلكين على التعامل مع أوضاع الفشل المختلفة
- إعادة المحاولات التلقائية مع التراجع الأسي تحسن الموثوقية
- AsyncGenerator يبسط استهلاك التصفح بالصفحات
- اعتراضات الطلب تُمكّن التخصيص بدون تعديل المنطق الأساسي