UNPKG

next-rest-framework

Version:

Next REST Framework - write type-safe, self-documenting REST APIs in Next.js

378 lines (373 loc) 15.6 kB
"use strict"; 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;