Claude Code Plugins

Community-maintained marketplace

Feedback

Write REST API endpoints with HTTP methods, status codes, versioning, and OpenAPI documentation. Use when creating API endpoints or implementing backend services.

Install Skill

1Download skill
2Enable skills in Claude

Open claude.ai/settings/capabilities and find the "Skills" section

3Upload to Claude

Click "Upload skill" and select the downloaded ZIP file

Note: Please verify skill by going through its instructions before using it.

SKILL.md

name rest-api
description Write REST API endpoints with HTTP methods, status codes, versioning, and OpenAPI documentation. Use when creating API endpoints or implementing backend services.
allowed-tools Read, Write, Edit, Glob, Grep

REST API Skill

Write REST API endpoints following project conventions for consistency, security, and progressive enhancement.


When to Use

  • Creating API endpoints
  • Handling HTTP methods and status codes
  • Supporting both JSON and form-encoded requests
  • Implementing versioning strategies
  • Building endpoints that support HTML form fallback

HTTP Methods

Standard Methods

// Express example
app.get('/api/users/:id', getUser);      // Read
app.post('/api/users', createUser);       // Create
app.put('/api/users/:id', replaceUser);   // Replace entire resource
app.patch('/api/users/:id', updateUser);  // Partial update
app.delete('/api/users/:id', deleteUser); // Delete

Form Fallback with API_METHOD

HTML forms only support GET and POST. For progressive enhancement, support API_METHOD field:

/**
 * Middleware to support API_METHOD for HTML forms
 * Allows PUT/PATCH/DELETE via POST when JavaScript unavailable
 */
function methodOverride(req, res, next) {
  if (req.method === 'POST' && req.body?.API_METHOD) {
    const method = req.body.API_METHOD.toUpperCase();
    if (['PUT', 'PATCH', 'DELETE'].includes(method)) {
      req.method = method;
      delete req.body.API_METHOD;
    }
  }
  next();
}

app.use(methodOverride);

HTML Form Example:

<form method="post" action="/api/users/123">
  <input type="hidden" name="API_METHOD" value="DELETE"/>
  <button type="submit">Delete User</button>
</form>

Content Types

Accept Both JSON and Form Data

import express from 'express';

const app = express();

// Parse both content types
app.use(express.json());
app.use(express.urlencoded({ extended: true }));

/**
 * Unified request body access
 * Works regardless of Content-Type
 */
app.post('/api/users', (req, res) => {
  // req.body works for both:
  // - application/json
  // - application/x-www-form-urlencoded
  const { name, email } = req.body;
  // ...
});

Response Content Negotiation

/**
 * Respond with JSON or HTML based on Accept header
 * @param {Request} req
 * @param {Response} res
 * @param {object} data - Data to send
 * @param {string} template - HTML template path
 */
function respond(req, res, data, template) {
  const acceptsHtml = req.accepts(['html', 'json']) === 'html';

  if (acceptsHtml && template) {
    res.render(template, data);
  } else {
    res.json(data);
  }
}

// Usage
app.get('/api/users/:id', async (req, res) => {
  const user = await getUser(req.params.id);
  respond(req, res, { user }, 'users/show');
});

Status Codes

Success Codes

Code When to Use Example
200 OK Successful read/update GET /users/123, PATCH /users/123
201 Created Resource created POST /users
204 No Content Successful delete DELETE /users/123

Client Error Codes

Code When to Use Example
400 Bad Request Invalid input Missing required field
401 Unauthorized Not authenticated Missing/invalid token
403 Forbidden Not authorized Accessing another user's data
404 Not Found Resource doesn't exist GET /users/999
409 Conflict State conflict Duplicate email
422 Unprocessable Entity Validation failed Email format invalid
429 Too Many Requests Rate limit exceeded Too many API calls

Server Error Codes

Code When to Use
500 Internal Server Error Unexpected error
502 Bad Gateway Upstream service failed
503 Service Unavailable Temporarily unavailable

Error Response Pattern

/**
 * Standard error response format
 * @param {Response} res
 * @param {number} status
 * @param {string} code - Machine-readable error code
 * @param {string} message - Human-readable message
 * @param {object} [details] - Additional context
 */
function sendError(res, status, code, message, details = null) {
  const error = {
    error: {
      code,
      message,
      ...(details && { details })
    }
  };
  res.status(status).json(error);
}

// Usage examples
sendError(res, 400, 'VALIDATION_ERROR', 'Email is required');
sendError(res, 401, 'UNAUTHORIZED', 'Invalid or expired token');
sendError(res, 404, 'NOT_FOUND', 'User not found');
sendError(res, 422, 'INVALID_EMAIL', 'Email format is invalid', {
  field: 'email',
  value: req.body.email
});

Versioning

Header-Based Versioning (Preferred)

/**
 * Extract API version from Accept-Version header
 * Default to latest stable version
 */
function getApiVersion(req) {
  const version = req.get('Accept-Version') || req.get('API-Version');
  return version || '1';
}

/**
 * Version routing middleware
 */
function versionRouter(versions) {
  return (req, res, next) => {
    const version = getApiVersion(req);
    const handler = versions[version] || versions.default;

    if (!handler) {
      return sendError(res, 400, 'INVALID_VERSION',
        `API version ${version} not supported`);
    }

    handler(req, res, next);
  };
}

// Usage
app.get('/api/users', versionRouter({
  '1': getUsersV1,
  '2': getUsersV2,
  'default': getUsersV2
}));

Client Usage:

fetch('/api/users', {
  headers: {
    'Accept-Version': '2'
  }
});

URL Versioning (Major Changes Only)

Reserve URL versioning for breaking changes that require complete API redesign:

// Only for major breaking changes
app.use('/api/v2', v2Router);  // New architecture
app.use('/api/v1', v1Router);  // Legacy, deprecated

Streaming Large Responses

JSON Streaming

For large datasets, stream JSON to reduce memory and improve TTFB:

import { Transform } from 'stream';

/**
 * Stream JSON array without loading all items in memory
 * @param {Response} res
 * @param {AsyncIterable} items - Async iterator of items
 */
async function streamJsonArray(res, items) {
  res.setHeader('Content-Type', 'application/json');
  res.write('[\n');

  let first = true;
  for await (const item of items) {
    if (!first) res.write(',\n');
    res.write(JSON.stringify(item));
    first = false;
  }

  res.write('\n]');
  res.end();
}

// Usage with database cursor
app.get('/api/export/users', async (req, res) => {
  const cursor = db.query('SELECT * FROM users').cursor();
  await streamJsonArray(res, cursor);
});

NDJSON (Newline Delimited JSON)

Alternative format for streaming:

/**
 * Stream as NDJSON (one JSON object per line)
 */
async function streamNdjson(res, items) {
  res.setHeader('Content-Type', 'application/x-ndjson');

  for await (const item of items) {
    res.write(JSON.stringify(item) + '\n');
  }

  res.end();
}

Rate Limiting

/**
 * Simple in-memory rate limiter
 * Use Redis for production/multi-instance
 */
function rateLimit(options = {}) {
  const {
    windowMs = 60000,    // 1 minute
    max = 100,           // requests per window
    keyGenerator = (req) => req.ip
  } = options;

  const hits = new Map();

  // Cleanup old entries periodically
  setInterval(() => {
    const now = Date.now();
    for (const [key, data] of hits) {
      if (now - data.start > windowMs) hits.delete(key);
    }
  }, windowMs);

  return (req, res, next) => {
    const key = keyGenerator(req);
    const now = Date.now();

    let data = hits.get(key);
    if (!data || now - data.start > windowMs) {
      data = { count: 0, start: now };
      hits.set(key, data);
    }

    data.count++;

    res.setHeader('X-RateLimit-Limit', max);
    res.setHeader('X-RateLimit-Remaining', Math.max(0, max - data.count));
    res.setHeader('X-RateLimit-Reset', Math.ceil((data.start + windowMs) / 1000));

    if (data.count > max) {
      return sendError(res, 429, 'RATE_LIMIT_EXCEEDED',
        'Too many requests, please try again later');
    }

    next();
  };
}

// Apply to all API routes
app.use('/api', rateLimit({ max: 100, windowMs: 60000 }));

// Stricter limit for sensitive endpoints
app.use('/api/auth', rateLimit({ max: 10, windowMs: 60000 }));

Token Authentication

/**
 * Bearer token authentication middleware
 */
function authenticate(req, res, next) {
  const authHeader = req.get('Authorization');

  if (!authHeader?.startsWith('Bearer ')) {
    return sendError(res, 401, 'MISSING_TOKEN',
      'Authorization header required');
  }

  const token = authHeader.slice(7);

  try {
    const payload = verifyToken(token);  // Your JWT/token verification
    req.user = payload;
    next();
  } catch (err) {
    return sendError(res, 401, 'INVALID_TOKEN',
      'Token is invalid or expired');
  }
}

// Protected routes
app.get('/api/users/me', authenticate, getCurrentUser);
app.patch('/api/users/me', authenticate, updateCurrentUser);

Third-Party API Proxying

Proxy third-party APIs to keep secrets safe and allow replacements:

/**
 * Proxy to third-party API
 * - Keeps API keys server-side
 * - Allows switching providers without frontend changes
 * - Can add caching, rate limiting, transformation
 */
app.get('/api/geocode', authenticate, async (req, res) => {
  const { address } = req.query;

  if (!address) {
    return sendError(res, 400, 'MISSING_ADDRESS', 'Address is required');
  }

  try {
    // Third-party API call with server-side secret
    const response = await fetch(
      `https://api.geocoder.example/v1/search?` +
      new URLSearchParams({
        q: address,
        key: process.env.GEOCODER_API_KEY  // Never exposed to client
      })
    );

    if (!response.ok) {
      throw new Error(`Geocoder API error: ${response.status}`);
    }

    const data = await response.json();

    // Transform response to your own format
    // (allows changing providers without frontend changes)
    res.json({
      results: data.features.map(f => ({
        lat: f.geometry.coordinates[1],
        lng: f.geometry.coordinates[0],
        address: f.properties.formatted
      }))
    });
  } catch (err) {
    console.error('Geocode proxy error:', err);
    sendError(res, 502, 'UPSTREAM_ERROR',
      'Geocoding service temporarily unavailable');
  }
});

Input Validation

Validate at the boundary - trust nothing from clients:

/**
 * Validation helper using JSON Schema
 */
import Ajv from 'ajv';
const ajv = new Ajv({ allErrors: true });

function validate(schema) {
  const validateFn = ajv.compile(schema);

  return (req, res, next) => {
    if (!validateFn(req.body)) {
      return sendError(res, 422, 'VALIDATION_ERROR',
        'Request body validation failed', {
          errors: validateFn.errors.map(e => ({
            path: e.instancePath,
            message: e.message
          }))
        });
    }
    next();
  };
}

// Usage
const createUserSchema = {
  type: 'object',
  required: ['email', 'name'],
  properties: {
    email: { type: 'string', format: 'email' },
    name: { type: 'string', minLength: 1, maxLength: 100 },
    role: { type: 'string', enum: ['user', 'admin'], default: 'user' }
  },
  additionalProperties: false
};

app.post('/api/users', validate(createUserSchema), createUser);

Health Check Endpoints

Provide endpoints for operational monitoring and container orchestration.

Basic Health Check

/**
 * Simple health check - returns 200 if server is running
 * Use for: Load balancer health checks, uptime monitoring
 */
app.get('/health', (req, res) => {
  res.json({
    status: 'ok',
    timestamp: new Date().toISOString(),
    uptime: process.uptime()
  });
});

Readiness Check with Dependencies

/**
 * Readiness check - verifies all dependencies are available
 * Use for: Kubernetes readiness probes, deployment verification
 * Returns 503 if any dependency is unhealthy
 */
app.get('/ready', async (req, res) => {
  const checks = {};
  let healthy = true;

  // Database check
  try {
    const start = Date.now();
    await db.query('SELECT 1');
    checks.database = {
      status: 'ok',
      latency: Date.now() - start
    };
  } catch (error) {
    checks.database = {
      status: 'error',
      message: error.message
    };
    healthy = false;
  }

  // Redis check (if used)
  if (redis) {
    try {
      const start = Date.now();
      await redis.ping();
      checks.redis = {
        status: 'ok',
        latency: Date.now() - start
      };
    } catch (error) {
      checks.redis = {
        status: 'error',
        message: error.message
      };
      healthy = false;
    }
  }

  // External service check (optional)
  // checks.externalApi = await checkExternalService();

  res.status(healthy ? 200 : 503).json({
    status: healthy ? 'ok' : 'degraded',
    timestamp: new Date().toISOString(),
    checks
  });
});

Liveness Check

/**
 * Liveness check - indicates if the process should be restarted
 * Use for: Kubernetes liveness probes
 * Returns 503 if the process is in a bad state
 */
app.get('/live', (req, res) => {
  // Check for conditions that require restart
  const memoryUsage = process.memoryUsage();
  const heapUsedPercent = memoryUsage.heapUsed / memoryUsage.heapTotal;

  // Example: restart if heap is 95%+ full
  if (heapUsedPercent > 0.95) {
    return res.status(503).json({
      status: 'unhealthy',
      reason: 'memory_pressure',
      heapUsedPercent: Math.round(heapUsedPercent * 100)
    });
  }

  res.json({
    status: 'ok',
    pid: process.pid,
    memory: {
      heapUsed: Math.round(memoryUsage.heapUsed / 1024 / 1024),
      heapTotal: Math.round(memoryUsage.heapTotal / 1024 / 1024),
      rss: Math.round(memoryUsage.rss / 1024 / 1024)
    }
  });
});

Startup Check

/**
 * Track startup completion for Kubernetes startupProbe
 */
let startupComplete = false;

async function initializeApp() {
  // Run migrations
  await runMigrations();

  // Warm caches
  await warmCaches();

  // Mark startup complete
  startupComplete = true;
}

app.get('/startup', (req, res) => {
  if (startupComplete) {
    res.json({ status: 'ok', started: true });
  } else {
    res.status(503).json({ status: 'starting', started: false });
  }
});

Health Check Response Patterns

/**
 * Health check response schema (OpenAPI)
 */
const HealthResponse = {
  type: 'object',
  properties: {
    status: {
      type: 'string',
      enum: ['ok', 'degraded', 'unhealthy']
    },
    timestamp: {
      type: 'string',
      format: 'date-time'
    },
    version: {
      type: 'string',
      description: 'Application version'
    },
    checks: {
      type: 'object',
      additionalProperties: {
        type: 'object',
        properties: {
          status: { type: 'string', enum: ['ok', 'error'] },
          latency: { type: 'number' },
          message: { type: 'string' }
        }
      }
    }
  }
};

HTTP Caching Headers

Use caching headers to improve performance and reduce server load.

Cache-Control Header

/**
 * Set Cache-Control for different resource types
 */

// Static, immutable content (versioned assets)
app.get('/api/static/:hash', (req, res) => {
  res.set('Cache-Control', 'public, max-age=31536000, immutable');
  res.json(staticData);
});

// Dynamic but cacheable (user-independent)
app.get('/api/products', (req, res) => {
  res.set('Cache-Control', 'public, max-age=300'); // 5 minutes
  res.json(products);
});

// User-specific, cacheable
app.get('/api/users/me/preferences', authenticate, (req, res) => {
  res.set('Cache-Control', 'private, max-age=60'); // 1 minute, user only
  res.json(preferences);
});

// Never cache sensitive data
app.get('/api/users/me', authenticate, (req, res) => {
  res.set('Cache-Control', 'no-store');
  res.json(user);
});

Cache-Control Values

Value Use Case
public, max-age=N CDN + browser cache for N seconds
private, max-age=N Browser-only cache (user-specific)
no-cache Must revalidate before using cache
no-store Never cache (sensitive data)
immutable Content will never change
stale-while-revalidate=N Serve stale while fetching fresh

ETag for Conditional Requests

import { createHash } from 'node:crypto';

/**
 * Generate ETag from response body
 * @param {object} data - Response data
 * @returns {string}
 */
function generateEtag(data) {
  const content = JSON.stringify(data);
  const hash = createHash('md5').update(content).digest('hex');
  return `"${hash}"`;
}

/**
 * ETag middleware for conditional responses
 */
function conditionalGet(getData) {
  return async (req, res) => {
    const data = await getData(req);
    const etag = generateEtag(data);

    res.set('ETag', etag);
    res.set('Cache-Control', 'private, max-age=0, must-revalidate');

    // Check if client has current version
    const clientEtag = req.get('If-None-Match');
    if (clientEtag === etag) {
      return res.status(304).end(); // Not Modified
    }

    res.json(data);
  };
}

// Usage
app.get('/api/users/:id', conditionalGet(async (req) => {
  return await getUserById(req.params.id);
}));

Last-Modified Header

/**
 * Last-Modified for time-based caching
 */
app.get('/api/articles/:id', async (req, res) => {
  const article = await getArticle(req.params.id);
  const lastModified = new Date(article.updatedAt);

  res.set('Last-Modified', lastModified.toUTCString());
  res.set('Cache-Control', 'private, max-age=0, must-revalidate');

  // Check If-Modified-Since header
  const ifModifiedSince = req.get('If-Modified-Since');
  if (ifModifiedSince) {
    const clientDate = new Date(ifModifiedSince);
    if (lastModified <= clientDate) {
      return res.status(304).end(); // Not Modified
    }
  }

  res.json(article);
});

Conditional PUT/PATCH (Optimistic Concurrency)

/**
 * Prevent lost updates with If-Match header
 */
app.patch('/api/articles/:id', authenticate, async (req, res) => {
  const article = await getArticle(req.params.id);
  const currentEtag = generateEtag(article);

  // Require If-Match header for updates
  const clientEtag = req.get('If-Match');
  if (!clientEtag) {
    return sendError(res, 428, 'PRECONDITION_REQUIRED',
      'If-Match header required for updates');
  }

  // Check for concurrent modification
  if (clientEtag !== currentEtag) {
    return sendError(res, 412, 'PRECONDITION_FAILED',
      'Resource has been modified, please refresh');
  }

  // Safe to update
  const updated = await updateArticle(req.params.id, req.body);
  res.set('ETag', generateEtag(updated));
  res.json(updated);
});

Vary Header for Cache Keys

/**
 * Use Vary to differentiate cached responses
 */
app.get('/api/content', (req, res) => {
  // Response varies based on these headers
  res.set('Vary', 'Accept-Language, Accept-Encoding');
  res.set('Cache-Control', 'public, max-age=300');

  const lang = req.get('Accept-Language')?.split(',')[0] || 'en';
  res.json(getContentForLanguage(lang));
});

Caching Middleware

/**
 * Reusable caching middleware
 * @param {object} options
 */
function cache(options = {}) {
  const {
    maxAge = 300,
    scope = 'public',
    staleWhileRevalidate = 0
  } = options;

  let cacheControl = `${scope}, max-age=${maxAge}`;
  if (staleWhileRevalidate) {
    cacheControl += `, stale-while-revalidate=${staleWhileRevalidate}`;
  }

  return (req, res, next) => {
    res.set('Cache-Control', cacheControl);
    next();
  };
}

// Usage
app.get('/api/catalog', cache({ maxAge: 600 }), getCatalog);
app.get('/api/user/feed', cache({ scope: 'private', maxAge: 60 }), getFeed);

Checklist

When creating REST endpoints:

Core API Design

  • Use appropriate HTTP methods (GET/POST/PUT/PATCH/DELETE)
  • Support API_METHOD for form fallback if progressive enhancement needed
  • Accept both JSON and form-urlencoded content types
  • Return appropriate status codes (2xx, 4xx, 5xx)
  • Use consistent error response format
  • Implement header-based versioning
  • Validate input at the boundary
  • Document with OpenAPI

Security & Performance

  • Add rate limiting
  • Use token authentication for protected routes
  • Proxy third-party APIs to hide secrets
  • Stream large responses when appropriate

Health & Monitoring

  • Implement /health endpoint (basic liveness)
  • Implement /ready endpoint (dependency checks)
  • Return 503 when dependencies unavailable

Caching

  • Set Cache-Control headers appropriately
  • Use ETag for conditional GET requests
  • Use If-Match for safe concurrent updates
  • Set Vary header when response depends on request headers
  • Never cache sensitive user data (use no-store)

Related Skills

  • nodejs-backend - Build Node.js backend services with Express/Fastify, Post...
  • database - Design PostgreSQL schemas with migrations, seeding, and d...
  • authentication - Implement secure authentication with JWT, sessions, OAuth...
  • api-client - Fetch API patterns with error handling, retry logic, and ...