|
| 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