Files
qqq-cli/scripts/merge-spec.mjs
james.maesandClaude Opus 5.5 ea79ddeb78 feat: reference CLI, generated UI API client, conformance suite and dev IdP
- @qrun-io/qqq-ui-api-client: typed client generated (openapi-typescript +
  openapi-fetch) from the merged UI API document; device-flow sign-in, refresh,
  UiApiError (BasicErrorResponseV1), SSE reader
- @qrun-io/qqq-cli: qqq login/whoami/apps/tables/query/get/run/jobs/tail,
  only through /qqq/v1 with Authorization: Bearer
- @qrun-io/qqq-ui-api-conformance: black-box suite + qqq-dev-idp
- scripts/merge-spec.mjs, Prism mock, CI and GitHub Packages publish workflows

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 11:47:12 -05:00

112 lines
4.7 KiB
JavaScript

#!/usr/bin/env node
/*
* Copyright 2026 QRun.IO, Inc.
* Licensed under the Apache License, Version 2.0 (see LICENSE).
*/
/**
* Merge the QQQ UI API core document with extension documents into one OpenAPI document.
*
* Usage: node scripts/merge-spec.mjs --out <file.json> <core.yaml|url> [extension.yaml|url ...]
*
* Inputs are the committed documents (qqq: qqq-middleware-javalin/src/main/resources/openapi/v1/
* openapi.yaml; each qbit: src/main/resources/openapi/qqq-v1/{name}.yaml), local paths or URLs.
* A server's own GET /qqq/v1/openapi.json is already merged and can be used directly instead.
*
* Rules: paths and operations are unioned (a duplicate method on one path is an error); component
* schemas are unioned (a name defined twice must be identical, e.g. BasicErrorResponseV1); the
* core document's info, servers and securitySchemes are kept; tags are unioned; every extension's
* name and version is recorded in info.x-qqq-extensions.
*/
import { readFile, writeFile } from 'node:fs/promises'
import { parse } from 'yaml'
const args = process.argv.slice(2)
const outIndex = args.indexOf('--out')
if (outIndex < 0 || args.length < outIndex + 2) {
console.error('usage: merge-spec.mjs --out <file.json> <core> [extensions...]')
process.exit(2)
}
const out = args[outIndex + 1]
const deriveMissing = args.includes('--derive-missing-ids')
const inputs = args.filter((a, i) => i !== outIndex && i !== outIndex + 1 && a !== '--derive-missing-ids')
/**
* The operationId QQQ derives for an operation without one (UiApiOperationIds.derive): the method,
* then each path segment in camel case, `{param}` as `By{Param}`, relative to /qqq/v1. Only for
* documents from servers before UI API 1.6.0 (--derive-missing-ids).
*/
function deriveOperationId(method, path) {
let id = method.toLowerCase()
for (let segment of path.replace(/^\/qqq\/v1/, '').split('/')) {
if (!segment) continue
if (segment.startsWith('{') && segment.endsWith('}')) {
id += 'By'
segment = segment.slice(1, -1)
}
for (const word of segment.split(/[^A-Za-z0-9]+/)) if (word) id += word[0].toUpperCase() + word.slice(1)
}
return id
}
async function load(source) {
const text = /^https?:\/\//.test(source) ? await (await fetch(source)).text() : await readFile(source, 'utf8')
return source.endsWith('.json') ? JSON.parse(text) : parse(text)
}
function stable(value) {
if (Array.isArray(value)) return value.map(stable)
if (value && typeof value === 'object') return Object.fromEntries(Object.keys(value).sort().map((k) => [k, stable(value[k])]))
return value
}
const [coreSource, ...extensionSources] = inputs
const merged = await load(coreSource)
merged.paths ??= {}
merged.components ??= {}
merged.components.schemas ??= {}
merged.tags ??= []
const extensions = []
for (const source of extensionSources) {
const doc = await load(source)
const name = (doc.info?.title ?? '').replace(/^QQQ Middleware API extension: /, '') || source
extensions.push({ name, version: doc.info?.version, description: doc.info?.description })
for (const [path, operations] of Object.entries(doc.paths ?? {})) {
const target = (merged.paths[path] ??= {})
for (const [method, operation] of Object.entries(operations)) {
if (target[method]) throw new Error(`duplicate operation ${method.toUpperCase()} ${path} (from ${name})`)
target[method] = operation
}
}
for (const [schemaName, schema] of Object.entries(doc.components?.schemas ?? {})) {
const existing = merged.components.schemas[schemaName]
if (existing && JSON.stringify(stable(existing)) !== JSON.stringify(stable(schema))) {
throw new Error(`schema ${schemaName} differs between the core document and ${name}`)
}
merged.components.schemas[schemaName] = schema
}
for (const tag of doc.tags ?? []) {
if (!merged.tags.some((t) => t.name === tag.name)) merged.tags.push(tag)
}
}
const operationIds = new Map()
for (const [path, operations] of Object.entries(merged.paths)) {
for (const [method, operation] of Object.entries(operations)) {
if (!operation.operationId && deriveMissing) operation.operationId = deriveOperationId(method, path)
const id = operation.operationId
if (!id) throw new Error(`no operationId: ${method.toUpperCase()} ${path}`)
if (operationIds.has(id)) throw new Error(`duplicate operationId ${id}: ${operationIds.get(id)} and ${method.toUpperCase()} ${path}`)
operationIds.set(id, `${method.toUpperCase()} ${path}`)
}
}
merged.info['x-qqq-extensions'] = extensions
await writeFile(out, JSON.stringify(merged, null, 2) + '\n')
console.log(`merged ${1 + extensionSources.length} documents: ${Object.keys(merged.paths).length} paths, ${operationIds.size} operations -> ${out}`)