UNPKG

next-rest-framework

Version:

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

616 lines (599 loc) 20.5 kB
#!/usr/bin/env node "use strict"; var __create = Object.create; var __defProp = Object.defineProperty; var __getOwnPropDesc = Object.getOwnPropertyDescriptor; var __getOwnPropNames = Object.getOwnPropertyNames; var __getProtoOf = Object.getPrototypeOf; var __hasOwnProp = Object.prototype.hasOwnProperty; var __commonJS = (cb, mod) => function __require() { return mod || (0, cb[__getOwnPropNames(cb)[0]])((mod = { exports: {} }).exports, mod), mod.exports; }; var __copyProps = (to, from, except, desc) => { if (from && typeof from === "object" || typeof from === "function") { for (let key of __getOwnPropNames(from)) if (!__hasOwnProp.call(to, key) && key !== except) __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable }); } return to; }; var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps( // If the importer is in node compatibility mode or this is not an ESM // file that has been converted to a CommonJS file using a Babel- // compatible transform (i.e. "__esModule" has not been set), then set // "default" to the CommonJS "module.exports" for node compatibility. isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target, mod )); // package.json var require_package = __commonJS({ "package.json"(exports2, module2) { module2.exports = { name: "next-rest-framework", version: "5.1.6", description: "Next REST Framework - Type-safe, self-documenting APIs for Next.js", keywords: [ "nextjs", "rest", "api", "next-rest-framework" ], homepage: "https://next-rest-framework.vercel.app", bugs: { url: "https://github.com/blomqma/next-rest-framework/issues", email: "blomqma@omg.lol" }, license: "ISC", author: "Markus Blomqvist <blomqma@omg.lol>", files: [ "dist" ], main: "dist/index.js", module: "dist/index.mjs", types: "dist/index.d.ts", repository: { type: "git", url: "https://github.com/blomqma/next-rest-framework.git", directory: "packages/next-rest-framework" }, scripts: { lint: "tsc", test: "jest", "test:watch": "jest --watch", build: "tsup --dts" }, bin: { "next-rest-framework": "./dist/cli/index.js" }, dependencies: { chalk: "4.1.2", commander: "10.0.1", esbuild: "0.19.11", lodash: "4.17.21", prettier: "3.0.2", "tiny-glob": "0.2.9", "zod-to-json-schema": "3.21.4" }, devDependencies: { "@types/jest": "29.5.4", "@types/lodash": "4.14.197", jest: "29.6.4", "node-mocks-http": "1.13.0", "openapi-types": "12.1.3", "ts-jest": "29.1.1", "ts-node": "10.9.1", tsup: "8.0.1", typescript: "*", next: "*", zod: "*" } }; } }); // src/cli/index.ts var import_commander = require("commander"); var import_chalk5 = __toESM(require("chalk")); // src/cli/utils.ts var import_promises = require("fs/promises"); var import_chalk2 = __toESM(require("chalk")); var import_tiny_glob = __toESM(require("tiny-glob")); var import_esbuild = require("esbuild"); var import_fs = require("fs"); var import_path = require("path"); // src/shared/config.ts var import_lodash = require("lodash"); // src/constants.ts var ValidMethod = /* @__PURE__ */ ((ValidMethod2) => { ValidMethod2["GET"] = "GET"; ValidMethod2["PUT"] = "PUT"; ValidMethod2["POST"] = "POST"; ValidMethod2["DELETE"] = "DELETE"; ValidMethod2["OPTIONS"] = "OPTIONS"; ValidMethod2["HEAD"] = "HEAD"; ValidMethod2["PATCH"] = "PATCH"; return ValidMethod2; })(ValidMethod || {}); var VERSION = require_package().version; var HOMEPAGE = require_package().homepage; var DEFAULT_TITLE = "Next REST Framework"; var DEFAULT_OG_TYPE = "website"; var DEFAULT_DESCRIPTION = "This is an autogenerated documentation by Next REST Framework."; var DEFAULT_FAVICON_URL = "https://raw.githubusercontent.com/blomqma/next-rest-framework/main/docs/static/img/favicon.ico"; var DEFAULT_LOGO_URL = "https://raw.githubusercontent.com/blomqma/next-rest-framework/d02224b38d07ede85257b22ed50159a947681f99/packages/next-rest-framework/logo.svg"; // src/shared/config.ts var DEFAULT_CONFIG = { deniedPaths: [], allowedPaths: ["**"], openApiObject: { info: { title: DEFAULT_TITLE, description: DEFAULT_DESCRIPTION, version: `v${VERSION}` } }, openApiJsonPath: "/openapi.json", docsConfig: { provider: "redoc", title: DEFAULT_TITLE, description: DEFAULT_DESCRIPTION, faviconUrl: DEFAULT_FAVICON_URL, logoUrl: DEFAULT_LOGO_URL, ogConfig: { title: DEFAULT_TITLE, type: DEFAULT_OG_TYPE, url: HOMEPAGE, imageUrl: DEFAULT_LOGO_URL } }, suppressInfo: false }; // src/shared/logging.ts var import_chalk = __toESM(require("chalk")); // src/shared/paths.ts var import_lodash2 = require("lodash"); // src/shared/schemas.ts var import_zod_to_json_schema = require("zod-to-json-schema"); // src/shared/utils.ts var isValidMethod = (x) => Object.values(ValidMethod).includes(x); // src/cli/utils.ts var import_lodash3 = require("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 (0, import_promises.rm)((0, import_path.join)(process.cwd(), NEXT_REST_FRAMEWORK_TEMP_FOLDER_NAME), { recursive: true, force: true }); } catch { } }; var compileEndpoints = async () => { await clearTmpFolder(); console.info(import_chalk2.default.yellowBright("Compiling endpoints...")); const entryPoints = await (0, import_tiny_glob.default)("./**/*.ts"); await (0, import_esbuild.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 = (0, import_fs.readdirSync)((0, import_path.join)(basePath, dir), { withFileTypes: true }); const files = dirents.map((dirent) => { const res = (0, import_path.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 = (0, import_path.join)( process.cwd(), NEXT_REST_FRAMEWORK_TEMP_FOLDER_NAME, path ); if ((0, import_fs.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 = (0, import_path.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 = (0, import_path.join)( process.cwd(), NEXT_REST_FRAMEWORK_TEMP_FOLDER_NAME, path ); if ((0, import_fs.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 = (0, import_path.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( import_chalk2.default.red( `A \`configPath\` parameter with a value of ${configPath} was provided but no Next REST Framework configs were found.` ) ); return; } if (!config) { console.error( import_chalk2.default.red( "Next REST Framework config not found. Initialize a docs handler to generate the OpenAPI spec." ) ); return; } if (configs.length > 1) { console.info( import_chalk2.default.yellowBright( "Multiple Next REST Framework configs found. Please specify a `configPath` parameter to select a specific config." ) ); } console.info( import_chalk2.default.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 = (0, import_path.join)( process.cwd(), NEXT_REST_FRAMEWORK_TEMP_FOLDER_NAME, path ); if ((0, import_fs.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 = (0, import_path.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 = (0, import_path.join)( process.cwd(), NEXT_REST_FRAMEWORK_TEMP_FOLDER_NAME, path ); if ((0, import_fs.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 = (0, import_path.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( import_chalk2.default.yellowBright( `The following paths are ignored by Next REST Framework: ${import_chalk2.default.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 = (0, import_lodash3.merge)( { openapi: OPEN_API_VERSION, info: config.openApiObject.info, paths: sortObjectByKeys(paths) }, components, config.openApiObject ); return spec; }; // src/cli/validate.ts var import_path2 = require("path"); var import_fs2 = require("fs"); var import_lodash4 = require("lodash"); var import_chalk3 = __toESM(require("chalk")); var validateOpenApiSpecFromBuild = async ({ configPath }) => { const config = await findConfig({ configPath }); if (!config) { return; } const spec = await generateOpenApiSpec({ config }); const path = (0, import_path2.join)(process.cwd(), "public", config.openApiJsonPath); try { const data = (0, import_fs2.readFileSync)(path); const openApiSpec = JSON.parse(data.toString()); if (!(0, import_lodash4.isEqualWith)(openApiSpec, spec)) { console.error( import_chalk3.default.red( "API spec changed is not up-to-date. Run `next-rest-framework generate` to update it." ) ); } else { console.info(import_chalk3.default.green("OpenAPI spec up to date!")); return true; } } catch { console.error( import_chalk3.default.red( "No OpenAPI spec found. Run `next-rest-framework generate` to generate it." ) ); } return false; }; // src/cli/generate.ts var import_chalk4 = __toESM(require("chalk")); var import_fs3 = require("fs"); var import_path3 = require("path"); var prettier = __toESM(require("prettier")); var import_lodash5 = require("lodash"); var writeOpenApiSpec = async ({ path, spec }) => { try { if (!(0, import_fs3.existsSync)((0, import_path3.join)(process.cwd(), "public"))) { console.info( import_chalk4.default.redBright( "The `public` folder was not found. Generating OpenAPI spec aborted." ) ); return; } const jsonSpec = await prettier.format(JSON.stringify(spec), { parser: "json" }); (0, import_fs3.writeFileSync)(path, jsonSpec, null); console.info(import_chalk4.default.green("OpenAPI spec generated successfully!")); } catch (e) { console.error(import_chalk4.default.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 = (0, import_path3.join)(process.cwd(), "public", config.openApiJsonPath); try { const data = (0, import_fs3.readFileSync)(path); const openApiSpec = JSON.parse(data.toString()); if (!(0, import_lodash5.isEqualWith)(openApiSpec, spec)) { console.info( import_chalk4.default.yellowBright( "OpenAPI spec changed, regenerating `openapi.json`..." ) ); await writeOpenApiSpec({ path, spec }); } else { console.info( import_chalk4.default.green("OpenAPI spec up to date, skipping generation.") ); } } catch { console.info( import_chalk4.default.yellowBright("No OpenAPI spec found, generating `openapi.json`...") ); await writeOpenApiSpec({ path, spec }); } }; // src/cli/index.ts var program = new import_commander.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(import_chalk5.default.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(import_chalk5.default.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);