Skip to content

Commit 47c0805

Browse files
authored
improvement(docs): streamline API reference navigation (#7383)
1 parent 938a315 commit 47c0805

5 files changed

Lines changed: 256 additions & 13 deletions

File tree

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
import { createOpenApiDownloadDocument } from '@/lib/openapi-download'
2+
3+
export const revalidate = false
4+
5+
export function GET() {
6+
return Response.json(createOpenApiDownloadDocument(), {
7+
headers: {
8+
'Content-Disposition': 'attachment; filename="sim-openapi-v2.json"',
9+
},
10+
})
11+
}

apps/docs/components/docs-layout/sidebar-components.tsx

Lines changed: 3 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -69,23 +69,12 @@ export function SidebarItem({ item }: { item: Item }) {
6969
)
7070
}
7171

72-
function isApiReferenceFolder(node: Folder): boolean {
73-
if (node.index?.url.includes('/api-reference/')) return true
74-
for (const child of node.children) {
75-
if (child.type === 'page' && child.url.includes('/api-reference/')) return true
76-
if (child.type === 'folder' && isApiReferenceFolder(child)) return true
77-
}
78-
return false
79-
}
80-
8172
export function SidebarFolder({ item, children }: { item: Folder; children: ReactNode }) {
8273
const pathname = usePathname()
8374
const { prefetch } = useSidebar()
8475
const hasActiveChild = checkHasActiveChild(item, pathname)
85-
const isApiRef = isApiReferenceFolder(item)
86-
const isOnApiRefPage = pathname.startsWith('/api-reference')
8776
const hasChildren = item.children.length > 0
88-
const defaultOpen = hasActiveChild || (isApiRef && isOnApiRefPage)
77+
const defaultOpen = hasActiveChild
8978
const [manualOpen, setManualOpen] = useState<{ pathname: string; open: boolean } | null>(null)
9079
const open = manualOpen?.pathname === pathname ? manualOpen.open : defaultOpen
9180
const toggleOpen = () => setManualOpen({ pathname, open: !open })
@@ -131,6 +120,7 @@ export function SidebarFolder({ item, children }: { item: Folder; children: Reac
131120
chipHoverSurfaceClass
132121
)}
133122
aria-label={open ? 'Collapse' : 'Expand'}
123+
aria-expanded={open}
134124
>
135125
<SidebarChevron open={open} className='text-[var(--text-icon)]' />
136126
</button>
@@ -139,6 +129,7 @@ export function SidebarFolder({ item, children }: { item: Folder; children: Reac
139129
) : (
140130
<button
141131
onClick={toggleOpen}
132+
aria-expanded={open}
142133
className={cn(
143134
'flex flex-1 items-center gap-2 rounded-md px-2 py-1.5 text-sm transition-colors',
144135
'text-[var(--text-body)]',

apps/docs/content/docs/api-reference/getting-started.mdx

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
---
22
title: Getting Started
3-
description: Base URL, first API call, response format, error handling, and pagination
3+
description: Base URL, OpenAPI specification, first API call, response format, error handling, and pagination
44
---
55

66
import { Callout } from 'fumadocs-ui/components/callout'
@@ -15,6 +15,10 @@ All API requests are made to:
1515
https://www.sim.ai
1616
```
1717

18+
## OpenAPI specification
19+
20+
Download the [complete OpenAPI 3.1 specification](/openapi.json) as JSON for client generation, request validation, and API tooling.
21+
1822
## Quick Start
1923

2024
<Steps>
Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
import { describe, expect, it } from 'vitest'
2+
import { createOpenApiDownloadDocument } from '@/lib/openapi-download'
3+
import { GET } from '@/app/openapi.json/route'
4+
5+
function collectReferences(value: unknown, references: string[] = []): string[] {
6+
if (Array.isArray(value)) {
7+
for (const item of value) collectReferences(item, references)
8+
return references
9+
}
10+
if (!value || typeof value !== 'object') return references
11+
12+
for (const [key, item] of Object.entries(value)) {
13+
if (key === '$ref' && typeof item === 'string') references.push(item)
14+
collectReferences(item, references)
15+
}
16+
return references
17+
}
18+
19+
function resolveReference(document: Record<string, unknown>, reference: string): unknown {
20+
return reference
21+
.replace('#/', '')
22+
.split('/')
23+
.reduce<unknown>((value, part) => {
24+
if (!value || typeof value !== 'object') return undefined
25+
return (value as Record<string, unknown>)[part]
26+
}, document)
27+
}
28+
29+
describe('OpenAPI download', () => {
30+
it('combines every API domain into one OpenAPI document', () => {
31+
const document = createOpenApiDownloadDocument()
32+
const paths = document.paths as Record<string, unknown>
33+
const tags = document.tags as Array<{ name: string }>
34+
35+
expect(document.openapi).toBe('3.1.0')
36+
expect(Object.keys(paths)).toHaveLength(129)
37+
expect(tags.map((tag) => tag.name)).toEqual([
38+
'Workflows',
39+
'Workflow Runs',
40+
'Logs',
41+
'Files',
42+
'Audit Logs',
43+
'Tables',
44+
'Knowledge Bases',
45+
'Billing',
46+
'Meta',
47+
'Workspaces',
48+
'MCP Servers',
49+
'Skills',
50+
'Custom Tools',
51+
'Credentials',
52+
'Secrets',
53+
'Catalog',
54+
])
55+
for (const reference of collectReferences(document)) {
56+
expect(reference).toMatch(/^#\//)
57+
expect(resolveReference(document, reference)).toBeDefined()
58+
}
59+
})
60+
61+
it('serves the document as a named JSON download', async () => {
62+
const response = GET()
63+
const document = await response.json()
64+
65+
expect(response.headers.get('content-type')).toContain('application/json')
66+
expect(response.headers.get('content-disposition')).toBe(
67+
'attachment; filename="sim-openapi-v2.json"'
68+
)
69+
expect(document.info.title).toBe('Sim API v2')
70+
})
71+
})

apps/docs/lib/openapi-download.ts

Lines changed: 166 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,166 @@
1+
import { isDeepStrictEqual } from 'node:util'
2+
import { OPENAPI_SPEC_FILES } from '@/lib/openapi-specs'
3+
import billingSpec from '@/openapi-v2-billing.json'
4+
import filesAuditSpec from '@/openapi-v2-files-audit.json'
5+
import knowledgeSpec from '@/openapi-v2-knowledge.json'
6+
import logsSpec from '@/openapi-v2-logs.json'
7+
import resourcesSpec from '@/openapi-v2-resources.json'
8+
import tablesSpec from '@/openapi-v2-tables.json'
9+
import workflowsSpec from '@/openapi-v2-workflows.json'
10+
11+
type JsonObject = Record<string, unknown>
12+
type OpenApiSpecFile = (typeof OPENAPI_SPEC_FILES)[number]
13+
14+
const OPENAPI_DOCUMENTS_BY_FILE = {
15+
'openapi-v2-workflows.json': workflowsSpec,
16+
'openapi-v2-logs.json': logsSpec,
17+
'openapi-v2-files-audit.json': filesAuditSpec,
18+
'openapi-v2-tables.json': tablesSpec,
19+
'openapi-v2-knowledge.json': knowledgeSpec,
20+
'openapi-v2-billing.json': billingSpec,
21+
'openapi-v2-resources.json': resourcesSpec,
22+
} satisfies Record<OpenApiSpecFile, JsonObject>
23+
24+
const OPENAPI_DOCUMENTS = OPENAPI_SPEC_FILES.map((file) => ({
25+
document: OPENAPI_DOCUMENTS_BY_FILE[file],
26+
namespace: file
27+
.replace('openapi-v2-', '')
28+
.replace('.json', '')
29+
.split('-')
30+
.map((part) => `${part.charAt(0).toUpperCase()}${part.slice(1)}`)
31+
.join(''),
32+
}))
33+
34+
interface OpenApiDocumentEntry {
35+
document: JsonObject
36+
namespace: string
37+
}
38+
39+
function requireObject(value: unknown, label: string): JsonObject {
40+
if (!value || typeof value !== 'object' || Array.isArray(value)) {
41+
throw new Error(`[docs] ${label} must be an object`)
42+
}
43+
return value as JsonObject
44+
}
45+
46+
function assertSharedValue(documents: JsonObject[], key: string): unknown {
47+
const value = documents[0]?.[key]
48+
for (const document of documents.slice(1)) {
49+
if (!isDeepStrictEqual(document[key], value)) {
50+
throw new Error(`[docs] OpenAPI documents disagree on ${key}`)
51+
}
52+
}
53+
return value
54+
}
55+
56+
function mergeUniqueEntries(records: JsonObject[], label: string): JsonObject {
57+
const merged: JsonObject = {}
58+
for (const record of records) {
59+
for (const [key, value] of Object.entries(record)) {
60+
if (key in merged && !isDeepStrictEqual(merged[key], value)) {
61+
throw new Error(`[docs] Conflicting OpenAPI ${label}: ${key}`)
62+
}
63+
merged[key] = value
64+
}
65+
}
66+
return merged
67+
}
68+
69+
function rewriteComponentReferences(value: unknown, namespace: string): unknown {
70+
if (Array.isArray(value)) {
71+
return value.map((item) => rewriteComponentReferences(item, namespace))
72+
}
73+
if (!value || typeof value !== 'object') return value
74+
75+
const rewritten: JsonObject = {}
76+
for (const [key, item] of Object.entries(value)) {
77+
if (key === '$ref' && typeof item === 'string') {
78+
const match = item.match(/^#\/components\/([^/]+)\/(.+)$/)
79+
rewritten[key] =
80+
match && match[1] !== 'securitySchemes'
81+
? `#/components/${match[1]}/${namespace}_${match[2]}`
82+
: item
83+
continue
84+
}
85+
rewritten[key] = rewriteComponentReferences(item, namespace)
86+
}
87+
return rewritten
88+
}
89+
90+
function mergeComponents(entries: OpenApiDocumentEntry[]): JsonObject {
91+
const merged: JsonObject = {}
92+
93+
for (const { document, namespace } of entries) {
94+
const components = requireObject(document.components, 'components')
95+
for (const [componentType, value] of Object.entries(components)) {
96+
const componentEntries = requireObject(value, componentType)
97+
const existing = requireObject(merged[componentType] ?? {}, componentType)
98+
99+
if (componentType === 'securitySchemes') {
100+
merged[componentType] = mergeUniqueEntries([existing, componentEntries], 'security scheme')
101+
continue
102+
}
103+
104+
const namespacedEntries: JsonObject = {}
105+
for (const [name, component] of Object.entries(componentEntries)) {
106+
namespacedEntries[`${namespace}_${name}`] = rewriteComponentReferences(component, namespace)
107+
}
108+
merged[componentType] = mergeUniqueEntries([existing, namespacedEntries], componentType)
109+
}
110+
}
111+
112+
return merged
113+
}
114+
115+
function mergeTags(documents: JsonObject[]): unknown[] {
116+
const tagsByName = new Map<string, unknown>()
117+
for (const document of documents) {
118+
if (!Array.isArray(document.tags)) throw new Error('[docs] OpenAPI tags must be an array')
119+
for (const tag of document.tags) {
120+
const record = requireObject(tag, 'tag')
121+
if (typeof record.name !== 'string') throw new Error('[docs] OpenAPI tag requires a name')
122+
const existing = tagsByName.get(record.name)
123+
if (existing && !isDeepStrictEqual(existing, tag)) {
124+
throw new Error(`[docs] Conflicting OpenAPI tag: ${record.name}`)
125+
}
126+
tagsByName.set(record.name, tag)
127+
}
128+
}
129+
return [...tagsByName.values()]
130+
}
131+
132+
/** Builds the complete public API description from the domain specs used by the reference UI. */
133+
export function createOpenApiDownloadDocument(): JsonObject {
134+
const entries = OPENAPI_DOCUMENTS
135+
const documents = entries.map(({ document }) => document)
136+
if (documents.length === 0) throw new Error('[docs] At least one OpenAPI document is required')
137+
138+
const info = requireObject(documents[0].info, 'info')
139+
const version = info.version
140+
for (const document of documents.slice(1)) {
141+
const documentInfo = requireObject(document.info, 'info')
142+
if (documentInfo.version !== version) {
143+
throw new Error('[docs] OpenAPI documents disagree on info.version')
144+
}
145+
}
146+
147+
return {
148+
openapi: assertSharedValue(documents, 'openapi'),
149+
info: {
150+
title: 'Sim API v2',
151+
version,
152+
description: 'Complete OpenAPI description for the Sim API v2.',
153+
},
154+
servers: assertSharedValue(documents, 'servers'),
155+
security: assertSharedValue(documents, 'security'),
156+
tags: mergeTags(documents),
157+
paths: mergeUniqueEntries(
158+
entries.map(({ document, namespace }) =>
159+
requireObject(rewriteComponentReferences(document.paths, namespace), 'paths')
160+
),
161+
'path'
162+
),
163+
components: mergeComponents(entries),
164+
'x-generated-by': assertSharedValue(documents, 'x-generated-by'),
165+
}
166+
}

0 commit comments

Comments
 (0)