Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Expose JavaScript API for generating OpenAPI schema #160

Closed
wants to merge 5 commits into from
Closed
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 30 additions & 0 deletions apps/example/openapi-generate.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import {
compileEndpoints,
syncOpenApiSpecFromBuild,
clearTmpFolder
} from 'next-rest-framework/generate';

async function main() {
try {
await compileEndpoints({
buildOptions: {
external: ['jsdom']
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added this + example route below for demonstrating the issue + testing.

}
});

console.info('Generating OpenAPI spec...');

await syncOpenApiSpecFromBuild({});
} catch (e) {
console.error(e);
process.exit(1);
}

await clearTmpFolder();
}

main()
.then(() => {
console.log('Completed building OpenAPI schema from custom script');
})
.catch(console.error);
6 changes: 5 additions & 1 deletion apps/example/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -7,17 +7,21 @@
"build": "pnpm prebuild && next build",
"start": "next start",
"generate": "pnpm prebuild && next-rest-framework generate",
"generate-custom-script": "pnpm prebuild && ts-node ./openapi-generate.ts",
"validate": "pnpm prebuild && next-rest-framework validate",
"lint": "tsc && next lint"
},
"dependencies": {
"jsdom": "^24.0.0",
"next-rest-framework": "workspace:*",
"zod-form-data": "2.0.2"
},
"devDependencies": {
"@types/jsdom": "^21.1.6",
"autoprefixer": "10.0.1",
"eslint-config-next": "14.0.4",
"postcss": "8.4.33",
"tailwindcss": "3.3.0",
"eslint-config-next": "14.0.4"
"ts-node": "10.9.1"
}
}
31 changes: 31 additions & 0 deletions apps/example/public/openapi.json
Original file line number Diff line number Diff line change
Expand Up @@ -526,6 +526,31 @@
}
}
},
"/api/v2/route-with-external-dep": {
"get": {
"operationId": "getDomInfo",
"responses": {
"200": {
"description": "Response for status 200",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/GetDomInfo200ResponseBody"
}
}
}
},
"500": {
"description": "An unknown error occurred, trying again might help.",
"content": {
"application/json": {
"schema": { "$ref": "#/components/schemas/UnexpectedError" }
}
}
}
}
}
},
"/api/v2/route-with-params/{slug}": {
"get": {
"operationId": "getPathParams",
Expand Down Expand Up @@ -978,6 +1003,12 @@
"additionalProperties": false,
"description": "Test form response."
},
"GetDomInfo200ResponseBody": {
"type": "object",
"properties": { "html": { "type": "string" } },
"required": ["html"],
"additionalProperties": false
},
"GetParams200ResponseBody": {
"type": "object",
"properties": {
Expand Down
25 changes: 25 additions & 0 deletions apps/example/src/app/api/v2/route-with-external-dep/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import { TypedNextResponse, route, routeOperation } from 'next-rest-framework';
import { z } from 'zod';
import { JSDOM } from 'jsdom';
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

An example using jsdom that is now marked as external in our custom openapi-generate.ts file.


export const runtime = 'edge';

export const { GET } = route({
getDomInfo: routeOperation({
method: 'GET'
})
.outputs([
{
status: 200,
contentType: 'application/json',
body: z.object({ html: z.string() })
}
])
.handler(() => {
const dom = new JSDOM('<!DOCTYPE html><p>Hello world</p>');

return TypedNextResponse.json({
html: dom.serialize()
});
})
});
6 changes: 6 additions & 0 deletions apps/example/tsconfig.json
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,12 @@
"@/*": ["./src/*"]
}
},
"ts-node": {
"compilerOptions": {
"module": "commonjs",
"target": "ES2022"
}
},
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
"exclude": ["node_modules"]
}
1 change: 1 addition & 0 deletions packages/next-rest-framework/generate.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * from './src/generate';
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This root generate file allows us to import with next-rest-framework/generate instead of exporting directly from the index file, which prevents esbuild from being included in the main bundle. Open to suggestions, but I don't think we want to include esbuild in the main bundle.

9 changes: 6 additions & 3 deletions packages/next-rest-framework/src/cli/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,12 @@

import { Command } from 'commander';
import chalk from 'chalk';
import { clearTmpFolder, compileEndpoints } from './utils';
import { validateOpenApiSpecFromBuild } from './validate';
import { syncOpenApiSpecFromBuild } from './generate';
import {
clearTmpFolder,
compileEndpoints,
validateOpenApiSpecFromBuild,
syncOpenApiSpecFromBuild
} from '../generate';

const program = new Command();

Expand Down
3 changes: 3 additions & 0 deletions packages/next-rest-framework/src/generate/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export { clearTmpFolder, compileEndpoints } from './utils';
export { validateOpenApiSpecFromBuild } from './validate';
export { syncOpenApiSpecFromBuild } from './generate';
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { rm } from 'fs/promises';
import chalk from 'chalk';
import { globSync } from 'fast-glob';
import { build } from 'esbuild';
import { type BuildOptions, build } from 'esbuild';
import { type NrfOasData } from '../shared/paths';
import { type NextRestFrameworkConfig } from '../types';
import { existsSync, readdirSync } from 'fs';
Expand All @@ -24,7 +24,9 @@ export const clearTmpFolder = async () => {
};

// Compile the Next.js routes and API routes to CJS modules with esbuild.
export const compileEndpoints = async () => {
export const compileEndpoints = async ({
buildOptions
}: { buildOptions?: BuildOptions } = {}) => {
await clearTmpFolder();
console.info(chalk.yellowBright('Compiling endpoints...'));
const entryPoints = globSync(['./**/*.ts', '!**/node_modules/**']);
Expand All @@ -36,10 +38,20 @@ export const compileEndpoints = async () => {
format: 'cjs',
platform: 'node',
outdir: NEXT_REST_FRAMEWORK_TEMP_FOLDER_NAME,
outExtension: { '.js': '.cjs' }
outExtension: { '.js': '.cjs' },
...buildOptions,
external: buildExternalDependencies(buildOptions)
});
};

function buildExternalDependencies(buildOptions: BuildOptions = {}): string[] {
// explicitly add the `esbuild` as an external build dependency as recommended
// by esbuild to avoid bundling it in the output.
//
// ref: https://github.com/evanw/esbuild//issues/2698#issuecomment-1325160925
return ['esbuild', ...(buildOptions.external ?? [])];
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Since this is now part of a JS API, we don't want esbuild to consider esbuild in its output. Otherwise, we receive a similar warning as the jsdom warning. See comment above.

Compiling endpoints...
▲ [WARNING] "esbuild" should be marked as external for use with "require.resolve" [require-resolve-not-external]

    ../../node_modules/.pnpm/[email protected]/node_modules/esbuild/lib/main.js:1825:36:
      1825 │   const libMainJS = require.resolve("esbuild");

}

// Traverse the base path and find all nested files.
const getNestedFiles = (basePath: string, dir: string): string[] => {
const dirents = readdirSync(join(basePath, dir), { withFileTypes: true });
Expand Down Expand Up @@ -105,8 +117,7 @@ export const findConfig = async ({ configPath }: { configPath?: string }) => {
route
);

const url = new URL(`file://${filePathToRoute}`).toString();
const res = await import(url).then((mod) => mod.default);
const res = await dynamicallyImportRoute(filePathToRoute);

const handlers: any[] = Object.entries(res)
.filter(([key]) => isValidMethod(key))
Expand Down Expand Up @@ -165,9 +176,7 @@ export const findConfig = async ({ configPath }: { configPath?: string }) => {
route
);

const url = new URL(`file://${filePathToRoute}`).toString();
const res = await import(url).then((mod) => mod.default);

const res = await dynamicallyImportRoute(filePathToRoute);
const _config = res.default._nextRestFrameworkConfig;

if (_config) {
Expand Down Expand Up @@ -227,6 +236,17 @@ export const findConfig = async ({ configPath }: { configPath?: string }) => {
return config;
};

async function dynamicallyImportRoute(filePathToRoute: string) {
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This was needed to work with ts-node. Importing using the file:// path wasn't resolving the module correctly. Open to suggestion here as well.

try {
// First try to import the file as a URL and assume it has a default export.
// If that fails, try to import the module directly at its absolute path.
const url = new URL(`file://${filePathToRoute}`).toString();
return await import(url).then((mod) => mod.default);
} catch (err) {}

return await import(filePathToRoute).then((mod) => mod);
}

// Generate the OpenAPI paths from the Next.js routes and API routes from the build output.
export const generatePathsFromBuild = async ({
config
Expand Down Expand Up @@ -352,8 +372,7 @@ export const generatePathsFromBuild = async ({
route
);

const url = new URL(`file://${filePathToRoute}`).toString();
const res = await import(url).then((mod) => mod.default);
const res = await dynamicallyImportRoute(filePathToRoute);

const handlers: any[] = Object.entries(res)
.filter(([key]) => isValidMethod(key))
Expand Down Expand Up @@ -404,8 +423,7 @@ export const generatePathsFromBuild = async ({
apiRoute
);

const url = new URL(`file://${filePathToRoute}`).toString();
const res = await import(url).then((mod) => mod.default);
const res = await dynamicallyImportRoute(filePathToRoute);

const data = await res.default._getPathsForRoute(
getApiRouteName(apiRoute)
Expand Down
2 changes: 1 addition & 1 deletion packages/next-rest-framework/tests/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ import { type NextApiRequest, type NextApiResponse } from 'next/types';
import { type BaseParams, type Modify } from '../src/types';
import { type OpenAPIV3_1 } from 'openapi-types';
import { getJsonSchema } from '../src/shared';
import { OPEN_API_VERSION } from '../src/cli/constants';
import { OPEN_API_VERSION } from '../src/generate/constants';
import qs from 'qs';

export const createMockRouteRequest = <Body, Query>({
Expand Down
3 changes: 2 additions & 1 deletion packages/next-rest-framework/tsup.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,8 @@ export default defineConfig({
'src/index.ts',
'src/constants.ts',
'src/client/index.ts',
'src/cli/index.ts'
'src/cli/index.ts',
'src/generate.ts'
],
bundle: true,
esbuildPlugins: [uaParserDirnamePlugin()],
Expand Down
Loading