forked from wangziqi/gongxue-base
test: guard taro api contract
This commit is contained in:
305
scripts/taro-api-contract-test.js
Normal file
305
scripts/taro-api-contract-test.js
Normal file
@@ -0,0 +1,305 @@
|
||||
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)`,
|
||||
);
|
||||
Reference in New Issue
Block a user