Why We Built Our Own OpenAPI to Markdoc Generator
No existing tool did what we needed, so we built our own.
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.
Why Markdoc?
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.
The Challenge: OpenAPI → Markdoc
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.
The Script Architecture
Our generator script follows a simple pattern: parse the OpenAPI spec, group endpoints by resource, then generate Markdoc pages for each resource and endpoint.
Core Generation Logic
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);
}
1. Grouping Endpoints by Resource
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;
}
2. Generating Resource Overview Pages
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 %}`;
}
3. Generating Individual Endpoint Pages
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 %}`;
}
4. Navigation Structure
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)};`;
}
Running the Generator
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.
Key Decisions
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