Files
jiapuapp/tests/openapi-yaml-json-parity-runtime-smoke.js
T
2026-07-23 08:24:07 +08:00

520 lines
19 KiB
JavaScript

const assert = require("assert");
const fs = require("fs");
const path = require("path");
const root = path.resolve(__dirname, "..");
const LOSSLESS_NUMBER = Symbol("openapi-parity-number");
// JSON/YAML 的 number 语义不等于 JavaScript 的 IEEE-754 Number。统一归一化为
// “有效数字 e 十进制指数”,可让 1、1.0、10e-1 等价,同时无损区分任意大整数。
const normalizeNumber = (source) => {
const match = /^(-?)(0|[1-9]\d*)(?:\.(\d+))?(?:[eE]([+-]?\d+))?$/.exec(source);
if (!match) throw new Error(`Invalid JSON/YAML number: ${source}`);
const fraction = match[3] || "";
let digits = `${match[2]}${fraction}`.replace(/^0+/, "");
if (!digits) return "0e0";
let exponent = BigInt(match[4] || "0") - BigInt(fraction.length);
const trailingZeros = digits.match(/0+$/)?.[0].length || 0;
if (trailingZeros) {
digits = digits.slice(0, -trailingZeros);
exponent += BigInt(trailingZeros);
}
return `${match[1] ? "-" : ""}${digits}e${exponent}`;
};
const createLosslessNumber = (source) =>
Object.freeze({ [LOSSLESS_NUMBER]: normalizeNumber(source) });
const getLosslessNumber = (value) =>
value && typeof value === "object" ? value[LOSSLESS_NUMBER] : undefined;
const losslessNumberToPlainString = (canonical) => {
const match = /^(-?)(\d+)e(-?\d+)$/.exec(canonical);
if (!match) throw new Error(`Invalid canonical number: ${canonical}`);
const exponentValue = BigInt(match[3]);
if (exponentValue > 10000n || exponentValue < -10000n) {
throw new Error(`YAML numeric mapping key exponent is too large: ${canonical}`);
}
const exponent = Number(exponentValue);
const sign = match[1];
const digits = match[2];
if (exponent >= 0) return `${sign}${digits}${"0".repeat(exponent)}`;
const point = digits.length + exponent;
return point > 0
? `${sign}${digits.slice(0, point)}.${digits.slice(point)}`
: `${sign}0.${"0".repeat(-point)}${digits}`;
};
// 后端会同时交付 JSON 与 YAML。这个解析器只实现当前 OpenAPI 导出实际使用的
// YAML 1.2 子集,但不依赖固定缩进、键是否加引号或字段顺序;遇到锚点、tag、
// tab 缩进等未声明语法会立即失败,不能把无法理解的 YAML 当成“已经一致”。
const parseYamlScalar = (source) => {
const value = source.trim();
if (value === "[]") return [];
if (value === "{}") return {};
if (value === "null" || value === "~") return null;
if (value === "true") return true;
if (value === "false") return false;
if (/^-?(?:0|[1-9]\d*)(?:\.\d+)?(?:[eE][+-]?\d+)?$/.test(value)) {
return createLosslessNumber(value);
}
if (value.startsWith('"')) {
try {
return JSON.parse(value);
} catch (error) {
throw new Error(`Invalid double-quoted YAML scalar: ${value}`);
}
}
if (value.startsWith("'")) {
if (!value.endsWith("'")) throw new Error(`Invalid single-quoted YAML scalar: ${value}`);
return value.slice(1, -1).replace(/''/g, "'");
}
if (/^[!&*]/.test(value)) {
throw new Error(`Unsupported YAML tag/anchor/alias: ${value}`);
}
if (findMappingColon(value) >= 0) {
throw new Error(`Invalid YAML plain scalar containing a mapping separator: ${value}`);
}
return value;
};
const stripInlineComment = (source) => {
let singleQuoted = false;
let doubleQuoted = false;
let escaped = false;
for (let index = 0; index < source.length; index += 1) {
const character = source[index];
if (doubleQuoted && escaped) {
escaped = false;
continue;
}
if (doubleQuoted && character === "\\") {
escaped = true;
continue;
}
if (!doubleQuoted && character === "'") {
if (singleQuoted && source[index + 1] === "'") {
index += 1;
continue;
}
singleQuoted = !singleQuoted;
continue;
}
if (!singleQuoted && character === '"') {
doubleQuoted = !doubleQuoted;
continue;
}
if (!singleQuoted && !doubleQuoted && character === "#" && (index === 0 || /\s/.test(source[index - 1]))) {
return source.slice(0, index).trimEnd();
}
}
return source.trimEnd();
};
const findMappingColon = (source) => {
let singleQuoted = false;
let doubleQuoted = false;
let escaped = false;
for (let index = 0; index < source.length; index += 1) {
const character = source[index];
if (doubleQuoted && escaped) {
escaped = false;
continue;
}
if (doubleQuoted && character === "\\") {
escaped = true;
continue;
}
if (!doubleQuoted && character === "'") {
if (singleQuoted && source[index + 1] === "'") {
index += 1;
continue;
}
singleQuoted = !singleQuoted;
continue;
}
if (!singleQuoted && character === '"') {
doubleQuoted = !doubleQuoted;
continue;
}
if (
!singleQuoted &&
!doubleQuoted &&
character === ":" &&
(index === source.length - 1 || /\s/.test(source[index + 1]))
) {
return index;
}
}
return -1;
};
const parseYamlKey = (source) => {
const key = parseYamlScalar(source);
const numericKey = getLosslessNumber(key);
if (numericKey !== undefined) return losslessNumberToPlainString(numericKey);
if (typeof key !== "string" && typeof key !== "number") {
throw new Error(`YAML mapping key must be scalar: ${source}`);
}
return String(key);
};
const foldBlockLines = (lines) => {
let result = "";
for (let index = 0; index < lines.length; index += 1) {
const current = lines[index];
if (index === 0) {
result = current;
continue;
}
const previous = lines[index - 1];
result += previous === "" || current === "" ? `\n${current}` : ` ${current}`;
}
return result;
};
const applyBlockChomping = (value, indicator) => {
if (indicator.endsWith("-")) return value.replace(/\n+$/, "");
if (indicator.endsWith("+")) return value;
return `${value.replace(/\n+$/, "")}\n`;
};
const parseOpenApiYaml = (source) => {
const rawLines = source.replace(/^\uFEFF/, "").replace(/\r\n?/g, "\n").split("\n");
const lines = rawLines.map((raw, index) => {
if (/^\t+/.test(raw) || /^ +\t/.test(raw)) {
throw new Error(`YAML tab indentation is not supported at line ${index + 1}`);
}
const indent = raw.match(/^ */)[0].length;
return { raw, indent, content: stripInlineComment(raw.slice(indent)), line: index + 1 };
});
const nextSignificant = (start) => {
let index = start;
while (index < lines.length && lines[index].content.trim() === "") index += 1;
return index;
};
const parseBlockScalar = (lineIndex, parentIndent, indicator) => {
let end = lineIndex + 1;
while (end < lines.length) {
const line = lines[end];
if (line.raw.trim() !== "" && line.indent <= parentIndent) break;
end += 1;
}
const block = lines.slice(lineIndex + 1, end);
const nonBlank = block.filter((line) => line.raw.trim() !== "");
const contentIndent = nonBlank.length
? Math.min(...nonBlank.map((line) => line.indent))
: parentIndent + 1;
if (contentIndent <= parentIndent) {
throw new Error(`Invalid YAML block indentation at line ${lines[lineIndex].line}`);
}
const values = block.map((line) => (line.raw.trim() === "" ? "" : line.raw.slice(contentIndent)));
const rawValue = indicator.startsWith(">") ? foldBlockLines(values) : values.join("\n");
return { value: applyBlockChomping(rawValue, indicator), next: end };
};
const parsePair = (content, lineIndex, entryIndent) => {
const colon = findMappingColon(content);
if (colon < 0) throw new Error(`Missing YAML mapping colon at line ${lines[lineIndex].line}`);
const key = parseYamlKey(content.slice(0, colon).trim());
const rawValue = content.slice(colon + 1).trim();
if (/^[>|][+-]?$/.test(rawValue)) {
const parsed = parseBlockScalar(lineIndex, entryIndent, rawValue);
return { key, value: parsed.value, next: parsed.next };
}
if (rawValue !== "") {
return { key, value: parseYamlScalar(rawValue), next: lineIndex + 1 };
}
const childIndex = nextSignificant(lineIndex + 1);
if (childIndex < lines.length && lines[childIndex].indent > entryIndent) {
const parsed = parseBlock(childIndex, lines[childIndex].indent);
return { key, value: parsed.value, next: parsed.next };
}
return { key, value: null, next: lineIndex + 1 };
};
const parseMap = (start, indent) => {
const result = {};
let index = start;
while (index < lines.length) {
index = nextSignificant(index);
if (index >= lines.length || lines[index].indent < indent) break;
if (lines[index].indent > indent) {
throw new Error(`Unexpected YAML indentation at line ${lines[index].line}`);
}
const content = lines[index].content.trim();
if (content.startsWith("-")) break;
const parsed = parsePair(content, index, indent);
if (Object.prototype.hasOwnProperty.call(result, parsed.key)) {
throw new Error(`Duplicate YAML key '${parsed.key}' at line ${lines[index].line}`);
}
result[parsed.key] = parsed.value;
index = parsed.next;
}
return { value: result, next: index };
};
const parseSequence = (start, indent) => {
const result = [];
let index = start;
while (index < lines.length) {
index = nextSignificant(index);
if (index >= lines.length || lines[index].indent < indent) break;
if (lines[index].indent > indent) {
throw new Error(`Unexpected YAML sequence indentation at line ${lines[index].line}`);
}
const content = lines[index].content.trim();
if (!content.startsWith("-") || (content.length > 1 && !/\s/.test(content[1]))) break;
const item = content.slice(1).trim();
if (item === "") {
const childIndex = nextSignificant(index + 1);
if (childIndex >= lines.length || lines[childIndex].indent <= indent) {
result.push(null);
index += 1;
} else {
const parsed = parseBlock(childIndex, lines[childIndex].indent);
result.push(parsed.value);
index = parsed.next;
}
continue;
}
if (findMappingColon(item) < 0) {
result.push(parseYamlScalar(item));
index += 1;
continue;
}
const object = {};
const first = parsePair(item, index, indent + 2);
object[first.key] = first.value;
index = first.next;
const continuation = nextSignificant(index);
if (continuation < lines.length && lines[continuation].indent === indent + 2 && !lines[continuation].content.trim().startsWith("-")) {
const parsed = parseMap(continuation, indent + 2);
for (const [key, value] of Object.entries(parsed.value)) {
if (Object.prototype.hasOwnProperty.call(object, key)) {
throw new Error(`Duplicate YAML sequence-object key '${key}' at line ${lines[continuation].line}`);
}
object[key] = value;
}
index = parsed.next;
}
result.push(object);
}
return { value: result, next: index };
};
function parseBlock(start, indent) {
const index = nextSignificant(start);
if (index >= lines.length) return { value: null, next: index };
if (lines[index].indent !== indent) {
throw new Error(`Expected YAML indentation ${indent} at line ${lines[index].line}`);
}
return lines[index].content.trim().startsWith("-")
? parseSequence(index, indent)
: parseMap(index, indent);
}
const start = nextSignificant(0);
const parsed = parseBlock(start, lines[start]?.indent || 0);
if (nextSignificant(parsed.next) < lines.length) {
throw new Error(`Unparsed YAML content begins at line ${lines[nextSignificant(parsed.next)].line}`);
}
return parsed.value;
};
// Node 没有内置的 lossless JSON 解析器。这个小型递归下降解析器只实现 RFC 8259,
// 但把 number token 直接交给同一个任意精度归一化 owner,避免 JSON.parse 先行舍入。
const parseJsonLossless = (source) => {
let index = 0;
const fail = (message) => {
throw new Error(`${message} at JSON offset ${index}`);
};
const skipWhitespace = () => {
while (index < source.length && /[\u0020\u000a\u000d\u0009]/.test(source[index])) index += 1;
};
const parseString = () => {
const start = index;
index += 1;
while (index < source.length) {
const character = source[index];
if (character === '"') {
index += 1;
try {
return JSON.parse(source.slice(start, index));
} catch (error) {
fail("Invalid JSON string");
}
}
if (character === "\\") {
index += 2;
} else {
index += 1;
}
}
fail("Unterminated JSON string");
};
const parseNumber = () => {
const match = /^-?(?:0|[1-9]\d*)(?:\.\d+)?(?:[eE][+-]?\d+)?/.exec(source.slice(index));
if (!match) fail("Invalid JSON number");
index += match[0].length;
return createLosslessNumber(match[0]);
};
const parseLiteral = (literal, value) => {
if (source.slice(index, index + literal.length) !== literal) fail(`Invalid JSON literal`);
index += literal.length;
return value;
};
const parseArray = () => {
const result = [];
index += 1;
skipWhitespace();
if (source[index] === "]") {
index += 1;
return result;
}
while (index < source.length) {
result.push(parseValue());
skipWhitespace();
if (source[index] === "]") {
index += 1;
return result;
}
if (source[index] !== ",") fail("Expected ',' or ']' in JSON array");
index += 1;
skipWhitespace();
}
fail("Unterminated JSON array");
};
const parseObject = () => {
const result = Object.create(null);
index += 1;
skipWhitespace();
if (source[index] === "}") {
index += 1;
return result;
}
while (index < source.length) {
if (source[index] !== '"') fail("JSON object key must be a string");
const key = parseString();
if (Object.prototype.hasOwnProperty.call(result, key)) fail(`Duplicate JSON key '${key}'`);
skipWhitespace();
if (source[index] !== ":") fail("Expected ':' after JSON object key");
index += 1;
result[key] = parseValue();
skipWhitespace();
if (source[index] === "}") {
index += 1;
return result;
}
if (source[index] !== ",") fail("Expected ',' or '}' in JSON object");
index += 1;
skipWhitespace();
}
fail("Unterminated JSON object");
};
function parseValue() {
skipWhitespace();
const character = source[index];
if (character === "{") return parseObject();
if (character === "[") return parseArray();
if (character === '"') return parseString();
if (character === "t") return parseLiteral("true", true);
if (character === "f") return parseLiteral("false", false);
if (character === "n") return parseLiteral("null", null);
if (character === "-" || /\d/.test(character || "")) return parseNumber();
fail("Unexpected JSON token");
}
const value = parseValue();
skipWhitespace();
if (index !== source.length) fail("Unexpected trailing JSON content");
return value;
};
const firstDifference = (left, right, trail = "$") => {
const leftNumber = getLosslessNumber(left);
const rightNumber = getLosslessNumber(right);
if (leftNumber !== undefined || rightNumber !== undefined) {
return leftNumber === rightNumber
? null
: `${trail}: number ${leftNumber ?? "<non-number>"} != ${rightNumber ?? "<non-number>"}`;
}
if (Object.is(left, right)) return null;
if (Array.isArray(left) || Array.isArray(right)) {
if (!Array.isArray(left) || !Array.isArray(right)) return `${trail}: type mismatch`;
if (left.length !== right.length) return `${trail}: array length ${left.length} != ${right.length}`;
for (let index = 0; index < left.length; index += 1) {
const difference = firstDifference(left[index], right[index], `${trail}[${index}]`);
if (difference) return difference;
}
return null;
}
if (left && right && typeof left === "object" && typeof right === "object") {
const leftKeys = Object.keys(left).sort();
const rightKeys = Object.keys(right).sort();
if (leftKeys.length !== rightKeys.length || leftKeys.some((key, index) => key !== rightKeys[index])) {
return `${trail}: keys ${leftKeys.join(",")} != ${rightKeys.join(",")}`;
}
for (const key of leftKeys) {
const difference = firstDifference(left[key], right[key], `${trail}.${key}`);
if (difference) return difference;
}
return null;
}
return `${trail}: ${JSON.stringify(left)} != ${JSON.stringify(right)}`;
};
// 先用与 OpenAPI 导出相同的关键 YAML 形状验证解析器本身,防止 parity 检查因
// quoted key、非二空格缩进、对象数组或 block scalar 变化而静默误判。
const fixture = parseOpenApiYaml(`
openapi: 3.1.0
paths:
"/items/{id}":
get:
security:
- SaToken: []
responses:
200:
description: >-
first line
second line
headers: {}
components:
schemas:
Sample:
type: object
required:
- id
properties:
id:
type: string # lexical identity
`);
assert.strictEqual(fixture.openapi, "3.1.0");
assert.deepStrictEqual(fixture.paths["/items/{id}"].get.security, [{ SaToken: [] }]);
assert.strictEqual(fixture.paths["/items/{id}"].get.responses["200"].description, "first line second line");
assert.deepStrictEqual(fixture.components.schemas.Sample.required, ["id"]);
assert.throws(() => parseOpenApiYaml("openapi:3.0.1\n"), /mapping colon/);
assert.throws(() => parseOpenApiYaml("description: a: b\n"), /plain scalar/);
// JavaScript Number 会把这两个相邻的大整数舍入为同一个值;parity 合同必须先证明
// 自己能够发现这类双导出漂移,才能检查包含 int64 示例的真实 OpenAPI。
const unsafeIntegerDifference = firstDifference(
parseOpenApiYaml("id: 2060000000000000001\n"),
parseJsonLossless('{"id":2060000000000000000}'),
);
assert.match(unsafeIntegerDifference || "", /^\$\.id:/);
assert.strictEqual(
firstDifference(
parseOpenApiYaml("value: 1.00e2\n"),
parseJsonLossless('{"value":100}'),
),
null,
);
const yamlDocument = parseOpenApiYaml(fs.readFileSync(path.join(root, "APP.openapi.yaml"), "utf8"));
const jsonDocument = parseJsonLossless(fs.readFileSync(path.join(root, "APP.openapi.json"), "utf8"));
const difference = firstDifference(yamlDocument, jsonDocument);
if (difference) throw new Error(`Protected OpenAPI JSON/YAML semantic drift: ${difference}`);
console.log("OPENAPI-YAML-JSON-PARITY PASS");