完成50%
This commit is contained in:
@@ -0,0 +1,519 @@
|
||||
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");
|
||||
Reference in New Issue
Block a user