Como construir um SDK para sua API pública em TypeScript

1. Fundamentos e planejamento do SDK

Antes de escrever uma linha de código, é essencial definir o escopo do SDK. Um SDK bem projetado deve encapsular os endpoints da sua API pública de forma intuitiva, sem vazar detalhes de implementação. A primeira decisão é escolher entre um SDK monolítico (uma única classe com todos os métodos) ou modular (separação por recursos como users, products, orders). Para APIs com mais de 10 endpoints, a abordagem modular é fortemente recomendada.

O versionamento semântico (SemVer) deve espelhar a versão da API. Se sua API está na v2, seu SDK deve começar como 2.0.0. Isso evita confusão quando a API sofre breaking changes.

// Exemplo de estrutura modular de recursos
src/
  client/
    ApiClient.ts
    types.ts
  resources/
    Users.ts
    Products.ts
    Orders.ts
  index.ts

2. Configuração do projeto TypeScript

A estrutura de diretórios padrão para bibliotecas TypeScript inclui src/ para código fonte, types/ para declarações de tipos compartilhados, tests/ para testes e dist/ para o build final. O tsconfig.json deve ser configurado com strict: true e declaration: true para gerar arquivos .d.ts.

// tsconfig.json básico para SDK
{
  "compilerOptions": {
    "target": "ES2020",
    "module": "commonjs",
    "lib": ["ES2020"],
    "outDir": "./dist",
    "rootDir": "./src",
    "strict": true,
    "esModuleInterop": true,
    "declaration": true,
    "declarationMap": true,
    "sourceMap": true
  },
  "include": ["src/**/*"],
  "exclude": ["node_modules", "tests"]
}

Dependências mínimas: axios para requisições HTTP (ou node-fetch para ambientes sem fetch nativo) e uuid para IDs únicos em operações de batch. Evite dependências pesadas que aumentem o bundle size.

3. Definição de tipos e interfaces

Tipos fortes são a alma de um SDK TypeScript. Cada endpoint da API deve ter interfaces correspondentes para payloads de requisição e resposta. A configuração do cliente também deve ser tipada.

// types/api.ts
export interface ApiConfig {
  baseURL: string;
  apiKey?: string;
  accessToken?: string;
  timeout?: number;
  headers?: Record<string, string>;
}

export interface PaginatedResponse<T> {
  data: T[];
  total: number;
  page: number;
  perPage: number;
  hasMore: boolean;
}

export interface ApiError {
  status: number;
  message: string;
  code?: string;
  details?: Record<string, string[]>;
}

// types/users.ts
export interface User {
  id: string;
  name: string;
  email: string;
  createdAt: string;
}

export interface CreateUserPayload {
  name: string;
  email: string;
  password: string;
}

export interface UpdateUserPayload {
  name?: string;
  email?: string;
}

4. Cliente HTTP e tratamento de requisições

A classe ApiClient centraliza toda a comunicação HTTP. Métodos genéricos como get<T>(), post<T>(), put<T>() e delete<T>() garantem reutilização com tipagem completa. Interceptors são ideais para injetar tokens de autenticação automaticamente.

// client/ApiClient.ts
import axios, { AxiosInstance, AxiosRequestConfig, AxiosResponse } from 'axios';
import { ApiConfig, ApiError } from '../types/api';

export class ApiClient {
  private client: AxiosInstance;
  private config: ApiConfig;

  constructor(config: ApiConfig) {
    this.config = {
      timeout: 10000,
      ...config,
    };

    this.client = axios.create({
      baseURL: this.config.baseURL,
      timeout: this.config.timeout,
      headers: {
        'Content-Type': 'application/json',
        ...this.config.headers,
      },
    });

    this.setupInterceptors();
  }

  private setupInterceptors(): void {
    this.client.interceptors.request.use((config) => {
      if (this.config.apiKey) {
        config.headers['X-API-Key'] = this.config.apiKey;
      }
      if (this.config.accessToken) {
        config.headers['Authorization'] = `Bearer ${this.config.accessToken}`;
      }
      return config;
    });

    this.client.interceptors.response.use(
      (response) => response,
      (error) => {
        const apiError: ApiError = {
          status: error.response?.status || 500,
          message: error.response?.data?.message || error.message,
          code: error.response?.data?.code,
          details: error.response?.data?.details,
        };
        return Promise.reject(apiError);
      }
    );
  }

  async get<T>(url: string, config?: AxiosRequestConfig): Promise<T> {
    const response: AxiosResponse<T> = await this.client.get(url, config);
    return response.data;
  }

  async post<T>(url: string, data?: unknown, config?: AxiosRequestConfig): Promise<T> {
    const response: AxiosResponse<T> = await this.client.post(url, data, config);
    return response.data;
  }

  async put<T>(url: string, data?: unknown, config?: AxiosRequestConfig): Promise<T> {
    const response: AxiosResponse<T> = await this.client.put(url, data, config);
    return response.data;
  }

  async delete<T>(url: string, config?: AxiosRequestConfig): Promise<T> {
    const response: AxiosResponse<T> = await this.client.delete(url, config);
    return response.data;
  }
}

Para retry com backoff exponencial, utilize uma biblioteca como axios-retry ou implemente um wrapper simples:

// client/retryHandler.ts
import axios from 'axios';

const MAX_RETRIES = 3;
const BASE_DELAY = 1000;

export async function withRetry<T>(
  fn: () => Promise<T>,
  retries = MAX_RETRIES
): Promise<T> {
  try {
    return await fn();
  } catch (error) {
    if (retries === 0 || !axios.isAxiosError(error)) {
      throw error;
    }
    const delay = BASE_DELAY * Math.pow(2, MAX_RETRIES - retries);
    await new Promise((resolve) => setTimeout(resolve, delay));
    return withRetry(fn, retries - 1);
  }
}

5. Métodos de alto nível para recursos da API

Cada recurso da API vira uma classe que recebe o ApiClient como dependência. Métodos assíncronos tipados expõem a funcionalidade completa com parâmetros de filtro e paginação.

// resources/Users.ts
import { ApiClient } from '../client/ApiClient';
import { PaginatedResponse } from '../types/api';
import { User, CreateUserPayload, UpdateUserPayload } from '../types/users';

export class Users {
  constructor(private client: ApiClient) {}

  async list(params?: {
    page?: number;
    perPage?: number;
    search?: string;
  }): Promise<PaginatedResponse<User>> {
    return this.client.get<PaginatedResponse<User>>('/users', { params });
  }

  async get(id: string): Promise<User> {
    return this.client.get<User>(`/users/${id}`);
  }

  async create(payload: CreateUserPayload): Promise<User> {
    return this.client.post<User>('/users', payload);
  }

  async update(id: string, payload: UpdateUserPayload): Promise<User> {
    return this.client.put<User>(`/users/${id}`, payload);
  }

  async delete(id: string): Promise<void> {
    return this.client.delete<void>(`/users/${id}`);
  }

  async batchCreate(users: CreateUserPayload[]): Promise<User[]> {
    return this.client.post<User[]>('/users/batch', { users });
  }
}

6. Gerenciamento de autenticação e tokens

Para APIs que usam OAuth2, implemente um fluxo de login que retorna tokens de acesso e refresh. O SDK deve renovar tokens expirados automaticamente.

// auth/OAuth2Client.ts
import { ApiClient } from '../client/ApiClient';

interface TokenResponse {
  accessToken: string;
  refreshToken: string;
  expiresIn: number;
}

export class OAuth2Client {
  private client: ApiClient;
  private accessToken: string = '';
  private refreshToken: string = '';
  private expiresAt: number = 0;

  constructor(baseURL: string) {
    this.client = new ApiClient({ baseURL });
  }

  async login(email: string, password: string): Promise<void> {
    const response = await this.client.post<TokenResponse>('/auth/login', {
      email,
      password,
    });
    this.setTokens(response);
  }

  private setTokens(response: TokenResponse): void {
    this.accessToken = response.accessToken;
    this.refreshToken = response.refreshToken;
    this.expiresAt = Date.now() + response.expiresIn * 1000;
    this.client.config.accessToken = this.accessToken;
  }

  async refreshAccessToken(): Promise<void> {
    const response = await this.client.post<TokenResponse>('/auth/refresh', {
      refreshToken: this.refreshToken,
    });
    this.setTokens(response);
  }

  isTokenExpired(): boolean {
    return Date.now() >= this.expiresAt;
  }
}

7. Testes e documentação do SDK

Testes unitários com Jest e nock garantem que o SDK funciona sem depender do servidor real.

// tests/Users.test.ts
import nock from 'nock';
import { ApiClient } from '../src/client/ApiClient';
import { Users } from '../src/resources/Users';

const BASE_URL = 'https://api.example.com/v2';

describe('Users resource', () => {
  let client: ApiClient;
  let users: Users;

  beforeEach(() => {
    client = new ApiClient({ baseURL: BASE_URL, apiKey: 'test-key' });
    users = new Users(client);
  });

  afterEach(() => {
    nock.cleanAll();
  });

  it('should list users with pagination', async () => {
    const mockResponse = {
      data: [{ id: '1', name: 'John', email: 'john@test.com', createdAt: '2024-01-01' }],
      total: 1,
      page: 1,
      perPage: 10,
      hasMore: false,
    };

    nock(BASE_URL)
      .get('/users')
      .query({ page: 1, perPage: 10 })
      .reply(200, mockResponse);

    const result = await users.list({ page: 1, perPage: 10 });
    expect(result.data).toHaveLength(1);
    expect(result.total).toBe(1);
  });
});

Para documentação, use TypeDoc para gerar HTML a partir dos comentários JSDoc:

// Comentário JSDoc para método público
/**
 * Cria um novo usuário.
 * @param payload - Dados do usuário a ser criado
 * @returns O usuário recém-criado
 * @throws {ApiError} Se os dados forem inválidos
 */
async create(payload: CreateUserPayload): Promise<User> {
  return this.client.post<User>('/users', payload);
}

8. Publicação e manutenção contínua

Configure scripts no package.json para build e publicação. Use semantic-release para automatizar changelogs e versionamento.

// package.json (trecho relevante)
{
  "main": "dist/index.js",
  "types": "dist/index.d.ts",
  "scripts": {
    "build": "tsc",
    "test": "jest",
    "lint": "eslint src/",
    "prepublishOnly": "npm run build && npm test",
    "semantic-release": "semantic-release"
  },
  "files": ["dist/"]
}

Para CI, um workflow GitHub Actions simples:

# .github/workflows/ci.yml
name: CI
on: [push, pull_request]
jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: 20
      - run: npm ci
      - run: npm run lint
      - run: npm test
      - run: npm run build

Estratégias de depreciação: quando um método for obsoleto, use o decorator @deprecated no JSDoc e mantenha-o por pelo menos duas versões menores antes de removê-lo.


Referências