Markdoc and OpenAPI/Swagger spec generator

3 months ago 1

Part 2 of Building a Modern SDK System From Scratch

In Part 1, I showed how we generate our OpenAPI spec from RSwag tests. Now that we had our spec, we wanted to generate our api specs.

We evaluated multiple options: Docusaurus, Redocly, even Swagger UI with heavy customization. We needed React components for interactive code examples and API explorers, but we also wanted everyone on the team to be able to update docs without learning React.

Our API doc generator had to give us total control to make overrides when needed. Platform lock-in was also a major concern. I wanted something we could own completely.

Markdoc, created by Stripe (whose API docs are legendary), hit the sweet spot. It's basically Markdown that compiles to React, with a powerful tag system for custom components.

{% code-example title="Create a workflow" languages={ruby: "...", python: "...", typescript: "..."} /%}

This renders our custom React component while keeping the authoring experience simple.

Markdoc was perfect for hand-written docs, but we needed to generate pages from our OpenAPI spec. No existing tool did this the way we wanted. Markdoc's community wasn't as vast as I'd thought and I couldn't find any plugins to generate docs from an openapi spec.

We needed to group endpoints logically (not just alphabetically), maintain our own navigation order, and extract all the details from the spec like example responses, request params, and field descriptions. Plus we had to generate working code examples for each SDK language.

So we built our own generator. Here's how it works.

Our generator script follows a simple pattern: parse the OpenAPI spec, group endpoints by resource, then generate Markdoc pages for each resource and endpoint.

async function generateAPIDocs() { // Determine resource groupings const resources = groupEndpointsByResource(spec.paths); // Ensure main directory exists ensureDirectoryExists(CONFIG.outputDir); // Generate pages for each resource Object.entries(resources).forEach(([resourceName, resource]) => { const resourceDir = path.join(CONFIG.outputDir, resourceName); // Ensure resource directory exists ensureDirectoryExists(resourceDir); // Generate resource overview page const overviewContent = generateResourceOverviewPage( resourceName, resource, spec ); writeFile(path.join(resourceDir, "index.md"), overviewContent); // Generate individual endpoint pages resource.endpoints.forEach((endpoint) => { const filename = getEndpointFilename(endpoint) + ".md"; const endpointContent = generateEndpointPage( resourceName, endpoint, spec ); writeFile(path.join(resourceDir, filename), endpointContent); }); }); // Generate API resources constants for global navigations const constantsContent = generateAPIResourcesConstants(resources); writeFile(CONFIG.constantsPath, constantsContent); }

The first step extracts resources from your API paths:

function groupEndpointsByResource(paths) { const resources = {}; Object.entries(paths).forEach(([path, methods]) => { // Extract resource from /api/v1/workflows/{id} -> "workflows" const resource = path.match(/\/api\/v1\/([^\/]+)/)?.[1]; if (!resources[resource]) { resources[resource] = { endpoints: [] }; } // Add each method (GET, POST, etc) to the resource Object.entries(methods).forEach(([method, operation]) => { resources[resource].endpoints.push({ method, path, operation, operationId: operation.operationId }); }); }); return resources; }

Each resource gets an overview page with all its endpoints:

function generateResourceOverviewPage(resourceName, resource, spec) { const title = resourceName.charAt(0).toUpperCase() + resourceName.slice(1); return `--- title: ${title} API description: Manage ${resourceName} in your account --- {% columns %} {% column col=1 %} ## Available Endpoints {% endpoints %} ${resource.endpoints.map(endpoint => `- ${endpoint.method}, /api/${resourceName}/${getEndpointFilename(endpoint)}, ${endpoint.path}` ).join('\n')} {% /endpoints %} ## The ${title} Object ${generateSchemaAttributes(resource, spec)} {% /column %} {% column %} \`\`\`json {% title="${title} Object" %} ${JSON.stringify(getExampleObject(resource, spec), null, 2)} \`\`\` {% /column %} {% /columns %}`; }

Each endpoint gets its own detailed documentation:

function generateEndpointPage(resourceName, endpoint, spec) { const { method, path, operation } = endpoint; return `--- title: ${operation.summary} description: ${operation.description || ''} --- {% columns %} {% column col=1 %} **Endpoint** \`${method} https://api.yourcompany.com${path}\` ${generateParameters(operation.parameters)} ${generateRequestBody(operation.requestBody, spec)} ${generateResponseSchema(operation.responses, spec)} {% /column %} {% column %} {% code-example title="${operation.summary}" languages={${generateCodeExamples(operation, method, path)}} /%} \`\`\`json {% title="Response" %} ${JSON.stringify(getResponseExample(operation, spec), null, 2)} \`\`\` {% /column %} {% /columns %}`; }

The script also generates a constants file for navigation:

function generateAPIResourcesConstants(resources) { const apiResources = Object.keys(resources).map(resourceName => ({ name: resourceName, title: resourceName.charAt(0).toUpperCase() + resourceName.slice(1), endpoints: resources[resourceName].endpoints.map(endpoint => ({ method: endpoint.method, path: endpoint.path, title: endpoint.operation.summary || `${endpoint.method} ${endpoint.path}` })) })); return `export const API_RESOURCES = ${JSON.stringify(apiResources, null, 2)};`; }npm run generate-api-docs # Outputs: ✓ Loaded OpenAPI spec ✓ Found 12 resources ✓ Generated 87 endpoint pages ✓ Created navigation structure

The generated files slot right into our Markdoc site, where custom React components handle the interactive elements.

We could have used Redocly or similar, but owning the generator gives us complete control. We can order navigation however makes sense for our users (not just alphabetically), format code examples exactly how we want them, keep everything consistent with our design system, and directly integrate with our SDK examples.

For a developer tools company, documentation *is* the product experience.

Next up in Part 3: How we generate type-safe SDKs from this same OpenAPI spec.

Building a modern SDK system from scratch? I'd love to hear your approach - reach out on X

Discussion about this post

Read Entire Article