UNPKG

next-rest-framework

Version:

Next REST Framework - Type-safe, self-documenting APIs for Next.js

465 lines (457 loc) 14.8 kB
#!/usr/bin/env node import { isValidMethod } from "../chunk-2F2ZX7NN.mjs"; import "../chunk-2LDDF3UN.mjs"; import "../chunk-BXO7ZPPU.mjs"; // src/cli/index.ts import { Command } from "commander"; import chalk4 from "chalk"; // src/cli/utils.ts import { rm } from "fs/promises"; import chalk from "chalk"; import glob from "tiny-glob"; import { build } from "esbuild"; import { existsSync, readdirSync } from "fs"; import { join } from "path"; import { merge } from "lodash"; // src/cli/constants.ts var OPEN_API_VERSION = "3.0.1"; var NEXT_REST_FRAMEWORK_TEMP_FOLDER_NAME = ".next-rest-framework"; // src/cli/utils.ts var clearTmpFolder = async () => { try { await rm(join(process.cwd(), NEXT_REST_FRAMEWORK_TEMP_FOLDER_NAME), { recursive: true, force: true }); } catch { } }; var compileEndpoints = async () => { await clearTmpFolder(); console.info(chalk.yellowBright("Compiling endpoints...")); const entryPoints = await glob("./**/*.ts"); await build({ entryPoints, bundle: true, format: "cjs", platform: "node", outdir: NEXT_REST_FRAMEWORK_TEMP_FOLDER_NAME, outExtension: { ".js": ".cjs" }, packages: "external" }); }; var getNestedFiles = (basePath, dir) => { const dirents = readdirSync(join(basePath, dir), { withFileTypes: true }); const files = dirents.map((dirent) => { const res = join(dir, dirent.name); return dirent.isDirectory() ? getNestedFiles(basePath, res) : res; }); return files.flat(); }; var getRouteName = (file) => `/${file}`.replace("/route.cjs", "").replace(/\\/g, "/").replaceAll("[", "{").replaceAll("]", "}"); var getApiRouteName = (file) => `/api/${file}`.replace("/index", "").replace(/\\/g, "/").replaceAll("[", "{").replaceAll("]", "}").replace(".cjs", ""); var findConfig = async ({ configPath }) => { const configs = []; const findAppRouterConfig = async (path) => { const appRouterPath = join( process.cwd(), NEXT_REST_FRAMEWORK_TEMP_FOLDER_NAME, path ); if (existsSync(appRouterPath)) { const filteredRoutes = getNestedFiles(appRouterPath, "").filter( (file) => { if (configPath) { return configPath === getRouteName(file); } return true; } ); await Promise.all( filteredRoutes.map(async (route) => { try { const filePathToRoute = join( process.cwd(), NEXT_REST_FRAMEWORK_TEMP_FOLDER_NAME, path, route ); const url = new URL(`file://${filePathToRoute}`).toString(); const res = await import(url).then((mod) => mod.default); const handlers = Object.entries(res).filter(([key]) => isValidMethod(key)).map(([_key, handler]) => handler); for (const handler of handlers) { const _config = handler._nextRestFrameworkConfig; if (_config) { configs.push({ routeName: getRouteName(route), config: _config }); } } } catch (e) { } }) ); } }; await findAppRouterConfig("app"); await findAppRouterConfig("src/app"); const findPagesRouterConfig = async (path) => { const pagesRouterPath = join( process.cwd(), NEXT_REST_FRAMEWORK_TEMP_FOLDER_NAME, path ); if (existsSync(pagesRouterPath)) { const filteredApiRoutes = getNestedFiles(pagesRouterPath, "").filter( (file) => { if (configPath) { return configPath === getApiRouteName(file); } return true; } ); await Promise.all( filteredApiRoutes.map(async (route) => { try { const filePathToRoute = join( process.cwd(), NEXT_REST_FRAMEWORK_TEMP_FOLDER_NAME, "pages/api", route ); const url = new URL(`file://${filePathToRoute}`).toString(); const res = await import(url).then((mod) => mod.default); const _config = res.default._nextRestFrameworkConfig; if (_config) { configs.push({ routeName: getApiRouteName(route), config: _config }); } } catch { } }) ); } }; await findPagesRouterConfig("pages/api"); await findPagesRouterConfig("src/pages/api"); const { routeName, config } = configs[0] ?? { route: "", config: null }; if (!config && configPath) { console.error( chalk.red( `A \`configPath\` parameter with a value of ${configPath} was provided but no Next REST Framework configs were found.` ) ); return; } if (!config) { console.error( chalk.red( "Next REST Framework config not found. Initialize a docs handler to generate the OpenAPI spec." ) ); return; } if (configs.length > 1) { console.info( chalk.yellowBright( "Multiple Next REST Framework configs found. Please specify a `configPath` parameter to select a specific config." ) ); } console.info( chalk.yellowBright(`Using Next REST Framework config: ${routeName}`) ); return config; }; var generatePathsFromBuild = async ({ config }) => { const ignoredPaths = []; const isWildcardMatch = ({ pattern, path }) => { const regexPattern = pattern.split("/").map( (segment) => segment === "*" ? "[^/]*" : segment === "**" ? ".*" : segment ).join("/"); const regex = new RegExp(`^${regexPattern}$`); return regex.test(path); }; const isAllowedRoute = (path) => { const isAllowed = config.allowedPaths.some( (allowedPath) => isWildcardMatch({ pattern: allowedPath, path }) ); const isDenied = config.deniedPaths.some( (deniedPath) => isWildcardMatch({ pattern: deniedPath, path }) ); const routeIsAllowed = isAllowed && !isDenied; if (!routeIsAllowed) { ignoredPaths.push(path); } return routeIsAllowed; }; const getCleanedRoutes = (files) => files.filter((file) => file.endsWith("route.cjs")).filter((file) => !file.includes("[...")).filter((file) => !file.endsWith("rpc/[operationId]/route.cjs")).filter((file) => isAllowedRoute(getRouteName(file))); const getCleanedRpcRoutes = (files) => files.filter((file) => file.endsWith("rpc/[operationId]/route.cjs")).filter((file) => isAllowedRoute(getRouteName(file))); const getCleanedApiRoutes = (files) => files.filter((file) => !file.includes("[...")).filter((file) => !file.endsWith("rpc/[operationId].cjs")).filter((file) => isAllowedRoute(getApiRouteName(file))); const getCleanedRpcApiRoutes = (files) => files.filter((file) => file.endsWith("rpc/[operationId].cjs")).filter((file) => isAllowedRoute(getApiRouteName(file))); const isNrfOasData = (x) => { if (typeof x !== "object" || x === null) { return false; } return "paths" in x; }; let paths = {}; let schemas = {}; const collectAppRouterPaths = async (path) => { const appRouterPath = join( process.cwd(), NEXT_REST_FRAMEWORK_TEMP_FOLDER_NAME, path ); if (existsSync(appRouterPath)) { const files = getNestedFiles(appRouterPath, ""); const routes = getCleanedRoutes(files); const rpcRoutes = getCleanedRpcRoutes(files); await Promise.all( [...routes, ...rpcRoutes].map(async (route) => { try { const filePathToRoute = join( process.cwd(), NEXT_REST_FRAMEWORK_TEMP_FOLDER_NAME, path, route ); const url = new URL(`file://${filePathToRoute}`).toString(); const res = await import(url).then((mod) => mod.default); const handlers = Object.entries(res).filter(([key]) => isValidMethod(key)).map(([_key, handler]) => handler); for (const handler of handlers) { const data = await handler._getPathsForRoute(getRouteName(route)); if (isNrfOasData(data)) { paths = { ...paths, ...data.paths }; schemas = { ...schemas, ...data.schemas }; } } } catch { } }) ); } }; await collectAppRouterPaths("app"); await collectAppRouterPaths("src/app"); const collectPagesRouterPaths = async (path) => { const pagesRouterPath = join( process.cwd(), NEXT_REST_FRAMEWORK_TEMP_FOLDER_NAME, path ); if (existsSync(pagesRouterPath)) { const files = getNestedFiles(pagesRouterPath, ""); const apiRoutes = getCleanedApiRoutes(files); const rpcApiRoutes = getCleanedRpcApiRoutes(files); await Promise.all( [...apiRoutes, ...rpcApiRoutes].map(async (apiRoute) => { try { const filePathToRoute = join( process.cwd(), NEXT_REST_FRAMEWORK_TEMP_FOLDER_NAME, path, apiRoute ); const url = new URL(`file://${filePathToRoute}`).toString(); const res = await import(url).then((mod) => mod.default); const data = await res.default._getPathsForRoute( getApiRouteName(apiRoute) ); if (isNrfOasData(data)) { paths = { ...paths, ...data.paths }; schemas = { ...schemas, ...data.schemas }; } } catch { } }) ); } }; await collectPagesRouterPaths("pages/api"); await collectPagesRouterPaths("src/pages/api"); if (ignoredPaths.length) { console.info( chalk.yellowBright( `The following paths are ignored by Next REST Framework: ${chalk.bold( ignoredPaths.map((p) => ` - ${p}`) )}` ) ); } return { paths, schemas }; }; var generateOpenApiSpec = async ({ config }) => { const { paths = {}, schemas = {} } = await generatePathsFromBuild({ config }); const sortObjectByKeys = (obj) => { const unordered = { ...obj }; return Object.keys(unordered).sort().reduce((_obj, key) => { _obj[key] = unordered[key]; return _obj; }, {}); }; const components = Object.keys(schemas).length ? { components: { schemas: sortObjectByKeys(schemas) } } : {}; const spec = merge( { openapi: OPEN_API_VERSION, info: config.openApiObject.info, paths: sortObjectByKeys(paths) }, components, config.openApiObject ); return spec; }; // src/cli/validate.ts import { join as join2 } from "path"; import { readFileSync } from "fs"; import { isEqualWith } from "lodash"; import chalk2 from "chalk"; var validateOpenApiSpecFromBuild = async ({ configPath }) => { const config = await findConfig({ configPath }); if (!config) { return; } const spec = await generateOpenApiSpec({ config }); const path = join2(process.cwd(), "public", config.openApiJsonPath); try { const data = readFileSync(path); const openApiSpec = JSON.parse(data.toString()); if (!isEqualWith(openApiSpec, spec)) { console.error( chalk2.red( "API spec changed is not up-to-date. Run `next-rest-framework generate` to update it." ) ); } else { console.info(chalk2.green("OpenAPI spec up to date!")); return true; } } catch { console.error( chalk2.red( "No OpenAPI spec found. Run `next-rest-framework generate` to generate it." ) ); } return false; }; // src/cli/generate.ts import chalk3 from "chalk"; import { existsSync as existsSync2, readFileSync as readFileSync2, writeFileSync } from "fs"; import { join as join3 } from "path"; import * as prettier from "prettier"; import { isEqualWith as isEqualWith2 } from "lodash"; var writeOpenApiSpec = async ({ path, spec }) => { try { if (!existsSync2(join3(process.cwd(), "public"))) { console.info( chalk3.redBright( "The `public` folder was not found. Generating OpenAPI spec aborted." ) ); return; } const jsonSpec = await prettier.format(JSON.stringify(spec), { parser: "json" }); writeFileSync(path, jsonSpec, null); console.info(chalk3.green("OpenAPI spec generated successfully!")); } catch (e) { console.error(chalk3.red(`Error while generating the API spec: ${e}`)); } }; var syncOpenApiSpecFromBuild = async ({ configPath }) => { const config = await findConfig({ configPath }); if (!config) { return; } const spec = await generateOpenApiSpec({ config }); const path = join3(process.cwd(), "public", config.openApiJsonPath); try { const data = readFileSync2(path); const openApiSpec = JSON.parse(data.toString()); if (!isEqualWith2(openApiSpec, spec)) { console.info( chalk3.yellowBright( "OpenAPI spec changed, regenerating `openapi.json`..." ) ); await writeOpenApiSpec({ path, spec }); } else { console.info( chalk3.green("OpenAPI spec up to date, skipping generation.") ); } } catch { console.info( chalk3.yellowBright("No OpenAPI spec found, generating `openapi.json`...") ); await writeOpenApiSpec({ path, spec }); } }; // src/cli/index.ts var program = new Command(); program.command("generate").option( "--configPath <string>", "In case you have multiple docs handlers with different configurations, you can specify which configuration you want to use by providing the path to the API. Example: `/api/my-configuration`." ).description("Generate an OpenAPI spec with Next REST Framework.").action(async (options) => { const configPath = options.configPath ?? ""; try { await compileEndpoints(); console.info(chalk4.yellowBright("Generating OpenAPI spec...")); await syncOpenApiSpecFromBuild({ configPath }); } catch (e) { console.error(e); process.exit(1); } await clearTmpFolder(); }); program.command("validate").option( "--configPath <string>", "In case you have multiple docs handlers with different configurations, you can specify which configuration you want to use by providing the path to the API. Example: `/api/my-configuration`." ).description("Validate an OpenAPI spec with Next REST Framework.").action(async (options) => { const configPath = options.configPath ?? ""; try { await compileEndpoints(); console.info(chalk4.yellowBright("Validating OpenAPI spec...")); const valid = await validateOpenApiSpecFromBuild({ configPath }); if (!valid) { process.exit(1); } } catch (e) { console.error(e); process.exit(1); } await clearTmpFolder(); }); program.parse(process.argv);