Files
nocobase/scripts/check-openapi-app.mjs

225 lines
8.3 KiB
JavaScript

// Starts one application on SQLite test databases and writes what `scripts/check-openapi.mjs` needs to judge its API
// document: every `/api` route with its declaration (`inspectApiRoutes`), the problems `findApiDocumentSchemaProblems`
// finds, the document itself, and the commands it gives the application's CLI. Only app-server's generic functions are
// used, so a route any plugin contributes is judged the same way.
//
// It runs in a child process under tsx, because applications and workspace packages are TypeScript sources. Every
// package is resolved from the application's directory rather than from this file, so the same runner serves this
// repository's templates and an application assembled elsewhere, such as NocoBase 3 Pro's, which reaches this file
// through its `vendor/nocobase3` submodule.
//
// Usage (internal): node --import <tsx> scripts/check-openapi-app.mjs <request.json>
//
// The request is `{ appDir, connections?, config?, extend?, output }`. `extend` names a module whose default export
// receives the application's runtime definition and returns the one to start, which is how a caller adds plugins:
//
// export default async (definition, { loadPackage, withPlugins, withConfigs }) => {
// const { default: mail, mailConfig } = await loadPackage('packages/plugins/app-plugin-mail', './server');
// return withConfigs(withPlugins(definition, [mail]), { mail: mailConfig });
// };
import { existsSync, readFileSync, writeFileSync } from 'node:fs';
import path from 'node:path';
import process from 'node:process';
import { fileURLToPath, pathToFileURL } from 'node:url';
const CONDITIONS = new Set(['import', 'node', 'default']);
function pickExport(target) {
if (typeof target === 'string') return target;
if (Array.isArray(target)) {
for (const entry of target) {
const picked = pickExport(entry);
if (picked) return picked;
}
return undefined;
}
if (target && typeof target === 'object') {
for (const [condition, value] of Object.entries(target)) {
if (!CONDITIONS.has(condition)) continue;
const picked = pickExport(value);
if (picked) return picked;
}
}
return undefined;
}
/**
* The file a package directory exports at `subpath` (`.` or `./server`) under the `import` condition, as a file URL.
*/
export function resolvePackageExport(packageDir, subpath = '.') {
const manifest = JSON.parse(
readFileSync(path.join(packageDir, 'package.json'), 'utf8'),
);
let target;
if (manifest.exports !== undefined) {
const exportsMap =
typeof manifest.exports === 'string' ||
Object.keys(manifest.exports).every((key) => !key.startsWith('.'))
? { '.': manifest.exports }
: manifest.exports;
target = pickExport(exportsMap[subpath]);
} else {
target = subpath === '.' ? (manifest.module ?? manifest.main) : subpath;
}
if (!target) {
throw new Error(
`${manifest.name ?? packageDir} does not export ${subpath} under the import condition.`,
);
}
return pathToFileURL(path.join(packageDir, target)).href;
}
/**
* Resolves a bare specifier the way Node would from a module inside `fromDir`: the nearest `node_modules` holding the
* package, then its `exports` entry for the subpath under the `import` condition.
*/
export function resolveFrom(fromDir, specifier) {
const segments = specifier.split('/');
const name = specifier.startsWith('@')
? segments.slice(0, 2).join('/')
: segments[0];
const subpath = `.${specifier.slice(name.length)}`;
for (let dir = path.resolve(fromDir); ; dir = path.dirname(dir)) {
const packageDir = path.join(dir, 'node_modules', name);
if (existsSync(path.join(packageDir, 'package.json'))) {
return resolvePackageExport(packageDir, subpath);
}
if (path.dirname(dir) === dir) break;
}
throw new Error(`Cannot resolve ${specifier} from ${fromDir}.`);
}
async function main() {
const request = JSON.parse(readFileSync(process.argv[2], 'utf8'));
const appDir = path.resolve(request.appDir);
const load = (specifier) => import(resolveFrom(appDir, specifier));
process.env.AUTH_SECRET ??=
'openapi-check-auth-secret-at-least-32-characters';
const [
{ inspectApiRoutes, findApiDocumentSchemaProblems, apiDocsToken, cliToken },
{ defineStandaloneServer },
{ resolveAppRuntime, startApplicationInScope },
{ defineServerPlugins },
{ createTestApp },
{ default: appRuntime },
{ createApp },
] = await Promise.all([
load('@nocobase/app-server/router'),
load('@nocobase/app-server/node'),
load('@nocobase/app-server/runtime'),
load('@nocobase/app-server/plugins'),
load('@nocobase/app-testing/server'),
import(pathToFileURL(path.join(appDir, 'server/runtime.ts')).href),
import(pathToFileURL(path.join(appDir, 'server/app.ts')).href),
]);
const helpers = {
/** Imports a package as the application resolves it. */
load,
/** Imports what a package directory exports at `subpath`, such as a workspace plugin the application does not list. */
loadPackage: (packageDir, subpath = '.') =>
import(resolvePackageExport(path.resolve(packageDir), subpath)),
/** The definition with `plugins` appended to the application's own server plugins. */
withPlugins: (definition, plugins) => ({
...definition,
plugins: defineServerPlugins([...definition.plugins.plugins, ...plugins]),
}),
/** The definition with configuration sections added to its defaults, each a `defineAppConfig` factory. */
withConfigs: (definition, configs) => {
const base = definition.defaultConfigs;
const factory = (runtime) => ({
...base?.(runtime),
...Object.fromEntries(
Object.entries(configs).map(([key, configure]) => [
key,
configure(runtime),
]),
),
});
const sections = new Map(base?.sections ?? []);
for (const [key, configure] of Object.entries(configs)) {
if (configure.rules) sections.set(key, configure.rules);
}
return {
...definition,
defaultConfigs: Object.assign(factory, { sections }),
};
},
};
let definition = appRuntime;
if (request.extend) {
const { default: extend } = await import(
pathToFileURL(path.resolve(request.extend)).href
);
definition = await extend(definition, helpers);
}
// The same composition as the application's `server/embedded.ts`, with the runtime definition swapped for the
// extended one. A standalone proxy forwards outside `/api` and does not change the document.
const standalone = defineStandaloneServer({
rootDir: appDir,
appRuntime: definition,
createServer: async (scope) =>
startApplicationInScope(
scope,
createApp(await resolveAppRuntime(definition, scope)),
),
});
let app;
try {
app = await createTestApp({
createServer: standalone.create,
connections: request.connections ?? ['main'],
config: {
auth: { secret: process.env.AUTH_SECRET },
// Keep the report readable: startup information is noise here, and log files would outlive the run.
logging: { level: 'error', file: { enabled: false } },
...request.config,
},
server: { env: { DB_MIGRATIONS_AUTO_RUN: 'true' } },
});
const { application } = app;
const routes = inspectApiRoutes(application);
const document = await application.container
.resolve(apiDocsToken)
.getDocument();
// Every command the document gives the application's CLI, with the application's exclusions applied: what
// `scripts/gen-cli-reference.mjs` documents.
const cli = application.container.has(cliToken)
? application.container.resolve(cliToken)
: undefined;
writeFileSync(
request.output,
`${JSON.stringify({
routes,
schemaProblems: findApiDocumentSchemaProblems(document),
document,
cliCommands: cli ? cli.commandsOf(document) : [],
cliDescription: cli ? cli.described() : {},
})}\n`,
);
} finally {
await app?.close();
}
}
if (
process.argv[1] &&
path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)
) {
// Exit explicitly: a plugin may leave a timer or a handle behind once the application has closed.
main().then(
() => process.exit(0),
(error) => {
console.error(error);
process.exit(1);
},
);
}