forked from wangziqi/gongxue-base
306 lines
8.8 KiB
JavaScript
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)`,
|
|
);
|