import { readFileSync, readdirSync, writeFileSync } from 'node:fs'; import { dirname, join } from 'node:path'; import { fileURLToPath } from 'node:url'; const ROOT = dirname(dirname(fileURLToPath(import.meta.url))); const OPENAPI_DIR = join(ROOT, 'docs/api'); const JSON_SPEC_SUFFIX = '.openapi.json'; const YAML_SPEC_SUFFIX = '.openapi.yaml'; const ISO2_TO_ISO3 = JSON.parse(readFileSync(join(ROOT, 'shared/iso2-to-iso3.json'), 'utf8')); const FILTER_PARAM_CONTRACTS = JSON.parse(readFileSync(join(ROOT, 'shared/openapi-filter-param-contracts.json'), 'utf8')); const PREDICTION_MARKET_CATEGORIES = [ ...FILTER_PARAM_CONTRACTS.predictionMarketTechCategories, ...FILTER_PARAM_CONTRACTS.predictionMarketFinanceCategories, ]; function quotedList(values) { return values.map((value) => `"${value}"`).join(', '); } export const OPENAPI_FILTER_PARAM_SCHEMA_OVERRIDES = [ { path: '/api/displacement/v1/get-cross-border-movements', method: 'get', name: 'route', schema: { type: 'string', maxLength: 120 }, }, { path: '/api/news/v1/list-country-headlines', method: 'get', name: 'country_codes', schema: { type: 'array', minItems: 1, maxItems: 250, items: { type: 'string' } }, }, // ListNewsPool: the request validator caps these (list_news_pool.proto). { path: '/api/news/v1/list-news-pool', method: 'get', name: 'languages', schema: { type: 'array', maxItems: 8, items: { type: 'string' } }, }, { path: '/api/news/v1/list-news-pool', method: 'get', name: 'editions', schema: { type: 'array', maxItems: 8, items: { type: 'string' } }, }, { path: '/api/news/v1/list-news-pool', method: 'get', name: 'category', schema: { type: 'string', maxLength: 32 }, }, // ListSourceItems: the request validator caps these (list_source_items.proto). { path: '/api/news/v1/list-source-items', method: 'get', name: 'editions', schema: { type: 'array', maxItems: 8, items: { type: 'string' } }, }, { path: '/api/news/v1/list-source-items', method: 'get', name: 'category', schema: { type: 'string', minLength: 1, maxLength: 32 }, }, { path: '/api/news/v1/list-source-items', method: 'get', name: 'sources', schema: { type: 'array', minItems: 1, maxItems: 24, items: { type: 'string', maxLength: 120 } }, }, { path: '/api/news/v1/list-source-items', method: 'get', name: 'lang', schema: { type: 'string', maxLength: 8 }, }, { path: '/api/infrastructure/v1/get-bootstrap-data', method: 'get', name: 'keys', schema: { type: 'array', maxItems: 100, items: { type: 'string', minLength: 1 } }, }, { path: '/api/conflict/v1/get-humanitarian-summary', method: 'get', name: 'country_code', schema: { type: 'string', enum: Object.keys(ISO2_TO_ISO3).sort() }, }, { path: '/api/economic/v1/get-bls-series', method: 'get', name: 'series_id', description: `BLS/FRED-backed series ID. Supported values: ${quotedList(FILTER_PARAM_CONTRACTS.economicBlsSeriesIds)}.`, schema: { type: 'string', enum: FILTER_PARAM_CONTRACTS.economicBlsSeriesIds }, }, { path: '/api/economic/v1/get-fred-series', method: 'get', name: 'series_id', description: `FRED series ID. Supported values: ${quotedList(FILTER_PARAM_CONTRACTS.economicFredSeriesIds)}.`, schema: { type: 'string', enum: FILTER_PARAM_CONTRACTS.economicFredSeriesIds }, }, { path: '/api/forecast/v1/get-forecasts', method: 'get', name: 'domain', schema: { type: 'string', enum: FILTER_PARAM_CONTRACTS.forecastDomains }, }, { path: '/api/infrastructure/v1/get-temporal-baseline', method: 'get', name: 'type', schema: { type: 'string', enum: FILTER_PARAM_CONTRACTS.infrastructureTemporalBaselineTypes }, }, { path: '/api/infrastructure/v1/get-temporal-baseline', method: 'get', name: 'region', schema: { type: 'string', enum: ['global', ''] }, }, { path: '/api/intelligence/v1/compute-energy-shock', method: 'get', name: 'chokepoint_id', schema: { type: 'string', enum: FILTER_PARAM_CONTRACTS.intelligenceChokepointIds }, }, { path: '/api/intelligence/v1/compute-energy-shock', method: 'get', name: 'fuel_mode', schema: { type: 'string', enum: FILTER_PARAM_CONTRACTS.intelligenceFuelModes }, }, { path: '/api/market/v1/get-country-stock-index', method: 'get', name: 'country_code', schema: { type: 'string', enum: Object.keys(FILTER_PARAM_CONTRACTS.marketCountryStockIndexes) }, }, { path: '/api/military/v1/list-military-bases', method: 'get', name: 'type', schema: { type: 'string', enum: FILTER_PARAM_CONTRACTS.militaryBaseTypes }, }, { path: '/api/military/v1/list-military-bases', method: 'get', name: 'kind', schema: { type: 'string', enum: FILTER_PARAM_CONTRACTS.militaryBaseKinds, }, }, { path: '/api/news/v1/summarize-article-cache', method: 'get', name: 'cache_key', schema: { type: 'string', pattern: FILTER_PARAM_CONTRACTS.newsSummarizeArticleCacheKeyPattern }, }, { path: '/api/prediction/v1/list-prediction-markets', method: 'get', name: 'category', schema: { type: 'string', enum: PREDICTION_MARKET_CATEGORIES, }, }, { path: '/api/research/v1/list-tech-events', method: 'get', name: 'type', description: `Event type filter: ${quotedList(FILTER_PARAM_CONTRACTS.researchTechEventTypes)}. Empty = all.`, schema: { type: 'string', enum: FILTER_PARAM_CONTRACTS.researchTechEventTypes }, }, { path: '/api/research/v1/list-hackernews-items', method: 'get', name: 'feed_type', description: `Feed type: ${quotedList(FILTER_PARAM_CONTRACTS.researchHackerNewsFeedTypes)}. Defaults to "top".`, schema: { type: 'string', enum: FILTER_PARAM_CONTRACTS.researchHackerNewsFeedTypes }, }, { path: '/api/trade/v1/list-comtrade-flows', method: 'get', name: 'cmd_code', schema: { type: 'string', pattern: FILTER_PARAM_CONTRACTS.tradeComtradeCmdCodePattern }, }, ]; function cloneJson(value) { return JSON.parse(JSON.stringify(value)); } function queryNameToPropertyName(name) { return name.replace(/_([a-z])/g, (_, c) => c.toUpperCase()); } function targetKey(target) { return `${target.path}\0${target.method}\0${target.name}`; } function pickSchemaContract(schema, fallbackType) { const contract = {}; if (schema.type || fallbackType) contract.type = schema.type || fallbackType; if (Array.isArray(schema.enum)) { contract.enum = schema.enum.filter((value) => !String(value).endsWith('_UNSPECIFIED')); } if (typeof schema.pattern === 'string') contract.pattern = schema.pattern; return contract.enum || contract.pattern ? contract : null; } function findRequestSchema(spec, operationId) { const schemas = spec.components?.schemas || {}; const exactName = operationId + 'Request'; if (schemas[exactName]) return schemas[exactName]; const suffixMatches = Object.entries(schemas).filter(([name]) => name.endsWith('_' + exactName)); return suffixMatches.length === 1 ? suffixMatches[0]?.[1] : undefined; } function collectTargets(spec) { const byKey = new Map(); for (const [path, pathItem] of Object.entries(spec.paths || {})) { for (const method of ['get', 'post', 'put', 'patch', 'delete']) { const operation = pathItem?.[method]; if (!operation?.operationId || !Array.isArray(operation.parameters)) continue; const requestSchema = findRequestSchema(spec, operation.operationId); const properties = requestSchema?.properties || {}; for (const parameter of operation.parameters) { if (parameter?.in !== 'query' || !parameter.name) continue; const propertySchema = properties[queryNameToPropertyName(parameter.name)]; if (!propertySchema) continue; const schema = pickSchemaContract(propertySchema, parameter.schema?.type); if (!schema) continue; const target = { path, method, name: parameter.name, schema }; byKey.set(targetKey(target), target); } } } for (const override of OPENAPI_FILTER_PARAM_SCHEMA_OVERRIDES) { byKey.set(targetKey(override), cloneJson(override)); } return [...byKey.values()]; } export function applyOpenApiFilterParamSchemas(spec) { let changed = false; for (const target of collectTargets(spec)) { const operation = spec.paths?.[target.path]?.[target.method]; if (!operation?.parameters) continue; const parameter = operation.parameters.find((p) => p?.in === 'query' && p.name === target.name); if (!parameter) continue; const nextSchema = { ...(parameter.schema || {}), ...cloneJson(target.schema) }; if (JSON.stringify(parameter.schema || {}) !== JSON.stringify(nextSchema)) { parameter.schema = nextSchema; changed = true; } if (target.description && parameter.description !== target.description) { parameter.description = target.description; changed = true; } } return changed; } function countIndent(line) { const match = line.match(/^ */); return match ? match[0].length : 0; } function quoteYaml(value) { return `'${String(value).replace(/'/g, "''")}'`; } function schemaLines(schema, indent = 18, name = 'schema') { const lines = [`${' '.repeat(indent)}${name}:`]; const pad = ' '.repeat(indent + 2); if (schema.type) lines.push(`${pad}type: ${schema.type}`); if (Array.isArray(schema.enum)) { lines.push(`${pad}enum:`); for (const value of schema.enum) lines.push(`${pad} - ${quoteYaml(value)}`); } if (schema.pattern) lines.push(`${pad}pattern: ${quoteYaml(schema.pattern)}`); if (Number.isInteger(schema.maxItems)) lines.push(`${pad}maxItems: ${schema.maxItems}`); if (Number.isInteger(schema.minItems)) lines.push(`${pad}minItems: ${schema.minItems}`); if (Number.isInteger(schema.minLength)) lines.push(`${pad}minLength: ${schema.minLength}`); if (Number.isInteger(schema.maxLength)) lines.push(`${pad}maxLength: ${schema.maxLength}`); if (schema.items) lines.push(...schemaLines(schema.items, indent + 2, 'items')); return lines; } function patchYamlTarget(text, target) { const lines = text.split('\n'); const pathNeedle = ` ${target.path}:`; const pathIndex = lines.indexOf(pathNeedle); if (pathIndex === -1) return text; let pathEnd = lines.length; for (let i = pathIndex + 1; i < lines.length; i++) { if (lines[i]?.startsWith(' /')) { pathEnd = i; break; } } const methodNeedle = ` ${target.method}:`; const methodIndex = lines.findIndex((line, i) => i > pathIndex && i < pathEnd && line === methodNeedle); if (methodIndex === -1) return text; let methodEnd = pathEnd; for (let i = methodIndex + 1; i < pathEnd; i++) { if (/^ {8}[a-z]+:$/.test(lines[i] || '')) { methodEnd = i; break; } } const nameNeedle = ` - name: ${target.name}`; const paramIndex = lines.findIndex((line, i) => i > methodIndex && i < methodEnd && line === nameNeedle); if (paramIndex === -1) return text; let paramEnd = methodEnd; for (let i = paramIndex + 1; i < methodEnd; i++) { if ((lines[i] || '').startsWith(' - name:') || (lines[i] || '').startsWith(' responses:')) { paramEnd = i; break; } } if (target.description) { const descriptionIndex = lines.findIndex((line, i) => i > paramIndex && i < paramEnd && line.startsWith(' description:')); if (descriptionIndex !== -1) { lines[descriptionIndex] = ` description: ${quoteYaml(target.description)}`; } else { const inIndex = lines.findIndex((line, i) => i > paramIndex && i < paramEnd && line === ' in: query'); if (inIndex !== -1) { lines.splice(inIndex + 1, 0, ` description: ${quoteYaml(target.description)}`); paramEnd++; methodEnd++; pathEnd++; } } } const schemaIndex = lines.findIndex((line, i) => i > paramIndex && i < paramEnd && line === ' schema:'); if (schemaIndex === -1) return lines.join('\n'); let schemaEnd = paramEnd; for (let i = schemaIndex + 1; i < paramEnd; i++) { const line = lines[i] || ''; if (line.trim() && countIndent(line) <= 18) { schemaEnd = i; break; } } lines.splice(schemaIndex, schemaEnd - schemaIndex, ...schemaLines(target.schema)); return lines.join('\n'); } function patchYamlText(text, targets) { let next = text; for (const target of targets) { next = patchYamlTarget(next, target); } return next; } function processJsonFile(file, check) { const before = readFileSync(file, 'utf8'); const spec = JSON.parse(before); const changed = applyOpenApiFilterParamSchemas(spec); if (!changed) return false; const after = JSON.stringify(spec); if (check) return true; writeFileSync(file, after); return true; } function readJsonSpec(file) { return JSON.parse(readFileSync(file, 'utf8')); } function collectTargetsFromJsonFiles(files) { const byKey = new Map(); for (const file of files) { for (const target of collectTargets(readJsonSpec(file))) { byKey.set(targetKey(target), target); } } return [...byKey.values()]; } function processYamlFile(file, check, targets) { const before = readFileSync(file, 'utf8'); const after = patchYamlText(before, targets); if (before === after) return false; if (check) return true; writeFileSync(file, after); return true; } export function applyOpenApiFilterParamSchemaFiles({ check = false } = {}) { const changed = []; const entries = readdirSync(OPENAPI_DIR).sort(); const jsonEntries = entries.filter((entry) => entry.endsWith(JSON_SPEC_SUFFIX)); for (const entry of jsonEntries) { const file = join(OPENAPI_DIR, entry); if (processJsonFile(file, check)) changed.push(entry); } const jsonFiles = jsonEntries.map((entry) => join(OPENAPI_DIR, entry)); const allTargets = collectTargetsFromJsonFiles(jsonFiles); for (const entry of entries) { if (!entry.endsWith(YAML_SPEC_SUFFIX)) continue; const file = join(OPENAPI_DIR, entry); const jsonPeerEntry = entry.replace(YAML_SPEC_SUFFIX, JSON_SPEC_SUFFIX); const jsonPeer = join(OPENAPI_DIR, jsonPeerEntry); const targets = jsonEntries.includes(jsonPeerEntry) ? collectTargets(readJsonSpec(jsonPeer)) : allTargets; if (processYamlFile(file, check, targets)) changed.push(entry); } return changed; } if (import.meta.url === `file://${process.argv[1]}`) { const check = process.argv.includes('--check'); const changed = applyOpenApiFilterParamSchemaFiles({ check }); if (check && changed.length > 0) { console.error('OpenAPI filter parameter schemas are stale:'); for (const file of changed) console.error(` ${file}`); console.error('Run: node scripts/apply-openapi-filter-param-schemas.mjs'); process.exit(1); } if (changed.length > 0) { console.log(`Updated OpenAPI filter parameter schemas in ${changed.length} file(s).`); } }