|
| 1 | +/** |
| 2 | + * Generates static/openapi-specs/apis.json — the discovery document consumed by |
| 3 | + * openapi-analyzer-mcp (see docs/architecture/api-first.md). |
| 4 | + * |
| 5 | + * The API list comes from redoc.config.js so the discovery document always |
| 6 | + * matches the published API reference. Each baseURL is read from the spec's own |
| 7 | + * `servers` entry rather than being maintained here. |
| 8 | + */ |
| 9 | + |
| 10 | +const fs = require('fs'); |
| 11 | +const path = require('path'); |
| 12 | + |
| 13 | +const { specs } = require('../redoc.config'); |
| 14 | + |
| 15 | +const OUTPUT_PATH = path.join(__dirname, '..', 'static', 'openapi-specs', 'apis.json'); |
| 16 | +const CONCURRENCY = 8; |
| 17 | + |
| 18 | +/** |
| 19 | + * Reads the first `servers[].url` out of an OpenAPI YAML document. |
| 20 | + * |
| 21 | + * Avoids a YAML dependency: `servers` is always a top-level key, so scan from it |
| 22 | + * until the next top-level key and take the first `url`. |
| 23 | + */ |
| 24 | +function extractBaseURL(spec) { |
| 25 | + const lines = spec.split('\n'); |
| 26 | + const start = lines.findIndex((line) => /^servers:/.test(line)); |
| 27 | + |
| 28 | + if (start === -1) return null; |
| 29 | + |
| 30 | + for (const line of lines.slice(start + 1)) { |
| 31 | + // A new top-level key ends the block; sequence items may sit at column 0 too. |
| 32 | + if (/^[^\s-]/.test(line)) break; |
| 33 | + |
| 34 | + const match = line.match(/^\s*-?\s*url:\s*(.+?)\s*$/); |
| 35 | + |
| 36 | + if (match) return match[1].replace(/^['"]|['"]$/g, ''); |
| 37 | + } |
| 38 | + |
| 39 | + return null; |
| 40 | +} |
| 41 | + |
| 42 | +async function resolveBaseURL(spec, previousBaseURL) { |
| 43 | + const fallback = () => { |
| 44 | + if (!previousBaseURL) return null; |
| 45 | + console.warn(` keeping previously known baseURL ${previousBaseURL}`); |
| 46 | + |
| 47 | + return previousBaseURL; |
| 48 | + }; |
| 49 | + |
| 50 | + let response; |
| 51 | + |
| 52 | + try { |
| 53 | + response = await fetch(spec.specUrl); |
| 54 | + } catch (error) { |
| 55 | + console.warn(`✗ ${spec.layout.title}: ${spec.specUrl} — ${error.message}`); |
| 56 | + |
| 57 | + return fallback(); |
| 58 | + } |
| 59 | + |
| 60 | + if (!response.ok) { |
| 61 | + console.warn(`✗ ${spec.layout.title}: ${spec.specUrl} — HTTP ${response.status}`); |
| 62 | + |
| 63 | + return fallback(); |
| 64 | + } |
| 65 | + |
| 66 | + const baseURL = extractBaseURL(await response.text()); |
| 67 | + |
| 68 | + if (!baseURL) { |
| 69 | + console.warn(`✗ ${spec.layout.title}: no servers entry in ${spec.specUrl}`); |
| 70 | + |
| 71 | + return fallback(); |
| 72 | + } |
| 73 | + |
| 74 | + return baseURL; |
| 75 | +} |
| 76 | + |
| 77 | +async function mapWithConcurrency(items, limit, fn) { |
| 78 | + const results = new Array(items.length); |
| 79 | + let next = 0; |
| 80 | + |
| 81 | + const worker = async () => { |
| 82 | + while (next < items.length) { |
| 83 | + const index = next++; |
| 84 | + results[index] = await fn(items[index]); |
| 85 | + } |
| 86 | + }; |
| 87 | + |
| 88 | + await Promise.all(Array.from({ length: Math.min(limit, items.length) }, worker)); |
| 89 | + |
| 90 | + return results; |
| 91 | +} |
| 92 | + |
| 93 | +function readPreviousBaseURLs() { |
| 94 | + try { |
| 95 | + const previous = JSON.parse(fs.readFileSync(OUTPUT_PATH, 'utf8')); |
| 96 | + |
| 97 | + return new Map(previous.apis.map((api) => [api.properties[0].url, api.baseURL])); |
| 98 | + } catch { |
| 99 | + return new Map(); |
| 100 | + } |
| 101 | +} |
| 102 | + |
| 103 | +async function main() { |
| 104 | + const previousBaseURLs = readPreviousBaseURLs(); |
| 105 | + |
| 106 | + const apis = ( |
| 107 | + await mapWithConcurrency(specs, CONCURRENCY, async (spec) => { |
| 108 | + const baseURL = await resolveBaseURL(spec, previousBaseURLs.get(spec.specUrl)); |
| 109 | + |
| 110 | + if (!baseURL) { |
| 111 | + console.warn(` skipping ${spec.layout.title} — no baseURL available`); |
| 112 | + |
| 113 | + return null; |
| 114 | + } |
| 115 | + |
| 116 | + return { |
| 117 | + name: spec.layout.title, |
| 118 | + baseURL, |
| 119 | + properties: [{ type: 'Swagger', url: spec.specUrl }], |
| 120 | + }; |
| 121 | + }) |
| 122 | + ).filter(Boolean); |
| 123 | + |
| 124 | + if (!apis.length) { |
| 125 | + throw new Error('No APIs could be resolved — refusing to write an empty discovery document'); |
| 126 | + } |
| 127 | + |
| 128 | + const document = { |
| 129 | + name: 'Epilot APIs', |
| 130 | + description: 'Collection of Epilot API specifications', |
| 131 | + url: 'https://docs.epilot.io', |
| 132 | + apis, |
| 133 | + }; |
| 134 | + |
| 135 | + const contents = `${JSON.stringify(document, null, 2)}\n`; |
| 136 | + const unchanged = fs.existsSync(OUTPUT_PATH) && fs.readFileSync(OUTPUT_PATH, 'utf8') === contents; |
| 137 | + |
| 138 | + if (unchanged) { |
| 139 | + console.log(`apis.json already up to date (${apis.length} APIs)`); |
| 140 | + |
| 141 | + return; |
| 142 | + } |
| 143 | + |
| 144 | + fs.writeFileSync(OUTPUT_PATH, contents); |
| 145 | + console.log(`Wrote apis.json with ${apis.length} of ${specs.length} APIs`); |
| 146 | +} |
| 147 | + |
| 148 | +main().catch((error) => { |
| 149 | + console.error(error.message); |
| 150 | + process.exit(1); |
| 151 | +}); |
0 commit comments