Files
OpenViking/docs/scripts/check-api-reference.mjs
T
yufeng 91a8f03084 docs: add tabbed API examples (#3185)
* docs: add tabbed API examples

* docs: move llms actions to page header

* docs: make content layout responsive

* docs: split guide navigation sections

* docs: regroup agent and migration guides

* docs: add wide-screen page gutters

* fix: address API docs review findings

* fix: address API docs review findings

* fix: harden API example tabs

* fix: harden tabbed API documentation

* fix(docs): hide unavailable llms actions

* fix(docs): group API examples into tabs

* fix(docs): preserve localized response tabs
2026-07-13 10:00:52 +08:00

247 lines
9.0 KiB
JavaScript

import fs from 'node:fs'
import path from 'node:path'
import { fileURLToPath } from 'node:url'
const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..')
const routerDir = path.join(repoRoot, 'openviking/server/routers')
const apiDocs = ['zh', 'en'].flatMap((locale) =>
fs.readdirSync(path.join(repoRoot, 'docs', locale, 'api'))
.filter((file) => file.endsWith('.md') && !file.startsWith('01-') && !file.startsWith('99-'))
.map((file) => path.join(repoRoot, 'docs', locale, 'api', file))
)
function normalizePath(value) {
const pathname = value.split('?')[0].replace(/\/$/, '') || '/'
return pathname.replace(/\{[^}]+\}/g, '{}')
}
function closingDelimiter(source, openingIndex, opening = '(', closing = ')') {
let depth = 0
let quote = ''
let escaped = false
for (let index = openingIndex; index < source.length; index++) {
const character = source[index]
if (quote) {
if (escaped) escaped = false
else if (character === '\\') escaped = true
else if (character === quote) quote = ''
continue
}
if (character === '"' || character === "'" || character === '`') {
quote = character
continue
}
if (character === opening) depth++
else if (character === closing && --depth === 0) return index
}
return -1
}
function splitTopLevel(source) {
const parts = []
let start = 0
const stack = []
let quote = ''
let escaped = false
const pairs = { '(': ')', '[': ']', '{': '}' }
for (let index = 0; index < source.length; index++) {
const character = source[index]
if (quote) {
if (escaped) escaped = false
else if (character === '\\') escaped = true
else if (character === quote) quote = ''
continue
}
if (character === '"' || character === "'" || character === '`') quote = character
else if (pairs[character]) stack.push(pairs[character])
else if (character === stack.at(-1)) stack.pop()
else if (character === ',' && stack.length === 0) {
parts.push(source.slice(start, index).trim())
start = index + 1
}
}
const tail = source.slice(start).trim()
if (tail) parts.push(tail)
return parts
}
const routes = new Map()
for (const file of fs.readdirSync(routerDir).filter((name) => name.endsWith('.py'))) {
const source = fs.readFileSync(path.join(routerDir, file), 'utf8')
const prefix = source.match(/router\s*=\s*APIRouter\([^)]*prefix=["']([^"']+)/s)?.[1] ?? ''
for (const match of source.matchAll(/@router\.(get|post|put|patch|delete)\(\s*["']([^"']*)/g)) {
const definition = source.indexOf('def ', match.index + match[0].length)
const opening = source.indexOf('(', definition)
const closing = closingDelimiter(source, opening)
const signature = closing < 0 ? '' : source.slice(opening + 1, closing)
const routePath = prefix + match[2]
const pathParameters = new Set(
Array.from(routePath.matchAll(/\{([^}]+)\}/g), (item) => item[1])
)
const query = new Map()
for (const parameter of splitTopLevel(signature)) {
const queryMatch = parameter.match(/^([A-Za-z_]\w*)\s*:[\s\S]*?=\s*Query\(([\s\S]*)\)$/)
if (queryMatch) {
query.set(queryMatch[1], /^\s*\.\.\.(?:\s*,|\s*$)/.test(queryMatch[2]))
continue
}
const defaultMatch = parameter.match(/^([A-Za-z_]\w*)\s*:[\s\S]*?=\s*([\s\S]+)$/)
if (!defaultMatch || pathParameters.has(defaultMatch[1])) continue
if (
/^(?:Path|Depends|Body|Header|Cookie|File|Form|Security)\s*\(/.test(defaultMatch[2])
) continue
query.set(defaultMatch[1], false)
}
routes.set(`${match[1].toUpperCase()} ${normalizePath(routePath)}`, { query, pathParameters })
}
}
const clientSource = fs.readFileSync(path.join(repoRoot, 'sdk/typescript/src/client.ts'), 'utf8')
const clientMethods = new Map()
for (const match of clientSource.matchAll(/^ (?:async )?([A-Za-z][A-Za-z0-9]*)\s*\(/gm)) {
const opening = clientSource.indexOf('(', match.index)
const closing = closingDelimiter(clientSource, opening)
if (closing < 0) continue
const parameters = splitTopLevel(clientSource.slice(opening + 1, closing))
const required = parameters.filter((parameter) => {
const declaration = parameter.split(':', 1)[0]
return !declaration.includes('?') && !parameter.includes('=') && !parameter.startsWith('...')
}).length
clientMethods.set(match[1], {
required,
maximum: parameters.some((parameter) => parameter.startsWith('...'))
? Infinity
: parameters.length,
parameters
})
}
const errors = []
let httpExamples = 0
let typescriptCalls = 0
const curlUrlPattern = String.raw`["']?https?:\/\/[^/\s"']+(\/[A-Za-z0-9_{}<>?=&./:-]+)`
const explicitCurlPattern = new RegExp(
String.raw`\bcurl\b[^\n]*?(?:-X|--request)\s+(GET|POST|PUT|PATCH|DELETE)\s+` +
curlUrlPattern,
'g'
)
const implicitGetCurlPattern = new RegExp(
String.raw`\bcurl\b(?![^\n]*(?:-X|--request))[^\n]*?` + curlUrlPattern,
'g'
)
for (const file of apiDocs) {
const source = fs.readFileSync(file, 'utf8')
const relative = path.relative(repoRoot, file)
const httpReferences = [
...Array.from(
source.matchAll(/^\s*(GET|POST|PUT|PATCH|DELETE)\s+(\/[A-Za-z0-9_{}?=&./:-]+)/gm),
(match) => [match[1], match[2]]
),
...Array.from(
source.matchAll(explicitCurlPattern),
(match) => [match[1], match[2]]
),
...Array.from(
source.matchAll(implicitGetCurlPattern),
(match) => ['GET', match[1]]
)
]
for (const [method, documentedPath] of httpReferences) {
const normalizedPath = normalizePath(documentedPath)
const route = `${method} ${normalizedPath}`
httpExamples++
let contract = routes.get(route)
if (!contract) {
for (const [candidate, candidateContract] of routes) {
const [candidateMethod, candidatePath] = candidate.split(' ', 2)
if (candidateMethod !== method) continue
const pattern = candidatePath
.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
.replace(/\\\{\\\}/g, '[^/]+')
if (new RegExp(`^${pattern}$`).test(normalizedPath)) {
contract = candidateContract
break
}
}
}
if (!contract) {
errors.push(`${relative}: unknown HTTP route ${route}`)
continue
}
const documentedPathParameters = new Set(
Array.from(documentedPath.split('?')[0].matchAll(/\{([^}]+)\}/g), (item) => item[1])
)
if (documentedPathParameters.size) {
for (const name of documentedPathParameters) {
if (!contract.pathParameters.has(name)) {
errors.push(`${relative}: ${route} has unknown path parameter ${name}`)
}
}
for (const name of contract.pathParameters) {
if (!documentedPathParameters.has(name)) {
errors.push(`${relative}: ${route} is missing path parameter ${name}`)
}
}
}
const queryNames = new Set(
(documentedPath.split('?')[1] ?? '')
.split('&')
.filter(Boolean)
.map((item) => item.split('=')[0])
)
for (const name of queryNames) {
if (!contract.query.has(name)) {
errors.push(`${relative}: ${route} has unknown query parameter ${name}`)
}
}
for (const [name, required] of contract.query) {
if (required && !queryNames.has(name)) {
errors.push(`${relative}: ${route} is missing required query parameter ${name}`)
}
}
}
for (const block of source.matchAll(/```(?:typescript|ts)\n([\s\S]*?)\n```/g)) {
for (const call of block[1].matchAll(/\bclient\.([A-Za-z][A-Za-z0-9]*)\s*\(/g)) {
typescriptCalls++
const contract = clientMethods.get(call[1])
if (!contract) {
errors.push(`${relative}: unknown TypeScript SDK method client.${call[1]}()`)
continue
}
const opening = call.index + call[0].lastIndexOf('(')
const closing = closingDelimiter(block[1], opening)
if (closing < 0) {
errors.push(`${relative}: could not parse TypeScript SDK call client.${call[1]}()`)
continue
}
const args = splitTopLevel(block[1].slice(opening + 1, closing))
const argumentCount = args.length
if (argumentCount < contract.required || argumentCount > contract.maximum) {
const expected = contract.required === contract.maximum
? String(contract.required)
: `${contract.required}-${contract.maximum}`
errors.push(
`${relative}: client.${call[1]}() has ${argumentCount} arguments; expected ${expected}`
)
}
for (let index = 0; index < Math.min(args.length, contract.parameters.length); index++) {
const type = contract.parameters[index].match(/:\s*([^=]+?)(?:\s*=|$)/)?.[1]?.trim()
if (type === 'string' && /^[{[]/.test(args[index])) {
errors.push(`${relative}: client.${call[1]}() argument ${index + 1} must be a string`)
}
}
}
}
}
if (errors.length) {
console.error(errors.join('\n'))
process.exitCode = 1
} else {
console.log(
`API reference check passed: ${httpExamples} HTTP examples with query contracts, ` +
`${typescriptCalls} TypeScript SDK calls with signature contracts`
)
}