‹ 首页

typescript-api-design

@martinffx · 收录于 1 周前

REST API design patterns. Use when designing endpoints, error responses, pagination, versioning, or API structure. Framework-agnostic principles for building consistent, maintainable APIs.

适合你,如果你正在设计或重构REST API,需要规范化的模式指导。

/ 下载安装
typescript-api-design.skill双击,或拖进 Claude 桌面版 / Cowork,即完成安装↓ .skill↓ .zip
用别的 agent?下载 .zip 解压,把文件夹放进它的技能目录
Claude Code~/.claude/skills/(项目级 .claude/skills/)
Codex CLI~/.codex/skills/
Cursor自动读取上面两处目录
其他工具见其文档的「skills」目录;两个下载是同一份文件,只是名字不同
/ 通过 npx 安装 校验哈希
npx oh-my-skill add martinffx/atelier/typescript-api-design
/ 通过 bash 安装
curl -fsSL https://oh-my-skill.com/install.sh | bash -s -- martinffx/atelier/typescript-api-design
/ 已经装过?验证本机副本,不用重装
npx oh-my-skill verify martinffx/atelier/typescript-api-design
安装目标可用 --agent / --scope 或 --to 明确指定;省略时只会在唯一已存在的 agent 目录上自动选择,零命中或多命中会停止并提示。content_hash 缺失或不一致均拒装。
34GitHub stars
~2K最小装载
~4.1K含声明引用
~4.1K文本包总量
镜像托管

怎么用

技能原文 SKILL.md作者撰写 · MIT · e5f96ba

API Design Patterns

Best practices for designing REST APIs with consistent structure, error handling, and resource patterns.

Additional References
  • [references/error-responses.md](./references/error-responses.md) - Detailed error handling examples
Resource Naming

Use consistent, predictable URL patterns:

# Collection resources (plural nouns)
GET    /api/v1/users              # List users
POST   /api/v1/users              # Create user
GET    /api/v1/users/:id          # Get user
PUT    /api/v1/users/:id          # Update user (full)
PATCH  /api/v1/users/:id          # Update user (partial)
DELETE /api/v1/users/:id          # Delete user

# Nested resources
GET    /api/v1/users/:userId/posts          # List user's posts
POST   /api/v1/users/:userId/posts          # Create post for user
GET    /api/v1/users/:userId/posts/:postId  # Get specific post

# Actions (use verbs sparingly)
POST   /api/v1/users/:id/activate          # Activate user
POST   /api/v1/posts/:id/publish           # Publish post
POST   /api/v1/invoices/:id/send           # Send invoice
Guidelines
  • Use plural nouns for collections (/users, not /user)
  • Use lowercase with hyphens for multi-word resources (/ledger-accounts)
  • Avoid deep nesting (max 2 levels: /users/:id/posts/:id)
  • Use query parameters for filtering, sorting, pagination
  • Use verbs only for actions that don't fit CRUD (activate, publish, send)
API Versioning

Version APIs in the URL path:

/api/v1/users
/api/v2/users

# Not in headers (harder to test/debug)
# Not in query params (breaks caching)
Version Strategy
// v1/routes.ts
export async function v1Routes(app: FastifyInstance) {
  app.get('/users', getUsersV1)
  app.post('/users', createUserV1)
}

// v2/routes.ts
export async function v2Routes(app: FastifyInstance) {
  app.get('/users', getUsersV2)  // Breaking change in response structure
  app.post('/users', createUserV2)
}

// server.ts
app.register(v1Routes, { prefix: '/api/v1' })
app.register(v2Routes, { prefix: '/api/v2' })
RFC 7807 Problem Details

Standardized error response format:

interface ProblemDetail {
  type: string          // Error type identifier
  status: number        // HTTP status code
  title: string         // Short, human-readable summary
  detail: string        // Specific explanation for this occurrence
  instance: string      // URI reference to specific occurrence
  traceId: string       // Request trace ID for debugging
}

// Example error response
{
  "type": "NOT_FOUND",
  "status": 404,
  "title": "Not Found",
  "detail": "User with ID usr_01h455vb4pex5vsknk084sn02q not found",
  "instance": "/api/v1/users/usr_01h455vb4pex5vsknk084sn02q",
  "traceId": "req_abc123xyz"
}
Error Types
// Domain error base class
abstract class AppError extends Error {
  abstract readonly status: number
  abstract readonly type: string

  constructor(message: string, public readonly context?: ErrorContext) {
    super(message)
  }

  toResponse(instance: string, traceId: string): ProblemDetail {
    return {
      type: this.type,
      status: this.status,
      title: this.name,
      detail: this.message,
      instance,
      traceId,
      ...this.context,
    }
  }
}

// Specific error types
class NotFoundError extends AppError {
  readonly status = 404
  readonly type = 'NOT_FOUND'
}

class ConflictError extends AppError {
  readonly status = 409
  readonly type = 'CONFLICT'

  constructor(
    message: string,
    public readonly retryable: boolean = false,
    context?: ErrorContext
  ) {
    super(message, context)
  }
}

class ServiceUnavailableError extends AppError {
  readonly status = 503
  readonly type = 'SERVICE_UNAVAILABLE'

  constructor(
    message: string,
    public readonly retryable: boolean = true,
    context?: ErrorContext
  ) {
    super(message, context)
  }
}

See [references/error-responses.md](./references/error-responses.md) for complete examples.

Pagination (Cursor-Based)

Use cursor-based pagination for large datasets:

// Request
GET /api/v1/posts?limit=20&cursor=pst_01h455vb4pex5vsknk084sn02q

// Response
{
  "items": [
    { "id": "pst_01h455w3x8k5z9y7q1m0n2b3c4", ... },
    { "id": "pst_01h455x2y9l6a0z8r2n1o3c5d6", ... }
  ],
  "nextCursor": "pst_01h455z1a0m7b8y9s3o2p4d6e7",
  "hasMore": true
}
Implementation
interface PaginatedRequest {
  limit?: number   // Max items to return (default 20, max 100)
  cursor?: string  // Cursor for next page (opaque to client)
}

interface PaginatedResponse<T> {
  items: T[]
  nextCursor?: string
  hasMore: boolean
}

async function listPosts(req: PaginatedRequest): Promise<PaginatedResponse<Post>> {
  const limit = Math.min(req.limit ?? 20, 100)
  const queryLimit = limit + 1  // Fetch one extra to check hasMore

  const posts = await db.query.posts.findMany({
    where: req.cursor ? gt(posts.id, req.cursor) : undefined,
    orderBy: desc(posts.createdAt),
    limit: queryLimit,
  })

  const hasMore = posts.length > limit
  const items = posts.slice(0, limit)
  const nextCursor = hasMore ? items[items.length - 1].id : undefined

  return { items, nextCursor, hasMore }
}
Why Cursor Over Offset
❌ Offset-based (/posts?offset=40&limit=20)
  - Unstable: Items can shift if new records inserted
  - Performance: DB must scan all previous rows
  - Inaccurate: Can miss or duplicate items

✅ Cursor-based (/posts?cursor=pst_xyz&limit=20)
  - Stable: Cursor points to specific item
  - Performant: DB uses index seek
  - Accurate: No gaps or duplicates
Filtering & Sorting

Use query parameters for filtering and sorting:

# Filtering
GET /api/v1/users?status=active&role=admin
GET /api/v1/posts?author=usr_abc&published=true

# Sorting
GET /api/v1/posts?sort=-createdAt      # Descending (- prefix)
GET /api/v1/users?sort=name             # Ascending

# Combined
GET /api/v1/posts?author=usr_abc&status=published&sort=-createdAt&limit=20
Implementation
interface ListPostsQuery {
  author?: string
  status?: 'draft' | 'published'
  sort?: 'createdAt' | '-createdAt' | 'title' | '-title'
  limit?: number
  cursor?: string
}

async function listPosts(query: ListPostsQuery): Promise<PaginatedResponse<Post>> {
  const conditions = []

  if (query.author) {
    conditions.push(eq(posts.authorId, query.author))
  }
  if (query.status) {
    conditions.push(eq(posts.status, query.status))
  }

  const orderByColumn = query.sort?.startsWith('-')
    ? query.sort.slice(1)
    : query.sort ?? 'createdAt'
  const orderByDirection = query.sort?.startsWith('-') ? desc : asc

  return await db.query.posts.findMany({
    where: conditions.length > 0 ? and(...conditions) : undefined,
    orderBy: orderByDirection(posts[orderByColumn]),
    limit: query.limit ?? 20,
  })
}
HTTP Status Codes

Use status codes consistently:

# Success
200 OK               # Successful GET, PUT, PATCH
201 Created          # Successful POST (include Location header)
204 No Content       # Successful DELETE, PUT with no response body

# Client Errors
400 Bad Request      # Invalid request body/parameters
401 Unauthorized     # Missing or invalid authentication
403 Forbidden        # Valid auth, but lacks permission
404 Not Found        # Resource doesn't exist
409 Conflict         # Resource already exists, optimistic lock failure
422 Unprocessable    # Validation error (semantic)
429 Too Many Requests # Rate limit exceeded

# Server Errors
500 Internal Server Error  # Unexpected error
503 Service Unavailable    # Temporary unavailability, retry later
Response Envelope (When to Use)

Don't use envelopes for simple CRUD:

// ❌ Unnecessary wrapping
GET /api/v1/users/123
{
  "success": true,
  "data": { "id": "123", "name": "Alice" }
}

// ✅ Return resource directly
GET /api/v1/users/123
{
  "id": "123",
  "name": "Alice"
}

Use envelopes for pagination:

// ✅ Envelope needed for metadata
GET /api/v1/users?limit=20
{
  "items": [...],
  "nextCursor": "usr_xyz",
  "hasMore": true
}
Timestamps

Use ISO 8601 format for all timestamps:

{
  "createdAt": "2024-01-15T14:30:00.000Z",  // ISO 8601 UTC
  "updatedAt": "2024-01-16T09:15:30.123Z"
}

// In entities
toResponse(): UserResponse {
  return {
    ...
    createdAt: this.createdAt.toISOString(),  // Date → ISO string
    updatedAt: this.updatedAt.toISOString(),
  }
}
Idempotency

Use idempotency keys for safe retries:

// Request
POST /api/v1/transactions
Headers:
  Idempotency-Key: txn_abc123xyz
Body:
  { "amount": 100, "from": "usr_123", "to": "usr_456" }

// Implementation
async function createTransaction(rq: CreateTransactionRequest, idempotencyKey: string) {
  // Check if transaction with this key already exists
  const existing = await db.query.transactions.findFirst({
    where: eq(transactions.idempotencyKey, idempotencyKey),
  })

  if (existing) {
    return TransactionEntity.fromRecord(existing)  // Return existing
  }

  // Create new transaction
  const transaction = TransactionEntity.fromRequest(rq, idempotencyKey)
  return await transactionRepo.create(transaction)
}
Guidelines
  1. Plural nouns - Collections use plural resource names
  2. Lowercase with hyphens - Multi-word resources like ledger-accounts
  3. Version in URL - /api/v1/, /api/v2/ for breaking changes
  4. RFC 7807 errors - Standardized error response format
  5. Cursor pagination - For large datasets (more stable than offset)
  6. Query params - For filtering, sorting, pagination (not in path)
  7. HTTP status codes - Use correct codes (200, 201, 204, 400, 404, 409, 500, 503)
  8. ISO 8601 timestamps - Always use .toISOString() for dates
  9. Idempotency keys - For non-idempotent operations (POST, PATCH)
  10. No unnecessary envelopes - Return resources directly unless pagination needed
按 MIT 许可原样转载,未经改动 · 在 GitHub 查看 →

评论

登录即可评论;带「已验证安装」的,是发布者名下有本店的安装或持有记录。