next-rest-framework
Version:
Next REST Framework - Type-safe, self-documenting APIs for Next.js
579 lines (564 loc) • 19.2 kB
JavaScript
;
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 __export = (target, all) => {
for (var name in all)
__defProp(target, name, { get: all[name], enumerable: true });
};
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
));
var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
// package.json
var require_package = __commonJS({
"package.json"(exports2, module2) {
module2.exports = {
name: "next-rest-framework",
version: "6.1.1",
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",
formidable: "^3.5.1",
lodash: "4.17.21",
prettier: "3.0.2",
qs: "6.14.1"
},
devDependencies: {
"@types/formidable": "^3.4.5",
"@types/jest": "29.5.4",
"@types/lodash": "4.14.197",
"@types/qs": "6.9.11",
esbuild: "0.19.11",
jest: "29.6.4",
next: "*",
"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: "*",
zod: "^4.1.13",
"zod-form-data": "3.0.1"
}
};
}
});
// src/cli/validate.ts
var validate_exports = {};
__export(validate_exports, {
validate: () => validate
});
module.exports = __toCommonJS(validate_exports);
var import_path2 = require("path");
// src/cli/utils.ts
var import_chalk3 = __toESM(require("chalk"));
var import_fs = require("fs");
var import_path = require("path");
// src/shared/config.ts
var import_lodash = require("lodash");
// src/constants.ts
var DEFAULT_ERRORS = {
unexpectedError: "An unknown error occurred, trying again might help.",
methodNotAllowed: "Method not allowed.",
notFound: "Not found.",
notImplemented: "Not implemented.",
invalidMediaType: "Invalid media type.",
operationNotAllowed: "Operation not allowed.",
invalidRequestBody: "Invalid request body.",
invalidQueryParameters: "Invalid query parameters.",
invalidPathParameters: "Invalid path parameters."
};
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";
var INVALID_REQUEST_BODY_RESPONSE = {
description: DEFAULT_ERRORS.invalidRequestBody,
content: {
"application/json": {
schema: {
$ref: `#/components/schemas/MessageWithErrors`
}
}
}
};
var UNEXPECTED_ERROR_RESPONSE = {
description: DEFAULT_ERRORS.unexpectedError,
content: {
"application/json": {
schema: {
$ref: `#/components/schemas/ErrorMessage`
}
}
}
};
var INVALID_RPC_REQUEST_RESPONSE = {
description: "Error response.",
content: {
"application/json": {
schema: {
oneOf: [
{
description: DEFAULT_ERRORS.invalidRequestBody,
$ref: `#/components/schemas/MessageWithErrors`
},
{
description: DEFAULT_ERRORS.unexpectedError,
$ref: `#/components/schemas/ErrorMessage`
}
]
}
}
}
};
var INVALID_MEDIA_TYPE_RESPONSE = {
description: DEFAULT_ERRORS.invalidMediaType,
content: {
"application/json": {
schema: {
$ref: `#/components/schemas/ErrorMessage`
}
}
},
headers: {
Allow: {
schema: {
type: "string"
}
}
}
};
var INVALID_QUERY_PARAMETERS_RESPONSE = {
description: DEFAULT_ERRORS.invalidQueryParameters,
content: {
"application/json": {
schema: {
$ref: `#/components/schemas/MessageWithErrors`
}
}
}
};
var INVALID_PATH_PARAMETERS_RESPONSE = {
description: DEFAULT_ERRORS.invalidPathParameters,
content: {
"application/json": {
schema: {
$ref: `#/components/schemas/MessageWithErrors`
}
}
}
};
// 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
}
}
};
// src/shared/logging.ts
var import_chalk = __toESM(require("chalk"));
var logGenerateErrorForRoute = (path, error) => {
console.info(
import_chalk.default.yellow(`---
Error while importing ${path}, skipping path...`)
);
console.error(import_chalk.default.red(error));
console.info(
import_chalk.default.yellow(
`If you don't want this path to be part of your generated OpenAPI spec and want to prevent seeing this error in the future, please add ${path} to 'deniedPaths'.`
)
);
};
// src/shared/paths.ts
var import_lodash2 = require("lodash");
// src/shared/schemas.ts
var import_chalk2 = __toESM(require("chalk"));
// 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";
// src/cli/utils.ts
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.ts", "").replace("/route.js", "").replace(/\\/g, "/").replaceAll("[", "{").replaceAll("]", "}").replace(/\/?\([^)]*\)/g, "");
var getApiRouteName = (file) => `/api/${file}`.replace("/index", "").replace(/\\/g, "/").replaceAll("[", "{").replaceAll("]", "}").replace(".ts", "").replace(".js", "");
var findConfig = async ({ configPath }) => {
const configs = [];
const findAppRouterConfig = async (path) => {
const appRouterPath = (0, import_path.join)(process.cwd(), path);
if ((0, import_fs.existsSync)(appRouterPath)) {
const filteredRoutes = getNestedFiles(appRouterPath, "").filter(
(file) => {
if (configPath) {
return configPath === getRouteName(file);
}
return file.endsWith("route.ts") || file.endsWith("route.js");
}
);
await Promise.all(
filteredRoutes.map(async (route) => {
try {
const filePathToRoute = (0, import_path.join)(process.cwd(), path, route);
const url = new URL(`file://${filePathToRoute}`).toString();
const res = await import(url).then((mod) => mod.default || mod);
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 {
}
})
);
}
};
await findAppRouterConfig("app");
await findAppRouterConfig("src/app");
const findPagesRouterConfig = async (path) => {
const pagesRouterPath = (0, import_path.join)(process.cwd(), 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(), path, route);
const url = new URL(`file://${filePathToRoute}`).toString();
const res = await import(url).then((mod) => mod.default || mod);
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_chalk3.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_chalk3.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_chalk3.default.yellowBright(
"Multiple Next REST Framework configs found. Please specify a `configPath` parameter to select a specific config."
)
);
}
console.info(
import_chalk3.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.ts") || file.endsWith("route.js")) && !file.includes("[...") && !file.endsWith("rpc/[operationId]/route.ts") && !file.endsWith("rpc/[operationId]/route.js") && isAllowedRoute(getRouteName(file))
);
const getCleanedRpcRoutes = (files) => files.filter(
(file) => (file.endsWith("rpc/[operationId]/route.ts") || file.endsWith("rpc/[operationId]/route.js")) && isAllowedRoute(getRouteName(file))
);
const getCleanedApiRoutes = (files) => files.filter(
(file) => (file.endsWith(".ts") || file.endsWith(".js")) && !file.includes("[...") && !file.endsWith("rpc/[operationId].ts") && !file.endsWith("rpc/[operationId].js") && isAllowedRoute(getApiRouteName(file))
);
const getCleanedRpcApiRoutes = (files) => files.filter(
(file) => (file.endsWith("rpc/[operationId].ts") || file.endsWith("rpc/[operationId].js")) && 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(), 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(), path, route);
const url = new URL(`file://${filePathToRoute}`).toString();
const res = await import(url).then((mod) => mod.default || mod);
const handlers = Object.entries(res).filter(([key]) => isValidMethod(key)).map(([_key, handler]) => handler);
for (const handler of handlers) {
const isDocsHandler = !!handler._nextRestFrameworkConfig;
if (isDocsHandler) {
continue;
}
const data = await handler._getPathsForRoute(getRouteName(route));
if (isNrfOasData(data)) {
paths = { ...paths, ...data.paths };
schemas = { ...schemas, ...data.schemas };
}
}
} catch (e) {
logGenerateErrorForRoute(getRouteName(route), e);
}
})
);
}
};
await collectAppRouterPaths("app");
await collectAppRouterPaths("src/app");
const collectPagesRouterPaths = async (path) => {
const pagesRouterPath = (0, import_path.join)(process.cwd(), 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(), path, apiRoute);
const url = new URL(`file://${filePathToRoute}`).toString();
const res = await import(url).then((mod) => mod.default || mod);
const isDocsHandler = !!res.default._nextRestFrameworkConfig;
if (isDocsHandler) {
return;
}
const data = await res.default._getPathsForRoute(
getApiRouteName(apiRoute)
);
if (isNrfOasData(data)) {
paths = { ...paths, ...data.paths };
schemas = { ...schemas, ...data.schemas };
}
} catch (e) {
logGenerateErrorForRoute(getApiRouteName(apiRoute), e);
}
})
);
}
};
await collectPagesRouterPaths("pages/api");
await collectPagesRouterPaths("src/pages/api");
if (ignoredPaths.length) {
console.info(
import_chalk3.default.yellowBright(
`The following paths are ignored by Next REST Framework: ${import_chalk3.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_fs2 = require("fs");
var import_chalk4 = __toESM(require("chalk"));
var validate = 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 (!(JSON.stringify(openApiSpec) === JSON.stringify(spec))) {
console.error(
import_chalk4.default.red(
"API spec changed is not up-to-date. Run `next-rest-framework generate` to update it."
)
);
} else {
console.info(import_chalk4.default.green("OpenAPI spec up to date!"));
return true;
}
} catch {
console.error(
import_chalk4.default.red(
"No OpenAPI spec found. Run `next-rest-framework generate` to generate it."
)
);
}
return false;
};
// Annotate the CommonJS export names for ESM import in node:
0 && (module.exports = {
validate
});