Files
gongxue-base/scripts/taro-api-contract-test.js
2026-07-01 01:40:41 +08:00

306 lines
8.8 KiB
JavaScript

import assert from 'node:assert/strict';
import fs from 'node:fs';
import path from 'node:path';
const repoRoot = process.cwd();
const apiFeaturesRoot = path.join(repoRoot, 'apps', 'api', 'src', 'features');
const taroSrcRoot = path.join(repoRoot, 'apps', 'taro', 'src');
const dynamicApiAllowlist = [
{
method: 'POST',
frontendPattern: /^\/api\/tenant-content\/imports\/preview\/[^/]+$/,
backendPaths: [
'/api/tenant-content/imports/preview/questions',
'/api/tenant-content/imports/preview/vocabulary',
'/api/tenant-content/imports/preview/handbook',
'/api/tenant-content/imports/preview/scoreline',
'/api/tenant-content/imports/preview/videos',
],
},
{
method: 'POST',
frontendPattern: /^\/api\/tenant-content\/imports\/[^/]+$/,
backendPaths: [
'/api/tenant-content/imports/questions',
'/api/tenant-content/imports/vocabulary',
'/api/tenant-content/imports/handbook',
'/api/tenant-content/imports/scoreline',
'/api/tenant-content/imports/videos',
],
},
];
const serverAliases = [
{
method: 'GET',
frontendPattern: /^\/api\/questions\/[^/]+\/videos$/,
backendPaths: ['/api/questions/videos'],
},
];
function readText(filePath) {
return fs.readFileSync(filePath, 'utf8').replace(/\r\n/g, '\n');
}
function normalizeSlashes(value) {
return value.replace(/\\/g, '/');
}
function relative(filePath) {
return normalizeSlashes(path.relative(repoRoot, filePath));
}
function walkFiles(dir, extensions) {
const result = [];
const entries = fs.readdirSync(dir, { withFileTypes: true });
for (const entry of entries) {
const entryPath = path.join(dir, entry.name);
if (entry.isDirectory()) {
result.push(...walkFiles(entryPath, extensions));
continue;
}
if (extensions.some(extension => entry.name.endsWith(extension))) result.push(entryPath);
}
return result;
}
function extractBackendRoutes() {
const files = walkFiles(apiFeaturesRoot, ['.ts']);
const routes = [];
const routeRegex = /\[\s*['"`](GET|POST|PUT|PATCH|DELETE)['"`]\s*,\s*['"`](\/[^'"`]+)['"`]\s*,/g;
for (const filePath of files) {
const text = readText(filePath);
for (const match of text.matchAll(routeRegex)) {
routes.push({
method: match[1],
path: match[2],
file: relative(filePath),
});
}
}
return routes;
}
function skipString(text, index) {
const quote = text[index];
let cursor = index + 1;
while (cursor < text.length) {
const char = text[cursor];
if (char === '\\') {
cursor += 2;
continue;
}
if (char === quote) return cursor + 1;
cursor += 1;
}
return cursor;
}
function skipTemplate(text, index) {
let cursor = index + 1;
while (cursor < text.length) {
const char = text[cursor];
if (char === '\\') {
cursor += 2;
continue;
}
if (char === '`') return cursor + 1;
if (char === '$' && text[cursor + 1] === '{') {
cursor = findMatching(text, cursor + 1, '{', '}') + 1;
continue;
}
cursor += 1;
}
return cursor;
}
function findMatching(text, openIndex, openChar, closeChar) {
let depth = 0;
for (let cursor = openIndex; cursor < text.length; cursor += 1) {
const char = text[cursor];
if (char === '"' || char === "'") {
cursor = skipString(text, cursor) - 1;
continue;
}
if (char === '`') {
cursor = skipTemplate(text, cursor) - 1;
continue;
}
if (char === openChar) depth += 1;
if (char === closeChar) {
depth -= 1;
if (depth === 0) return cursor;
}
}
return text.length - 1;
}
function splitTopLevelArguments(text) {
const args = [];
let start = 0;
let round = 0;
let curly = 0;
let square = 0;
for (let cursor = 0; cursor < text.length; cursor += 1) {
const char = text[cursor];
if (char === '"' || char === "'") {
cursor = skipString(text, cursor) - 1;
continue;
}
if (char === '`') {
cursor = skipTemplate(text, cursor) - 1;
continue;
}
if (char === '(') round += 1;
else if (char === ')') round -= 1;
else if (char === '{') curly += 1;
else if (char === '}') curly -= 1;
else if (char === '[') square += 1;
else if (char === ']') square -= 1;
else if (char === ',' && round === 0 && curly === 0 && square === 0) {
args.push(text.slice(start, cursor).trim());
start = cursor + 1;
}
}
args.push(text.slice(start).trim());
return args;
}
function parseFirstStringArgument(argument) {
const trimmed = argument.trim();
const quote = trimmed[0];
if (!['"', "'", '`'].includes(quote)) return null;
let value = '';
for (let index = 1; index < trimmed.length; index += 1) {
const char = trimmed[index];
if (char === '\\') {
value += trimmed.slice(index, index + 2);
index += 1;
continue;
}
if (quote === '`' && char === '$' && trimmed[index + 1] === '{') {
const end = findMatching(trimmed, index + 1, '{', '}');
value += '${dynamic}';
index = end;
continue;
}
if (char === quote) {
return value;
}
value += char;
}
return null;
}
function extractApiCallsFromFile(filePath) {
const text = readText(filePath);
const calls = [];
const callPattern = /\bapiRequest\b/g;
for (const match of text.matchAll(callPattern)) {
const openIndex = findCallOpen(text, match.index + 'apiRequest'.length);
if (openIndex < 0) continue;
const closeIndex = findMatching(text, openIndex, '(', ')');
const source = text.slice(openIndex + 1, closeIndex);
const args = splitTopLevelArguments(source);
const rawPath = parseFirstStringArgument(args[0] || '');
if (!rawPath) continue;
const methodMatch = (args[1] || '').match(/\bmethod\s*:\s*['"`](GET|POST|PUT|PATCH|DELETE)['"`]/);
const line = text.slice(0, match.index).split('\n').length;
calls.push({
method: methodMatch?.[1] || 'GET',
path: rawPath,
file: relative(filePath),
line,
});
}
return calls;
}
function findCallOpen(text, startIndex) {
let angle = 0;
for (let cursor = startIndex; cursor < text.length; cursor += 1) {
const char = text[cursor];
if (char === '"' || char === "'") {
cursor = skipString(text, cursor) - 1;
continue;
}
if (char === '`') {
cursor = skipTemplate(text, cursor) - 1;
continue;
}
if (char === '<') {
angle += 1;
continue;
}
if (char === '>' && angle > 0) {
angle -= 1;
continue;
}
if (char === '(' && angle === 0) return cursor;
if (angle === 0 && !/\s/.test(char)) return -1;
}
return -1;
}
function extractFrontendApiCalls() {
return walkFiles(taroSrcRoot, ['.ts', '.tsx']).flatMap(extractApiCallsFromFile);
}
function dynamicPathToRegex(pathPattern) {
const escaped = pathPattern
.replace(/[.+?^${}()|[\]\\]/g, '\\$&')
.replace(/\\\$\\\{dynamic\\\}/g, '[^/]+');
return new RegExp(`^${escaped}$`);
}
function formatCall(call) {
return `${call.method} ${call.path} (${call.file}:${call.line})`;
}
const backendRoutes = extractBackendRoutes();
const routeKeys = new Set(backendRoutes.map(route => `${route.method} ${route.path}`));
const duplicateRoutes = backendRoutes
.map(route => `${route.method} ${route.path}`)
.filter((key, index, items) => items.indexOf(key) !== index);
assert.deepEqual([...new Set(duplicateRoutes)].sort(), [], 'Backend route registry must not contain duplicate method/path pairs');
const frontendCalls = extractFrontendApiCalls();
const invalidPaths = frontendCalls.filter(call => !call.path.startsWith('/api/'));
assert.deepEqual(
invalidPaths.map(formatCall),
[],
'Taro apiRequest calls must target /api/... paths. Use env.ts only for runtime-config.json.',
);
function isAllowedDynamic(call) {
const rules = [...dynamicApiAllowlist, ...serverAliases].filter(rule => rule.method === call.method);
return rules.some(rule => {
if (!rule.frontendPattern.test(call.path) && !dynamicPathToRegex(call.path).test(rule.backendPaths[0] || '')) return false;
return rule.backendPaths.every(backendPath => routeKeys.has(`${rule.method} ${backendPath}`));
});
}
const missingCalls = frontendCalls.filter(call => {
if (routeKeys.has(`${call.method} ${call.path}`)) return false;
if (isAllowedDynamic(call)) return false;
return true;
});
assert.deepEqual(
missingCalls.map(formatCall),
[],
'Every Taro apiRequest method/path must be registered by apps/api or explicitly covered by a server alias.',
);
const coverage = {
backendRoutes: backendRoutes.length,
frontendCalls: frontendCalls.length,
uniqueFrontendCalls: new Set(frontendCalls.map(call => `${call.method} ${call.path}`)).size,
};
console.log(
`[PASS] Taro API contract (${coverage.frontendCalls} calls, ${coverage.uniqueFrontendCalls} unique frontend method/path pairs, ${coverage.backendRoutes} backend routes)`,
);