agentsclimarketplace

Api design

Skill Lukk17/agent-standards/.agents/skills/api-design

One git checkout drops a shared AI coding setup (skills, subagents, MCP servers, OpenSpec scaffolding) into any project, across Claude Code, Kilo, OpenCode, Codex, and Copilot.

Install
npx -y skills add Lukk17/agent-standards --skill api-design

Assembled from the repository path, not quoted from the project. Check it against their README if it does not work.

One thing to look at

  • 0 stars0 stars. Stars are a popularity signal and not a quality one, but at this level it is likely that nobody has read this closely except its author, and you would be relying on your own review.

What its author says it does

Copied from the file, not written here

REST API design patterns including resource naming, status codes, pagination, filtering, error responses, versioning, and rate limiting for production APIs. Also covers GraphQL schema design, mutations, authorization, and production security.

SKILL.md

19.1 KB, as published. Nobody here has run it

API Design Patterns

Conventions and best practices for designing consistent, developer-friendly REST APIs and GraphQL APIs.


When to Activate

  • Designing new API endpoints
  • Reviewing existing API contracts
  • Adding pagination, filtering, or sorting
  • Implementing error handling for APIs
  • Planning API versioning strategy
  • Building public or partner-facing APIs

Resource Design

URL Structure

# Resources are nouns, plural, lowercase, kebab-case
GET    /api/v1/users
GET    /api/v1/users/:id
POST   /api/v1/users
PUT    /api/v1/users/:id
PATCH  /api/v1/users/:id
DELETE /api/v1/users/:id

# Sub-resources for relationships
GET    /api/v1/users/:id/orders
POST   /api/v1/users/:id/orders

# Actions that don't map to CRUD (use verbs sparingly)
POST   /api/v1/orders/:id/cancel
POST   /api/v1/auth/login
POST   /api/v1/auth/refresh

Naming Rules

# GOOD
/api/v1/team-members          # kebab-case for multi-word resources
/api/v1/orders?status=active  # query params for filtering
/api/v1/users/123/orders      # nested resources for ownership

# BAD
/api/v1/getUsers              # verb in URL
/api/v1/user                  # singular (use plural)
/api/v1/team_members          # snake_case in URLs
/api/v1/users/123/getOrders   # verb in nested resource

HTTP Methods and Status Codes

Method Semantics

MethodIdempotentSafeUse For
GETYesYesRetrieve resources
POSTNoNoCreate resources, trigger actions
PUTYesNoFull replacement of a resource
PATCHNo*NoPartial update of a resource
DELETEYesNoRemove a resource

*PATCH can be made idempotent with proper implementation

Status Code Reference

# Success
200 OK                    — GET, PUT, PATCH (with response body)
201 Created               — POST (include Location header)
204 No Content            — DELETE, PUT (no response body)
207 Multi-Status          — Bulk operations with per-item results
304 Not Modified          — Conditional GET, resource unchanged

# Client Errors
400 Bad Request           — Validation failure, malformed JSON
401 Unauthorized          — Missing or invalid authentication
403 Forbidden             — Authenticated but not authorized
404 Not Found             — Resource doesn't exist
409 Conflict              — Duplicate entry, state conflict
422 Unprocessable Entity  — Semantically invalid (valid JSON, bad data)
429 Too Many Requests     — Rate limit exceeded

# Server Errors
500 Internal Server Error — Unexpected failure (never expose details)
502 Bad Gateway           — Upstream service failed
503 Service Unavailable   — Temporary overload, include Retry-After

Common Mistakes

# BAD: 200 for everything
{ "status": 200, "success": false, "error": "Not found" }

# GOOD: Use HTTP status codes semantically
HTTP/1.1 404 Not Found
Content-Type: application/problem+json
{ "type": "https://example.com/errors/not-found", "title": "Not Found", "status": 404, "detail": "User not found" }

# BAD: 500 for validation errors
# GOOD: 400 or 422 with field-level details

# BAD: 200 for created resources
# GOOD: 201 with Location header
HTTP/1.1 201 Created
Location: /api/v1/users/abc-123

Response Format

Success Response

{
  "data": {
    "id": "abc-123",
    "email": "[email protected]",
    "name": "Alice",
    "created_at": "2025-01-15T10:30:00Z"
  }
}

Collection Response (with Pagination)

{
  "data": [
    { "id": "abc-123", "name": "Alice" },
    { "id": "def-456", "name": "Bob" }
  ],
  "meta": {
    "total": 142,
    "page": 1,
    "per_page": 20,
    "total_pages": 8
  },
  "links": {
    "self": "/api/v1/users?page=1&per_page=20",
    "next": "/api/v1/users?page=2&per_page=20",
    "last": "/api/v1/users?page=8&per_page=20"
  }
}

Error Response (RFC 7807 Problem Details)

Use Content-Type: application/problem+json for all error responses.

{
  "type": "https://example.com/errors/validation",
  "title": "Validation Failed",
  "status": 422,
  "detail": "3 fields failed validation",
  "instance": "/orders/123",
  "errors": [
    { "field": "email", "message": "Invalid format", "code": "INVALID_EMAIL" }
  ]
}
FieldRequiredDescription
typeYesURI identifying the problem type
titleYesShort, human-readable summary
statusYesHTTP status code
detailNoHuman-readable explanation for this occurrence
instanceNoURI reference to the specific occurrence
errorsNoExtension: field-level validation errors

Response Envelope Variants

// Option A: Envelope with data wrapper (recommended for public APIs)
interface ApiResponse<T> {
  data: T;
  meta?: PaginationMeta;
  links?: PaginationLinks;
}

// Error: RFC 7807 Problem Details (Content-Type: application/problem+json)
interface ProblemDetails {
  type: string;
  title: string;
  status: number;
  detail?: string;
  instance?: string;
  errors?: FieldError[];
}

// Option B: Flat response (simpler, common for internal APIs)
// Success: just return the resource directly
// Error: RFC 7807 Problem Details object
// Distinguish by HTTP status code

Pagination

Offset-Based (Simple)

GET /api/v1/users?page=2&per_page=20

# Implementation
SELECT * FROM users
ORDER BY created_at DESC
LIMIT 20 OFFSET 20;

Pros: Easy to implement, supports "jump to page N" Cons: Slow on large offsets (OFFSET 100000), inconsistent with concurrent inserts

Cursor-Based (Default for Unbounded Data)

GET /api/v1/users?cursor=eyJpZCI6MTIzfQ&limit=20

# Implementation
SELECT * FROM users
WHERE id > :cursor_id
ORDER BY id ASC
LIMIT 21;  -- fetch one extra to determine has_next
{
  "data": [...],
  "meta": {
    "has_next": true,
    "next_cursor": "eyJpZCI6MTQzfQ"
  }
}

Pros: Consistent performance regardless of position, stable with concurrent inserts Cons: Cannot jump to arbitrary page, cursor is opaque

When to Use Which

Cursor pagination is the default for datasets that grow unboundedly; use offset pagination only for fixed/small datasets.

Use CasePagination Type
Admin dashboards, small datasets (<10K)Offset
Infinite scroll, feeds, large datasetsCursor (default)
Public APIsCursor (default) with offset (optional)
Search resultsOffset (users expect page numbers)

Filtering, Sorting, and Search

Filtering

# Simple equality (flat notation)
GET /api/v1/orders?status=active&customer_id=abc-123

# Comparison operators (bracket notation for ranges only)
GET /api/v1/products?price[gte]=10&price[lte]=100
GET /api/v1/orders?created_at[after]=2025-01-01

# Multiple values (comma-separated)
GET /api/v1/products?category=electronics,clothing

# Nested fields (dot notation)
GET /api/v1/orders?customer.country=US

Sorting

# Single field (prefix - for descending)
GET /api/v1/products?sort=-created_at

# Multiple fields (comma-separated)
GET /api/v1/products?sort=-featured,price,-created_at

Full-Text Search

# Search query parameter
GET /api/v1/products?q=wireless+headphones

# Field-specific search
GET /api/v1/users?email=alice

Sparse Fieldsets

# Return only specified fields (reduces payload)
GET /api/v1/users?fields=id,name,email
GET /api/v1/orders?fields=id,total,status&include=customer.name

Authentication and Authorization

Token-Based Auth

# Bearer token in Authorization header
GET /api/v1/users
Authorization: Bearer eyJhbGciOiJIUzI1NiIs...

# API key (for server-to-server)
GET /api/v1/data
X-API-Key: sk_live_abc123

Authorization Patterns

// Resource-level: check ownership
app.get("/api/v1/orders/:id", async (req, res) => {
  const order = await Order.findById(req.params.id);
  if (!order) return res.status(404).set("Content-Type", "application/problem+json").json({ type: "https://example.com/errors/not-found", title: "Not Found", status: 404 });
  if (order.userId !== req.user.id) return res.status(403).set("Content-Type", "application/problem+json").json({ type: "https://example.com/errors/forbidden", title: "Forbidden", status: 403 });
  return res.json({ data: order });
});

// Role-based: check permissions
app.delete("/api/v1/users/:id", requireRole("admin"), async (req, res) => {
  await User.delete(req.params.id);
  return res.status(204).send();
});

Idempotency

For all non-idempotent POST/PATCH operations, require Idempotency-Key: <UUID v4> header.

  • Cache the response for 24 hours keyed by Idempotency-Key
  • Reusing the same key with the same request body within the cache window replays the stored response without re-executing the operation
  • Return 422 if the same key is reused with a different request body
POST /api/v1/orders
Idempotency-Key: 550e8400-e29b-41d4-a716-446655440000

Conditional Requests (Caching)

For cacheable GETs, return ETag and Last-Modified headers. Clients use If-None-Match / If-Modified-Since to skip re-fetching unchanged resources. Return 304 Not Modified when the resource is unchanged.

# Server response
HTTP/1.1 200 OK
ETag: "33a64df551425fcc55e4d42a148795d9f25f89d4"
Last-Modified: Tue, 15 Jan 2025 10:30:00 GMT

# Client conditional request
GET /api/v1/users/123
If-None-Match: "33a64df551425fcc55e4d42a148795d9f25f89d4"

# Server response when unchanged
HTTP/1.1 304 Not Modified

Bulk Operations

Use POST /resources/batch for bulk create/update. Return 207 Multi-Status with per-item results:

{
  "results": [
    { "id": "1", "status": 201, "data": {} },
    { "id": "2", "status": 422, "error": "Invalid email" }
  ]
}
  • Limit bulk request body size; return 413 Payload Too Large if exceeded
  • Process atomically where possible; if partial success is allowed, always return 207

Health Endpoints

Two distinct endpoints, do NOT merge them:

  • GET /health: liveness: always returns 200 if the process is alive; never checks dependencies
  • GET /ready: readiness: checks DB, cache, external deps; returns 503 if any are degraded

These endpoints are outside the versioned API path (no /v1/ prefix).

// GET /ready — degraded example
{ "status": "degraded", "checks": { "db": "ok", "redis": "timeout" } }

Rate Limiting

Rate Limiting Headers

Include on all rate-limited responses:

  • X-RateLimit-Limit: 100
  • X-RateLimit-Remaining: 42
  • X-RateLimit-Reset: 1714000000 (UTC epoch seconds)

Return 429 when limit exceeded. Include Retry-After header.

HTTP/1.1 200 OK
X-RateLimit-Limit: 100
X-RateLimit-Remaining: 95
X-RateLimit-Reset: 1714000000

# When exceeded
HTTP/1.1 429 Too Many Requests
X-RateLimit-Limit: 100
X-RateLimit-Remaining: 0
X-RateLimit-Reset: 1714000000
Retry-After: 60
Content-Type: application/problem+json

{
  "type": "https://example.com/errors/rate-limit-exceeded",
  "title": "Too Many Requests",
  "status": 429,
  "detail": "Rate limit exceeded. Try again in 60 seconds."
}

Rate Limit Tiers

TierLimitWindowUse Case
Anonymous30/minPer IPPublic endpoints
Authenticated100/minPer userStandard API access
Premium1000/minPer API keyPaid API plans
Internal10000/minPer serviceService-to-service

Versioning

URL Path Versioning (Recommended)

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

Pros: Explicit, easy to route, cacheable Cons: URL changes between versions

Header Versioning

GET /api/users
Accept: application/vnd.myapp.v2+json

Pros: Clean URLs Cons: Harder to test, easy to forget

Version Lifecycle

  • Maintain the deprecated version in production for a minimum of 6 months after a new version is released
  • Maximum 2 active API versions at any time
  • Announce deprecation via Deprecation and Sunset response headers:
Deprecation: true
Sunset: Sat, 01 Jan 2026 00:00:00 GMT
  • Non-breaking changes don't need a new version:
    • Adding new fields to responses
    • Adding new optional query parameters
    • Adding new endpoints
  • Breaking changes require a new version:
    • Removing or renaming fields
    • Changing field types
    • Changing URL structure
    • Changing authentication method
  • Return 410 Gone after the sunset date

Implementation Patterns

TypeScript (Next.js API Route)

import { z } from "zod";
import { NextRequest, NextResponse } from "next/server";

const createUserSchema = z.object({
  email: z.string().email(),
  name: z.string().min(1).max(100),
});

export async function POST(req: NextRequest) {
  // Idempotency-Key required for non-idempotent POST
  const idempotencyKey = req.headers.get("Idempotency-Key");
  const body = await req.json();
  const parsed = createUserSchema.safeParse(body);

  if (!parsed.success) {
    return NextResponse.json({
      type: "https://example.com/errors/validation",
      title: "Validation Failed",
      status: 422,
      detail: "Request validation failed",
      errors: parsed.error.issues.map(i => ({
        field: i.path.join("."),
        message: i.message,
        code: i.code,
      })),
    }, {
      status: 422,
      headers: { "Content-Type": "application/problem+json" },
    });
  }

  const user = await createUser(parsed.data);

  return NextResponse.json(
    { data: user },
    {
      status: 201,
      headers: { Location: `/api/v1/users/${user.id}` },
    },
  );
}

Python (Django REST Framework)

from rest_framework import serializers, viewsets, status
from rest_framework.response import Response

class CreateUserSerializer(serializers.Serializer):
    email = serializers.EmailField()
    name = serializers.CharField(max_length=100)

class UserSerializer(serializers.ModelSerializer):
    class Meta:
        model = User
        fields = ["id", "email", "name", "created_at"]

class UserViewSet(viewsets.ModelViewSet):
    serializer_class = UserSerializer
    permission_classes = [IsAuthenticated]

    def get_serializer_class(self):
        if self.action == "create":
            return CreateUserSerializer
        return UserSerializer

    def create(self, request):
        serializer = CreateUserSerializer(data=request.data)
        serializer.is_valid(raise_exception=True)
        user = UserService.create(**serializer.validated_data)
        return Response(
            {"data": UserSerializer(user).data},
            status=status.HTTP_201_CREATED,
            headers={"Location": f"/api/v1/users/{user.id}"},
        )

Go (net/http)

func (h *UserHandler) CreateUser(w http.ResponseWriter, r *http.Request) {
    var req CreateUserRequest
    if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
        // writeProblem sets Content-Type: application/problem+json
        writeProblem(w, http.StatusBadRequest, "https://example.com/errors/invalid-json", "Invalid Request Body")
        return
    }

    if err := req.Validate(); err != nil {
        writeProblem(w, http.StatusUnprocessableEntity, "https://example.com/errors/validation", "Validation Failed")
        return
    }

    user, err := h.service.Create(r.Context(), req)
    if err != nil {
        switch {
        case errors.Is(err, domain.ErrEmailTaken):
            writeProblem(w, http.StatusConflict, "https://example.com/errors/email-taken", "Email Already Registered")
        default:
            writeProblem(w, http.StatusInternalServerError, "https://example.com/errors/internal", "Internal Server Error")
        }
        return
    }

    w.Header().Set("Location", fmt.Sprintf("/api/v1/users/%s", user.ID))
    writeJSON(w, http.StatusCreated, map[string]any{"data": user})
}

API Design Checklist

Before shipping a new endpoint:

  • Resource URL follows naming conventions (plural, kebab-case, no verbs)
  • Correct HTTP method used (GET for reads, POST for creates, etc.)
  • Appropriate status codes returned (not 200 for everything)
  • Input validated with schema (Zod, Pydantic, Bean Validation)
  • Error responses use RFC 7807 Problem Details (application/problem+json)
  • Pagination implemented for list endpoints (cursor default for unbounded data)
  • Idempotency-Key header required for non-idempotent POST/PATCH
  • ETag / Last-Modified returned for cacheable GETs
  • Authentication required (or explicitly marked as public)
  • Authorization checked (user can only access their own resources)
  • Rate limiting configured with X-RateLimit-* headers
  • Deprecation / Sunset headers set on deprecated endpoints
  • /health (liveness) and /ready (readiness) endpoints present and separated
  • Response does not leak internal details (stack traces, SQL errors)
  • Consistent naming with existing endpoints (camelCase vs snake_case)
  • Documented (OpenAPI/Swagger spec updated)

GraphQL API Standards

Schema Design

  • Types: PascalCase: UserProfile, OrderItem
  • Fields: camelCase: createdAt, totalAmount
  • Enum values: UPPER_SNAKE_CASE: ORDER_STATUS_PENDING
  • Every field must have a description: enforced by schema linting
  • Input types suffixed Input: CreateOrderInput
  • Mutation payload types suffixed Payload: CreateOrderPayload

Mutation Payload Structure

All mutations return a consistent payload:

type CreateOrderPayload {
  success: Boolean!
  errors: [UserError!]!
  order: Order
}

type UserError {
  field: String
  message: String!
  code: String!
}

N+1 Prevention, DataLoader

Always use DataLoader for batching related-object fetches:

const userLoader = new DataLoader(async (ids: readonly string[]) => {
  const users = await db.users.findMany({ where: { id: { in: [...ids] } } })
  return ids.map(id => users.find(u => u.id === id) ?? null)
})

Authorization

  • Implement field-level authorization: return explicit errors, never silent null
  • Use a shield or directive pattern; do not inline auth logic in resolvers

Production Security

  • Disable introspection in production
  • Enforce query depth limit (max 10 levels)
  • Enforce query complexity limits (assign cost per field; reject above threshold)
  • Use Automatic Persisted Queries (APQ) to reduce payload size and allow CDN caching

Subscriptions

Use graphql-ws protocol (not the deprecated subscriptions-transport-ws).

Schema Evolution

  • Additive only: never remove or rename fields/types without a deprecation cycle
  • Mark deprecated fields with @deprecated(reason: "Use X instead")
  • Run schema diffing in CI with graphql-inspector; block breaking changes

Pagination

Follow the Relay Cursor Connections Specification for all paginated fields:

type UserConnection {
  edges: [UserEdge!]!
  pageInfo: PageInfo!
  totalCount: Int!
}
type UserEdge {
  node: User!
  cursor: String!
}
type PageInfo {
  hasNextPage: Boolean!
  hasPreviousPage: Boolean!
  startCursor: String
  endCursor: String
}

Keep looking

Skills are one crate of 328,083. Ordering is by how many stacks a row turns up in, so the top of any crate is what has actually been picked rather than what has the most stars.