system design

Low Level Design: API Documentation Service

Overview

An API documentation service ingests API specifications (OpenAPI/Swagger, GraphQL schemas, gRPC protobufs), renders human-readable documentation, hosts an interactive sandbox where developers can execute live API calls, tracks versions and changelogs, and integrates into a developer portal. This is a common senior-level system design question because it touches spec parsing, content storage, real-time proxying, search indexing, and developer experience at scale.

Core Components

  • The spec ingestion pipeline accepts OpenAPI 3.x, Swagger 2.0, GraphQL SDL, protobuf definitions. It validates, normalizes to an internal representation, and stores.
  • The version registry tracks spec versions per API, diffs between versions, and generates structured changelogs.
  • The documentation renderer transforms the internal spec representation into HTML/React components for the developer portal.
  • The live sandbox (Try-It-Out) proxies authenticated requests from the browser to the actual API, handling CORS, credential injection, and response display.
  • The search index provides full-text search across all endpoints, schemas, and descriptions.
  • The developer portal is a web application serving the rendered documentation, sandbox, changelogs, and SDKs.

Data Model

-- API registrations
CREATE TABLE apis (
    api_id          UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    tenant_id       UUID NOT NULL,
    name            VARCHAR(255) NOT NULL,
    slug            VARCHAR(255) NOT NULL,
    spec_format     VARCHAR(20) NOT NULL DEFAULT 'openapi3', -- openapi3, swagger2, graphql, protobuf
    base_url        VARCHAR(1000),                           -- production base URL
    sandbox_url     VARCHAR(1000),                           -- sandbox/staging base URL
    visibility      VARCHAR(20) NOT NULL DEFAULT 'private', -- private, internal, public
    created_at      TIMESTAMPTZ NOT NULL DEFAULT now(),
    UNIQUE(tenant_id, slug)
);

-- API spec versions
CREATE TABLE api_versions (
    version_id      UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    api_id          UUID NOT NULL REFERENCES apis(api_id) ON DELETE CASCADE,
    version_label   VARCHAR(100) NOT NULL,          -- semver or date-based label
    spec_raw        TEXT NOT NULL,                  -- original spec text (YAML/JSON/SDL/proto)
    spec_normalized JSONB NOT NULL,                 -- parsed internal representation
    spec_hash       CHAR(64) NOT NULL,              -- SHA-256 of spec_raw for dedup
    is_current      BOOLEAN NOT NULL DEFAULT false,
    published_at    TIMESTAMPTZ NOT NULL DEFAULT now(),
    published_by    UUID,
    UNIQUE(api_id, version_label)
);

CREATE INDEX idx_api_versions_api ON api_versions(api_id, published_at DESC);

-- Changelog entries (generated by diffing consecutive versions)
CREATE TABLE api_changelogs (
    changelog_id    UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    api_id          UUID NOT NULL REFERENCES apis(api_id) ON DELETE CASCADE,
    from_version    UUID REFERENCES api_versions(version_id),
    to_version      UUID NOT NULL REFERENCES api_versions(version_id),
    change_type     VARCHAR(20) NOT NULL,   -- BREAKING, DEPRECATED, ADDED, MODIFIED, REMOVED
    path            VARCHAR(500),           -- affected endpoint or schema path
    description     TEXT NOT NULL,
    generated_at    TIMESTAMPTZ NOT NULL DEFAULT now()
);

CREATE INDEX idx_changelog_api ON api_changelogs(api_id, generated_at DESC);

-- Endpoints extracted from specs (for fast search and listing)
CREATE TABLE api_endpoints (
    endpoint_id     UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    version_id      UUID NOT NULL REFERENCES api_versions(version_id) ON DELETE CASCADE,
    api_id          UUID NOT NULL,
    method          VARCHAR(10) NOT NULL,   -- GET, POST, PUT, DELETE, PATCH, etc.
    path            VARCHAR(1000) NOT NULL,
    summary         VARCHAR(500),
    description     TEXT,
    tags            TEXT[],
    deprecated      BOOLEAN NOT NULL DEFAULT false,
    search_vector   TSVECTOR GENERATED ALWAYS AS (
        to_tsvector('english', coalesce(path,'') || ' ' || coalesce(summary,'') || ' ' || coalesce(description,''))
    ) STORED
);

CREATE INDEX idx_endpoints_version ON api_endpoints(version_id);
CREATE INDEX idx_endpoints_search ON api_endpoints USING GIN(search_vector);

-- Sandbox credentials per user (encrypted API keys for try-it-out)
CREATE TABLE sandbox_credentials (
    cred_id         UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    api_id          UUID NOT NULL REFERENCES apis(api_id) ON DELETE CASCADE,
    user_id         UUID NOT NULL,
    cred_type       VARCHAR(30) NOT NULL,   -- API_KEY, BEARER, BASIC, OAUTH2
    encrypted_value BYTEA NOT NULL,
    created_at      TIMESTAMPTZ NOT NULL DEFAULT now(),
    expires_at      TIMESTAMPTZ,
    UNIQUE(api_id, user_id, cred_type)
);

Spec Ingestion Workflow

  • The client uploads the spec file (or a URL to pull from) via POST. Max size limit (e.g., 5 MB). Stored to S3/GCS raw, keyed by SHA-256 hash for deduplication.
  • A worker pulls from the ingestion queue and parses and validates using spec-specific parsers:
    • OpenAPI: kin-openapi (Go) or swagger-parser (Node). Validate against the JSON Schema for OpenAPI 3.0.
    • GraphQL SDL: graphql-js schema builder with validation.
    • Protobuf: protoc with descriptor extraction.

    Return structured errors (line number, field name, message) on validation failure.

  • Normalize the parsed spec to an internal canonical JSON format. All spec formats are represented uniformly: a list of endpoints with their request/response schemas, authentication requirements, and metadata. This decouples the renderer and search indexer from spec-format-specific logic.
  • Diff the new normalized spec against the previous current version using a recursive JSON diff algorithm. Categorize changes:
    • BREAKING: removed endpoint, changed required parameter type, removed required response field
    • DEPRECATED: endpoint or parameter marked deprecated
    • ADDED: new endpoint or optional parameter
    • MODIFIED: changed description, example, or optional field type
  • Persist the api_versions row, api_endpoints rows, and api_changelogs rows in a transaction. Flip is_current = false on the previous version.
  • Publish a cache invalidation event for the affected api_id. CDN edges purge rendered documentation pages.

Changelog Generation Algorithm

Diff algorithm operates on the normalized spec tree. Core steps:

function diff_specs(old_spec, new_spec):
    changes = []

    // Endpoint-level diff
    old_endpoints = index_by(old_spec.endpoints, [method, path])
    new_endpoints = index_by(new_spec.endpoints, [method, path])

    for key in old_endpoints - new_endpoints:
        changes.append(BREAKING, key, "Endpoint removed")

    for key in new_endpoints - old_endpoints:
        changes.append(ADDED, key, "Endpoint added")

    for key in old_endpoints intersect new_endpoints:
        old_ep = old_endpoints[key]
        new_ep = new_endpoints[key]

        // Parameter diff
        for param in old_ep.required_params - new_ep.required_params:
            changes.append(BREAKING, key, "Required parameter removed: " + param)
        for param in new_ep.required_params - old_ep.required_params:
            changes.append(BREAKING, key, "New required parameter: " + param)

        // Schema diff (recursive)
        schema_changes = recursive_schema_diff(old_ep.response_schema, new_ep.response_schema)
        changes.extend(schema_changes)

        if new_ep.deprecated and not old_ep.deprecated:
            changes.append(DEPRECATED, key, "Endpoint deprecated")

    return changes

Live Sandbox Architecture

The Try-It-Out feature lets developers fill in parameter values in the browser and execute real API calls. Security and reliability requirements make this non-trivial:

Proxy Architecture

Browsers cannot directly call most APIs due to CORS restrictions. The sandbox service acts as a proxy:

  • Developer submits a request form: method, path, headers, body, credential selection.
  • Browser sends the request to the sandbox proxy endpoint: POST /sandbox/proxy with the target API identified by api_id.
  • Proxy validates: user is authorized to call this API, rate limit not exceeded, target URL is on the allowlist for this api_id.
  • Proxy injects the user’s stored sandbox credentials (decrypted from sandbox_credentials table).
  • Proxy forwards the request to the API’s sandbox_url with a timeout (default 30s).
  • Proxy returns the response (status, headers, body) to the browser.

Security Controls

  • The URL allowlist forwards only to pre-registered sandbox_url values. Never allow arbitrary URL injection (SSRF prevention).
  • Sandbox credentials are encrypted at rest, decrypted in memory only for the duration of the proxied request, and never returned to the browser.
  • A per-user, per-API rate limit on sandbox calls prevents abuse of upstream APIs.
  • Response sanitization strips Set-Cookie headers and other sensitive response headers before returning to the browser. Optionally truncate very large response bodies.

Search Index

Full-text search across all endpoints, schema fields, and descriptions:

  • For small to medium deployments, the search_vector GIN index on api_endpoints (PostgreSQL FTS) is sufficient. Query with WHERE search_vector @@ plainto_tsquery('english', input).
  • For large-scale developer portals (millions of endpoints across thousands of APIs), push normalized endpoint data to a search cluster such as Elasticsearch/OpenSearch. Index per tenant with separate field mappings. Supports fuzzy matching, synonym expansion (GET → retrieve, fetch), and faceted filtering by tag, method, and deprecation status.
  • For index freshness, after ingestion, emit an event to a search indexer worker. The worker upserts endpoint documents. Stale documents (from replaced versions) are deleted by version_id.

Version Tracking and Rollback

  • All versions are stored permanently (unless explicitly deleted by tenant). The current version is flagged; all others are accessible by version label.
  • Rollback: mark a previous version as current, regenerate the diff between the new current and the version it superseded, and emit a new changelog entry noting the rollback.
  • Deprecation policy: tenants can configure automatic sunset dates per version. A background job sends notifications and, after the sunset date, returns 410 Gone on docs requests for that version.

Failure Handling and Edge Cases

  • For an invalid spec, return detailed validation errors with line numbers and field paths. Do not partially persist a failed spec. Return the full error list (not just the first error) so the developer can fix all issues in one edit cycle.
  • If a spec is too large, reject it at upload time with a 413 and a clear message. For legitimate large specs, offer a multi-part upload or a URL-pull mode (the service fetches from a URL asynchronously).
  • On sandbox timeout, return a timeout error with the elapsed time. Do not hang the browser connection. Use async polling if latency is expected to exceed 30 seconds.
  • For circular references in a spec, OpenAPI schemas can have circular chains. The parser must detect cycles (via DFS with a visited set) and represent them safely in the normalized format (e.g., break cycles with a pointer in the internal format).
  • For concurrent version uploads, use an optimistic lock on is_current: UPDATE api_versions SET is_current = false WHERE api_id = ? AND is_current = true returning version_id, then INSERT the new version. If another upload races and flips is_current first, detect the conflict and retry or reject with a 409.
  • On CDN cache staleness, documentation pages are cached at the CDN edge for performance. On version publish, send purge requests to the CDN API for affected URLs (the current docs URL and the versioned URL). Use surrogate keys (cache tags) for efficient targeted purge.

Scalability Considerations

  • Documentation HTML can be rendered at publish time (static site generation) rather than on each request. Store rendered HTML in S3 and serve via CDN. Only re-render on spec update. Dynamic elements (sandbox, search) load separately as client-side components.
  • Raw spec text is stored in object storage (S3), not in the database. The database stores the normalized JSON and metadata. This keeps database row sizes manageable and allows efficient streaming of large specs.
  • Use a work queue (SQS, Kafka, or Pub/Sub) between the upload API and the parsing workers. This decouples upload latency from parsing time (which can take several seconds for large specs with deep schema resolution).
  • Partition the search index by tenant_id to ensure search isolation and allow per-tenant scaling. Public APIs across tenants can be included in a global public index for discoverability.
  • Proxy workers are stateless and I/O-bound (waiting on upstream API responses), so scale them horizontally. Use async I/O (Node.js, Go goroutines) for high concurrency with low memory overhead per request.

See also: Stripe Interview Guide 2026: Process, Bug Bash Round, and Payment Systems

See also: Atlassian Interview Guide

See also: Scale AI Interview Guide 2026: Data Infrastructure, RLHF Pipelines, and ML Engineering

newsletter

What's actually being asked right now

Interview patterns & comp trends, straight to your inbox.

No spam. Unsubscribe anytime.

newsletter

What's actually being asked right now

Interview patterns & comp trends, straight to your inbox.

No spam. Unsubscribe anytime.

1972 Soviet postage stamp commemorating the Mars 2 probe

worth a read

Mars For The Rest of Us — a weekly-or-more deep dive on the technical side of Mars exploration: rocket propulsion, microbiology, mission architecture, and everything in between. Written by Maciej Ceglowski.

Read it on Substack →
Scroll to Top