next-rest-framework
Version:
Next REST Framework - write type-safe, self-documenting REST APIs in Next.js
378 lines (373 loc) • 15.6 kB
JavaScript
;
var __importDefault = (this && this.__importDefault) || function (mod) {
return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", { value: true });
exports.getPathsFromMethodHandlers = exports.isValidMethod = exports.defaultResponse = exports.getOrCreateOpenApiSpec = exports.getHTMLForSwaggerUI = void 0;
const path_1 = require("path");
const constants_1 = require("../constants");
const lodash_1 = require("lodash");
const schemas_1 = require("./schemas");
const fs_1 = require("fs");
const chalk_1 = __importDefault(require("chalk"));
const logging_1 = require("./logging");
const getHTMLForSwaggerUI = ({ config: { openApiJsonPath, swaggerUiConfig: { defaultTheme, title, description, faviconHref, logoHref } = {} }, baseUrl, theme = defaultTheme ?? 'light' }) => {
const url = `${baseUrl}${openApiJsonPath}`;
return `<!DOCTYPE html>
<html lang="en" data-theme="${theme}">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>${title}</title>
<meta
name="description"
content="${description}"
/>
<link rel="icon" type="image/x-icon" href="${faviconHref}">
<link rel="stylesheet" href="https://unpkg.com/swagger-ui-dist@4.5.0/swagger-ui.css" />
<link
href="https://cdn.jsdelivr.net/npm/daisyui@2.46.0/dist/full.css"
rel="stylesheet"
type="text/css"
/>
<script src="https://cdn.tailwindcss.com"></script>
<style>
p, :not(code, a) > span:not(.opblock-summary-method), i, h1, h2, h3, h4, h5, h6, th, td, button, div {color: hsl(var(--bc)) !important;}
svg {fill: hsl(var(--bc)) !important;}
.opblock-section-header {background-color: hsl(var(--b)) !important;}
</style>
</head>
<body class="min-h-screen flex flex-col items-center">
<div class="navbar bg-base-200 flex justify-center">
<div class="max-w-7xl flex justify-between grow gap-5 h-24 px-5">
<div class="flex items-center gap-4">
<a>
<img
src="${logoHref}"
alt="Logo"
class="w-32"
/>
</a>
<p>v${constants_1.VERSION}</p>
</div>
<label class="swap swap-rotate">
<input
type="checkbox"
onclick="if(this.checked){document.documentElement.setAttribute('data-theme', 'light');document.cookie='theme=light; expires=Fri, 31 Dec 9999 23:59:59 GMT; path=/';}else{document.documentElement.setAttribute('data-theme', 'dark');document.cookie='theme=dark; expires=Fri, 31 Dec 9999 23:59:59 GMT; path=/';}"
${theme === 'light' ? 'checked' : ''}
/>
<svg
class="swap-on fill-current w-6 h-6"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
>
<path d="M5.64,17l-.71.71a1,1,0,0,0,0,1.41,1,1,0,0,0,1.41,0l.71-.71A1,1,0,0,0,5.64,17ZM5,12a1,1,0,0,0-1-1H3a1,1,0,0,0,0,2H4A1,1,0,0,0,5,12Zm7-7a1,1,0,0,0,1-1V3a1,1,0,0,0-2,0V4A1,1,0,0,0,12,5ZM5.64,7.05a1,1,0,0,0,.7.29,1,1,0,0,0,.71-.29,1,1,0,0,0,0-1.41l-.71-.71A1,1,0,0,0,4.93,6.34Zm12,.29a1,1,0,0,0,.7-.29l.71-.71a1,1,0,1,0-1.41-1.41L17,5.64a1,1,0,0,0,0,1.41A1,1,0,0,0,17.66,7.34ZM21,11H20a1,1,0,0,0,0,2h1a1,1,0,0,0,0-2Zm-9,8a1,1,0,0,0-1,1v1a1,1,0,0,0,2,0V20A1,1,0,0,0,12,19ZM18.36,17A1,1,0,0,0,17,18.36l.71.71a1,1,0,0,0,1.41,0,1,1,0,0,0,0-1.41ZM12,6.5A5.5,5.5,0,1,0,17.5,12,5.51,5.51,0,0,0,12,6.5Zm0,9A3.5,3.5,0,1,1,15.5,12,3.5,3.5,0,0,1,12,15.5Z" />
</svg>
<svg
class="swap-off fill-current w-6 h-6"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
>
<path d="M21.64,13a1,1,0,0,0-1.05-.14,8.05,8.05,0,0,1-3.37.73A8.15,8.15,0,0,1,9.08,5.49a8.59,8.59,0,0,1,.25-2A1,1,0,0,0,8,2.36,10.14,10.14,0,1,0,22,14.05,1,1,0,0,0,21.64,13Zm-9.5,6.69A8.14,8.14,0,0,1,7.08,5.22v.27A10.15,10.15,0,0,0,17.22,15.63a9.79,9.79,0,0,0,2.1-.22A8.11,8.11,0,0,1,12.14,19.73Z" />
</svg>
</label>
</div>
</div>
<main class="max-w-7xl grow w-full">
<div id="swagger-ui"></div>
</main>
<footer class="footer bg-base-200 flex justify-center">
<div class="container max-w-5xl flex flex-col items-center text-md gap-5 px-5 py-2">
<a href="https://github.com/blomqma/next-rest-framework" class="text-center text-sm flex flex-wrap items-center gap-1">
Built with Next REST Framework
<img
src="https://next-rest-framework.vercel.app/img/logo.svg"
alt="Next REST Framework logo"
class="w-10"
/>
</a>
</div>
</footer>
<script src="https://unpkg.com/swagger-ui-dist@4.5.0/swagger-ui-bundle.js" crossorigin></script>
<script>
window.onload = () => {
window.ui = SwaggerUIBundle({
url: '${url}',
dom_id: '#swagger-ui',
});
};
</script>
</body>
</html>`;
};
exports.getHTMLForSwaggerUI = getHTMLForSwaggerUI;
const getNestedRoutes = (basePath, dir) => {
const dirents = (0, fs_1.readdirSync)((0, path_1.join)(basePath, dir), { withFileTypes: true });
const files = dirents.map((dirent) => {
const res = (0, path_1.join)(dir, dirent.name);
return dirent.isDirectory() ? getNestedRoutes(basePath, res) : res;
});
return files.flat();
};
// Generate the OpenAPI paths from the Next.js API routes.
const generatePaths = async ({ config: { appDirPath, apiRoutesPath, openApiJsonPath, openApiYamlPath, swaggerUiPath, deniedPaths, allowedPaths, generatePathsTimeout }, baseUrl }) => {
const filterRoutes = (file) => {
const isRoute = file.endsWith('route.ts');
const isCatchAllRoute = file.includes('[...');
const isOpenApiJsonRoute = file === `${openApiJsonPath?.split('/').at(-1)}/route.ts`;
const isOpenApiYamlRoute = file === `${openApiYamlPath?.split('/').at(-1)}/route.ts`;
const isSwaggerUiRoute = file === `${swaggerUiPath?.split('/').at(-1)}/route.ts`;
if (!isRoute ||
isCatchAllRoute ||
isOpenApiJsonRoute ||
isOpenApiYamlRoute ||
isSwaggerUiRoute) {
return false;
}
else {
return true;
}
};
const filterApiRoutes = (file) => {
const isCatchAllRoute = file.includes('[...');
const isOpenApiJsonRoute = file === `${openApiJsonPath?.split('/').at(-1)}.ts`;
const isOpenApiYamlRoute = file === `${openApiYamlPath?.split('/').at(-1)}.ts`;
const isSwaggerUiRoute = file === `${swaggerUiPath?.split('/').at(-1)}.ts`;
if (isCatchAllRoute ||
isOpenApiJsonRoute ||
isOpenApiYamlRoute ||
isSwaggerUiRoute) {
return false;
}
else {
return true;
}
};
const isWildcardMatch = (pattern, path) => {
const regexPattern = pattern
.split('/')
.map((segment) => segment === '*' ? '[^/]*' : segment === '**' ? '.*' : segment)
.join('/');
const regex = new RegExp(`^${regexPattern}$`);
return regex.test(path);
};
const ignoredPaths = [];
const isAllowedRoute = (path) => {
const isAllowed = allowedPaths?.some((allowedPath) => isWildcardMatch(allowedPath, path));
const isDenied = deniedPaths?.some((deniedPath) => isWildcardMatch(deniedPath, path));
const routeIsAllowed = isAllowed && !isDenied;
if (!routeIsAllowed) {
ignoredPaths.push(path);
}
return routeIsAllowed;
};
let _routes = [];
try {
_routes = appDirPath
? getNestedRoutes((0, path_1.join)(process.cwd(), appDirPath ?? ''), '')
.filter(filterRoutes)
.map((file) => `${appDirPath.split('/app')[1]}/${file}`
.replace('/route.ts', '')
.replace(/\\/g, '/')
.replace('[', '{')
.replace(']', '}'))
.filter(isAllowedRoute)
: [];
}
catch {
if (!global.apiRoutesPathsNotFoundLogged) {
(0, logging_1.warnAboutDirNotFound)({
configName: 'appDirPath',
path: appDirPath ?? ''
});
global.apiRoutesPathsNotFoundLogged = true;
}
}
let apiRoutes = [];
try {
apiRoutes = apiRoutesPath
? getNestedRoutes((0, path_1.join)(process.cwd(), apiRoutesPath ?? ''), '')
.filter(filterApiRoutes)
.map((file) => `/api/${file}`
.replace('/index', '')
.replace(/\\/g, '/')
.replace('[', '{')
.replace(']', '}')
.replace('.ts', ''))
.filter(isAllowedRoute)
: [];
}
catch {
if (!global.apiRoutesPathsNotFoundLogged) {
(0, logging_1.warnAboutDirNotFound)({
configName: 'apiRoutesPath',
path: apiRoutesPath ?? ''
});
global.apiRoutesPathsNotFoundLogged = true;
}
}
if (ignoredPaths.length && !global.ignoredPathsLogged) {
(0, logging_1.logIgnoredPaths)(ignoredPaths);
global.ignoredPathsLogged = true;
}
const routes = [..._routes, ...apiRoutes];
let paths = {};
await Promise.all(routes.map(async (route) => {
const url = `${baseUrl}${route}`;
const controller = new AbortController();
const abortRequest = setTimeout(() => {
controller.abort();
}, generatePathsTimeout);
try {
const res = await fetch(url, {
headers: {
'User-Agent': constants_1.NEXT_REST_FRAMEWORK_USER_AGENT,
'Content-Type': 'application/json',
'x-forwarded-proto': baseUrl.split('://')[0],
'x-forwarded-host': baseUrl.split('://')[1]
},
signal: controller.signal
});
clearTimeout(abortRequest);
const data = await res.json();
const isPathItemObject = (obj) => {
return (!!obj && typeof obj === 'object' && 'nextRestFrameworkPaths' in obj);
};
if (res.status === 200 && isPathItemObject(data)) {
paths = { ...paths, ...data.nextRestFrameworkPaths };
}
}
catch {
// A user defined API route returned an error.
}
}));
const sortedPathKeys = Object.keys(paths).sort();
const sortedPaths = {};
for (const key of sortedPathKeys) {
sortedPaths[key] = paths[key];
}
return sortedPaths;
};
// In prod use the existing openapi.json file - in development mode update it whenever the generated API spec changes.
const getOrCreateOpenApiSpec = async ({ config, baseUrl }) => {
let specFileFound = false;
try {
const data = (0, fs_1.readFileSync)((0, path_1.join)(process.cwd(), 'openapi.json'));
global.openApiSpec = JSON.parse(data.toString());
specFileFound = true;
}
catch { }
if (process.env.NODE_ENV !== 'production') {
const paths = await generatePaths({ config, baseUrl });
const newSpec = {
...config.openApiSpecOverrides,
openapi: constants_1.OPEN_API_VERSION,
paths: (0, lodash_1.merge)({}, config.openApiSpecOverrides?.paths, paths)
};
if (!(0, lodash_1.isEqualWith)(global.openApiSpec, newSpec)) {
if (!specFileFound) {
console.info(chalk_1.default.yellowBright('No API spec found, generating openapi.json'));
}
else {
console.info(chalk_1.default.yellowBright('API spec changed, regenerating openapi.json'));
}
(0, fs_1.writeFileSync)((0, path_1.join)(process.cwd(), 'openapi.json'), JSON.stringify(newSpec, null, 2) + '\n', null);
if (!global.apiSpecGeneratedLogged) {
console.info(chalk_1.default.green('API spec generated successfully!'));
}
global.openApiSpec = newSpec;
}
else if (!global.apiSpecGeneratedLogged) {
console.info(chalk_1.default.green('API spec up to date, skipping generation.'));
}
global.apiSpecGeneratedLogged = true;
}
return global.openApiSpec;
};
exports.getOrCreateOpenApiSpec = getOrCreateOpenApiSpec;
exports.defaultResponse = {
description: constants_1.DEFAULT_ERRORS.unexpectedError,
content: {
'application/json': {
schema: {
type: 'object',
properties: {
message: { type: 'string' }
}
}
}
}
};
const isValidMethod = (x) => Object.values(constants_1.ValidMethod).includes(x);
exports.isValidMethod = isValidMethod;
const getPathsFromMethodHandlers = ({ config, methodHandlers, route }) => {
const { openApiSpecOverrides } = methodHandlers;
const paths = {};
paths[route] = {
...openApiSpecOverrides
};
Object.keys(methodHandlers)
.filter(exports.isValidMethod)
.forEach((_method) => {
const { openApiSpecOverrides, tags, input, output } = methodHandlers[_method];
const method = _method.toLowerCase();
let requestBodyContent = {};
if (input?.body && input?.contentType) {
const schema = (0, schemas_1.getJsonSchema)({ schema: input.body });
requestBodyContent = {
[input.contentType]: {
schema
}
};
}
const generatedResponses = output?.reduce((obj, { status, contentType, schema }) => {
const responseSchema = (0, schemas_1.getJsonSchema)({ schema });
return Object.assign(obj, {
[status]: {
content: {
[contentType]: {
schema: responseSchema
}
}
}
});
}, {});
const generatedOperationObject = {
requestBody: {
content: requestBodyContent
},
responses: {
...generatedResponses,
default: exports.defaultResponse
}
};
if (tags) {
generatedOperationObject.tags = tags;
}
const pathParameters = route.match(/{([^}]+)}/g);
if (pathParameters) {
generatedOperationObject.parameters = pathParameters.map((param) => ({
name: param.replace(/[{}]/g, ''),
in: 'path',
required: true
}));
}
if (input?.query) {
generatedOperationObject.parameters = [
...(generatedOperationObject.parameters ?? []),
...(0, schemas_1.getSchemaKeys)({
schema: input.query
}).map((key) => ({
name: key,
in: 'query'
}))
];
}
paths[route] = {
...paths[route],
[method]: (0, lodash_1.merge)(generatedOperationObject, openApiSpecOverrides)
};
});
return paths;
};
exports.getPathsFromMethodHandlers = getPathsFromMethodHandlers;