520 lines
19 KiB
JavaScript
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");
|