Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions apps/docs/app/openapi.json/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import { createOpenApiDownloadDocument } from '@/lib/openapi-download'

export const revalidate = false

export function GET() {
Comment thread
waleedlatif1 marked this conversation as resolved.
return Response.json(createOpenApiDownloadDocument(), {
headers: {
'Content-Disposition': 'attachment; filename="sim-openapi-v2.json"',
},
})
}
15 changes: 3 additions & 12 deletions apps/docs/components/docs-layout/sidebar-components.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -69,23 +69,12 @@ export function SidebarItem({ item }: { item: Item }) {
)
}

function isApiReferenceFolder(node: Folder): boolean {
if (node.index?.url.includes('/api-reference/')) return true
for (const child of node.children) {
if (child.type === 'page' && child.url.includes('/api-reference/')) return true
if (child.type === 'folder' && isApiReferenceFolder(child)) return true
}
return false
}

export function SidebarFolder({ item, children }: { item: Folder; children: ReactNode }) {
const pathname = usePathname()
const { prefetch } = useSidebar()
const hasActiveChild = checkHasActiveChild(item, pathname)
const isApiRef = isApiReferenceFolder(item)
const isOnApiRefPage = pathname.startsWith('/api-reference')
const hasChildren = item.children.length > 0
const defaultOpen = hasActiveChild || (isApiRef && isOnApiRefPage)
const defaultOpen = hasActiveChild
const [manualOpen, setManualOpen] = useState<{ pathname: string; open: boolean } | null>(null)
const open = manualOpen?.pathname === pathname ? manualOpen.open : defaultOpen
const toggleOpen = () => setManualOpen({ pathname, open: !open })
Expand Down Expand Up @@ -131,6 +120,7 @@ export function SidebarFolder({ item, children }: { item: Folder; children: Reac
chipHoverSurfaceClass
)}
aria-label={open ? 'Collapse' : 'Expand'}
aria-expanded={open}
>
<SidebarChevron open={open} className='text-[var(--text-icon)]' />
</button>
Expand All @@ -139,6 +129,7 @@ export function SidebarFolder({ item, children }: { item: Folder; children: Reac
) : (
<button
onClick={toggleOpen}
aria-expanded={open}
className={cn(
'flex flex-1 items-center gap-2 rounded-md px-2 py-1.5 text-sm transition-colors',
'text-[var(--text-body)]',
Expand Down
6 changes: 5 additions & 1 deletion apps/docs/content/docs/api-reference/getting-started.mdx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
---
title: Getting Started
description: Base URL, first API call, response format, error handling, and pagination
description: Base URL, OpenAPI specification, first API call, response format, error handling, and pagination
---

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

## OpenAPI specification

Download the [complete OpenAPI 3.1 specification](/openapi.json) as JSON for client generation, request validation, and API tooling.

## Quick Start

<Steps>
Expand Down
71 changes: 71 additions & 0 deletions apps/docs/lib/openapi-download.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
import { describe, expect, it } from 'vitest'
import { createOpenApiDownloadDocument } from '@/lib/openapi-download'
import { GET } from '@/app/openapi.json/route'

function collectReferences(value: unknown, references: string[] = []): string[] {
if (Array.isArray(value)) {
for (const item of value) collectReferences(item, references)
return references
}
if (!value || typeof value !== 'object') return references

for (const [key, item] of Object.entries(value)) {
if (key === '$ref' && typeof item === 'string') references.push(item)
collectReferences(item, references)
}
return references
}

function resolveReference(document: Record<string, unknown>, reference: string): unknown {
return reference
.replace('#/', '')
.split('/')
.reduce<unknown>((value, part) => {
if (!value || typeof value !== 'object') return undefined
return (value as Record<string, unknown>)[part]
}, document)
}

describe('OpenAPI download', () => {
it('combines every API domain into one OpenAPI document', () => {
const document = createOpenApiDownloadDocument()
const paths = document.paths as Record<string, unknown>
const tags = document.tags as Array<{ name: string }>

expect(document.openapi).toBe('3.1.0')
expect(Object.keys(paths)).toHaveLength(129)
expect(tags.map((tag) => tag.name)).toEqual([
'Workflows',
'Workflow Runs',
'Logs',
'Files',
'Audit Logs',
'Tables',
'Knowledge Bases',
'Billing',
'Meta',
'Workspaces',
'MCP Servers',
'Skills',
'Custom Tools',
'Credentials',
'Secrets',
'Catalog',
])
for (const reference of collectReferences(document)) {
expect(reference).toMatch(/^#\//)
expect(resolveReference(document, reference)).toBeDefined()
}
})

it('serves the document as a named JSON download', async () => {
const response = GET()
const document = await response.json()

expect(response.headers.get('content-type')).toContain('application/json')
expect(response.headers.get('content-disposition')).toBe(
'attachment; filename="sim-openapi-v2.json"'
)
expect(document.info.title).toBe('Sim API v2')
})
})
166 changes: 166 additions & 0 deletions apps/docs/lib/openapi-download.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,166 @@
import { isDeepStrictEqual } from 'node:util'
import { OPENAPI_SPEC_FILES } from '@/lib/openapi-specs'
import billingSpec from '@/openapi-v2-billing.json'
import filesAuditSpec from '@/openapi-v2-files-audit.json'
import knowledgeSpec from '@/openapi-v2-knowledge.json'
import logsSpec from '@/openapi-v2-logs.json'
import resourcesSpec from '@/openapi-v2-resources.json'
import tablesSpec from '@/openapi-v2-tables.json'
import workflowsSpec from '@/openapi-v2-workflows.json'

type JsonObject = Record<string, unknown>
type OpenApiSpecFile = (typeof OPENAPI_SPEC_FILES)[number]

const OPENAPI_DOCUMENTS_BY_FILE = {
'openapi-v2-workflows.json': workflowsSpec,
'openapi-v2-logs.json': logsSpec,
'openapi-v2-files-audit.json': filesAuditSpec,
'openapi-v2-tables.json': tablesSpec,
'openapi-v2-knowledge.json': knowledgeSpec,
'openapi-v2-billing.json': billingSpec,
'openapi-v2-resources.json': resourcesSpec,
} satisfies Record<OpenApiSpecFile, JsonObject>

const OPENAPI_DOCUMENTS = OPENAPI_SPEC_FILES.map((file) => ({
document: OPENAPI_DOCUMENTS_BY_FILE[file],
namespace: file
.replace('openapi-v2-', '')
.replace('.json', '')
.split('-')
.map((part) => `${part.charAt(0).toUpperCase()}${part.slice(1)}`)
.join(''),
}))

interface OpenApiDocumentEntry {
document: JsonObject
namespace: string
}

function requireObject(value: unknown, label: string): JsonObject {
if (!value || typeof value !== 'object' || Array.isArray(value)) {
throw new Error(`[docs] ${label} must be an object`)
}
return value as JsonObject
}

function assertSharedValue(documents: JsonObject[], key: string): unknown {
const value = documents[0]?.[key]
for (const document of documents.slice(1)) {
if (!isDeepStrictEqual(document[key], value)) {
throw new Error(`[docs] OpenAPI documents disagree on ${key}`)
}
}
return value
}

function mergeUniqueEntries(records: JsonObject[], label: string): JsonObject {
const merged: JsonObject = {}
for (const record of records) {
for (const [key, value] of Object.entries(record)) {
if (key in merged && !isDeepStrictEqual(merged[key], value)) {
throw new Error(`[docs] Conflicting OpenAPI ${label}: ${key}`)
}
merged[key] = value
}
}
return merged
}

function rewriteComponentReferences(value: unknown, namespace: string): unknown {
if (Array.isArray(value)) {
return value.map((item) => rewriteComponentReferences(item, namespace))
}
if (!value || typeof value !== 'object') return value

const rewritten: JsonObject = {}
for (const [key, item] of Object.entries(value)) {
if (key === '$ref' && typeof item === 'string') {
const match = item.match(/^#\/components\/([^/]+)\/(.+)$/)
rewritten[key] =
match && match[1] !== 'securitySchemes'
? `#/components/${match[1]}/${namespace}_${match[2]}`
: item
continue
}
rewritten[key] = rewriteComponentReferences(item, namespace)
}
return rewritten
}

function mergeComponents(entries: OpenApiDocumentEntry[]): JsonObject {
const merged: JsonObject = {}

for (const { document, namespace } of entries) {
const components = requireObject(document.components, 'components')
for (const [componentType, value] of Object.entries(components)) {
const componentEntries = requireObject(value, componentType)
const existing = requireObject(merged[componentType] ?? {}, componentType)

if (componentType === 'securitySchemes') {
merged[componentType] = mergeUniqueEntries([existing, componentEntries], 'security scheme')
continue
}

const namespacedEntries: JsonObject = {}
for (const [name, component] of Object.entries(componentEntries)) {
namespacedEntries[`${namespace}_${name}`] = rewriteComponentReferences(component, namespace)
}
merged[componentType] = mergeUniqueEntries([existing, namespacedEntries], componentType)
}
}

return merged
}

function mergeTags(documents: JsonObject[]): unknown[] {
const tagsByName = new Map<string, unknown>()
for (const document of documents) {
if (!Array.isArray(document.tags)) throw new Error('[docs] OpenAPI tags must be an array')
for (const tag of document.tags) {
const record = requireObject(tag, 'tag')
if (typeof record.name !== 'string') throw new Error('[docs] OpenAPI tag requires a name')
const existing = tagsByName.get(record.name)
if (existing && !isDeepStrictEqual(existing, tag)) {
throw new Error(`[docs] Conflicting OpenAPI tag: ${record.name}`)
}
tagsByName.set(record.name, tag)
}
}
return [...tagsByName.values()]
}

/** Builds the complete public API description from the domain specs used by the reference UI. */
export function createOpenApiDownloadDocument(): JsonObject {
const entries = OPENAPI_DOCUMENTS
const documents = entries.map(({ document }) => document)
if (documents.length === 0) throw new Error('[docs] At least one OpenAPI document is required')

const info = requireObject(documents[0].info, 'info')
const version = info.version
for (const document of documents.slice(1)) {
const documentInfo = requireObject(document.info, 'info')
if (documentInfo.version !== version) {
throw new Error('[docs] OpenAPI documents disagree on info.version')
}
}

return {
openapi: assertSharedValue(documents, 'openapi'),
info: {
title: 'Sim API v2',
version,
description: 'Complete OpenAPI description for the Sim API v2.',
},
servers: assertSharedValue(documents, 'servers'),
security: assertSharedValue(documents, 'security'),
tags: mergeTags(documents),
paths: mergeUniqueEntries(
entries.map(({ document, namespace }) =>
requireObject(rewriteComponentReferences(document.paths, namespace), 'paths')
),
'path'
),
components: mergeComponents(entries),
'x-generated-by': assertSharedValue(documents, 'x-generated-by'),
}
}
Loading