scripts/apply-openapi-filter-param-schemas.mjs
Rama main · réplica de lectura del repositorio original.
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).`);
}
}