From 63575ae38293fdce6c10b7a62cc5b7032ecf8f90 Mon Sep 17 00:00:00 2001 From: Samat Jorobekov Date: Mon, 27 Apr 2026 14:00:49 +0300 Subject: [PATCH 1/2] feat: add getDocsHandler() for mounting docs page manually --- README.md | 48 ++++++++++++++++------- src/example/index.ts | 5 ++- src/openapi-registry.ts | 74 ++++++++++++++++++++--------------- src/tests/integration.test.ts | 44 ++++++++++++++++++++- src/types.ts | 1 + 5 files changed, 125 insertions(+), 47 deletions(-) diff --git a/README.md b/README.md index bc1bae6..89f2283 100644 --- a/README.md +++ b/README.md @@ -70,20 +70,21 @@ app.run(); // Open http://localhost:3030/api/docs `createOpenApiRegistry(config?: OpenApiRegistryConfig)` tunes both the generated schema and the Swagger UI mount. Key options: -| Field | Default | Description | -| ----------------- | -------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `title` | `"API Documentation"` | Top-level title shown in the UI. | -| `version` | `"1.0.0"` | Populates `info.version`. | -| `description` | `"Generated API documentation"` | Short blurb under the title. | -| `contact` | `undefined` | `{name, email, url}` for ownership info. | -| `license` | `undefined` | `{name, url}` displayed in the footer. | -| `servers` | `[ { url: 'http://localhost:3030' } ]` | Servers array for the spec dropdown. | -| `swaggerUi` | `{}` | Passed straight to `swagger-ui-express` (`customCss`, `explorer`, themes, …). | -| `enabled` | `true` | Convenience flag—skip calling `registerRoutes` if you want to hide docs. | -| `path` | `'/api/docs'` | Mount path for Swagger UI; value is used as-is. | -| `swaggerJsonPath` | `undefined` | Path relative to mount path where OpenAPI schema is served as JSON. When set, Swagger UI loads the schema from this endpoint instead of embedding it directly. | -| `authPolicy` | `AuthPolicy.disabled` | Controls authentication for the Swagger UI page itself. | -| `securitySchemes` | `undefined` | OpenAPI Security Schemes | +| Field | Default | Description | +| ----------------- | -------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `title` | `"API Documentation"` | Top-level title shown in the UI. | +| `version` | `"1.0.0"` | Populates `info.version`. | +| `description` | `"Generated API documentation"` | Short blurb under the title. | +| `contact` | `undefined` | `{name, email, url}` for ownership info. | +| `license` | `undefined` | `{name, url}` displayed in the footer. | +| `servers` | `[ { url: 'http://localhost:3030' } ]` | Servers array for the spec dropdown. | +| `swaggerUi` | `{}` | Passed straight to `swagger-ui-express` (`customCss`, `explorer`, themes, …). | +| `enabled` | `true` | Convenience flag—skip calling `registerRoutes` if you want to hide docs. | +| `path` | `'/api/docs'` | Mount path for Swagger UI; value is used as-is. Ignored when `skipMount` is `true` (see below). | +| `skipMount` | `false` | When `true`, `registerRoutes` does **not** register a `MOUNT` route for the docs. Mount the handler yourself with `getDocsHandler()` (see [below](#manual-mounting)) so the docs path does not run ExpressKit’s per-route middleware (`appBeforeAuthMiddleware`, `appAfterAuthMiddleware`, auth, CSRF, CSP, cache headers). | +| `swaggerJsonPath` | `undefined` | Path relative to mount path where OpenAPI schema is served as JSON. When set, Swagger UI loads the schema from this endpoint instead of embedding it directly. | +| `authPolicy` | `AuthPolicy.disabled` | Controls authentication for the Swagger UI page itself. | +| `securitySchemes` | `undefined` | OpenAPI Security Schemes | Usage example: @@ -120,6 +121,7 @@ const {registerRoutes} = createOpenApiRegistry({ ``` - [Basic Usage](#basic-usage) +- [Manual mounting](#manual-mounting) - [Available Security Scheme Types](#available-security-scheme-types) - [Custom Security Schemes](#custom-security-schemes) - [Styling Swagger UI](#styling-swagger-ui) @@ -287,6 +289,24 @@ const routes = { registerRoutes(routes, nodekit); ``` +## Manual mounting + +A `MOUNT` doc route runs [ExpressKit’s per-route middleware](https://github.com/gravity-ui/expresskit) (e.g. `appBeforeAuthMiddleware`, auth, CSRF, CSP). To skip that, use `skipMount: true` and mount `getDocsHandler()` on `app.express` manually; + +```typescript +import {ExpressKit} from '@gravity-ui/expresskit'; +import {createOpenApiRegistry} from '@gravity-ui/expresskit-api'; + +const {registerRoutes, getDocsHandler} = createOpenApiRegistry({ + title: 'Super API', + skipMount: true, +}); +const app = new ExpressKit(nodekit, registerRoutes(routes, nodekit)); + +app.express.use('/api/docs', getDocsHandler()); +app.run(); +``` + ## Styling Swagger UI Customize the Swagger UI via `swaggerUi` options or by bringing in theme helpers such as [`swagger-themes`](https://www.npmjs.com/package/swagger-themes): diff --git a/src/example/index.ts b/src/example/index.ts index 922082b..a55b0f1 100644 --- a/src/example/index.ts +++ b/src/example/index.ts @@ -6,12 +6,13 @@ import {SwaggerTheme, SwaggerThemeNameEnum} from 'swagger-themes'; import {routes} from './routes'; const theme = new SwaggerTheme(); -const {registerRoutes} = createOpenApiRegistry({ +const {registerRoutes, getDocsHandler} = createOpenApiRegistry({ title: 'Super API', swaggerUi: { explorer: true, customCss: theme.getBuffer(SwaggerThemeNameEnum.DARK), }, + skipMount: true, }); const nodekit = new NodeKit({ @@ -25,6 +26,8 @@ const nodekit = new NodeKit({ const app = new ExpressKit(nodekit, registerRoutes(routes, nodekit)); +app.express.use('/api/docs', getDocsHandler()); + // Only run the app if this file is executed directly (not when imported for tests) if (require.main === module) { app.run(); diff --git a/src/openapi-registry.ts b/src/openapi-registry.ts index 20c0f6b..ded7239 100644 --- a/src/openapi-registry.ts +++ b/src/openapi-registry.ts @@ -18,6 +18,7 @@ import { getContract, getErrorContract, } from '@gravity-ui/expresskit'; +import {Router as createRouter} from 'express'; import type {RequestHandler} from 'express'; import {z} from 'zod'; import {getSecurityScheme} from './security-schemas'; @@ -403,51 +404,62 @@ export function createOpenApiRegistry(config: OpenApiRegistryConfig) { }); const mountPath = config.path ?? '/api/docs'; - const options = config.swaggerUi; - const swaggerJsonPath = config.swaggerJsonPath; + + if (config.skipMount) { + return {...routes}; + } return { ...routes, [`MOUNT ${mountPath}`]: { authPolicy: config.authPolicy ?? AuthPolicy.disabled, - handler: ({router}: Parameters[0]) => { - const schema = getOpenApiSchema(); - - if (swaggerJsonPath) { - router.get(swaggerJsonPath, (_req, res) => { - res.json(schema); - }); - - const relativePath = swaggerJsonPath.startsWith('/') - ? swaggerJsonPath.slice(1) - : swaggerJsonPath; - - const asyncOptions = { - ...options, - swaggerOptions: { - ...options?.swaggerOptions, - url: relativePath, - }, - }; - - router.use( - '/', - serveFiles(undefined, asyncOptions), - setup(null, asyncOptions), - ); - } else { - router.use('/', serveFiles(schema), setup(schema, options)); - } - }, + handler: (() => buildDocsRouter()) as AppMountHandler, }, }; } + function buildDocsRouter(): ReturnType { + const schema = getOpenApiSchema(); + const options = config.swaggerUi; + const swaggerJsonPath = config.swaggerJsonPath; + const router = createRouter(); + + if (swaggerJsonPath) { + router.get(swaggerJsonPath, (_req, res) => { + res.json(schema); + }); + + const relativePath = swaggerJsonPath.startsWith('/') + ? swaggerJsonPath.slice(1) + : swaggerJsonPath; + + const asyncOptions = { + ...options, + swaggerOptions: { + ...options?.swaggerOptions, + url: relativePath, + }, + }; + + router.use('/', serveFiles(undefined, asyncOptions), setup(null, asyncOptions)); + } else { + router.use('/', serveFiles(schema), setup(schema, options)); + } + + return router; + } + + function getDocsHandler(): RequestHandler { + return buildDocsRouter(); + } + return { registerSecurityScheme, getOpenApiSchema, + getDocsHandler, + reset, registerErrorHandler, diff --git a/src/tests/integration.test.ts b/src/tests/integration.test.ts index dd35ca4..b76d517 100644 --- a/src/tests/integration.test.ts +++ b/src/tests/integration.test.ts @@ -1,5 +1,5 @@ import request from 'supertest'; -import type {Express} from 'express'; +import type {Express, NextFunction, Request, Response} from 'express'; import {ExpressKit, RouteContract, withContract} from '@gravity-ui/expresskit'; import {NodeKit} from '@gravity-ui/nodekit'; import {createOpenApiRegistry} from '../openapi-registry'; @@ -108,4 +108,46 @@ describe('ExpressKit Integration Tests', () => { expect(uiResponse.headers['content-type']).toMatch(/text\/html/); }); }); + + describe('skipMount: true', () => { + let expressApp: Express; + const beforeAuthSpy = jest.fn((_req: Request, _res: Response, next: NextFunction) => + next(), + ); + + beforeAll(() => { + const nodekitWithMiddleware = new NodeKit({ + config: { + appName: 'test-app-skip-mount', + appLoggingDestination: {write: () => {}}, + appBeforeAuthMiddleware: [beforeAuthSpy], + }, + }); + + const registry = createOpenApiRegistry({skipMount: true, title: 'Skip Mount Test'}); + const testApp = new ExpressKit( + nodekitWithMiddleware, + registry.registerRoutes(routes, nodekitWithMiddleware), + ); + testApp.express.use('/api/docs', registry.getDocsHandler()); + expressApp = testApp.express; + }); + + beforeEach(() => { + beforeAuthSpy.mockClear(); + }); + + it('should serve docs UI at /api/docs without invoking appBeforeAuthMiddleware', async () => { + const response = await request(expressApp).get('/api/docs').redirects(1).expect(200); + + expect(response.headers['content-type']).toMatch(/text\/html/); + expect(beforeAuthSpy).not.toHaveBeenCalled(); + }); + + it('should still invoke appBeforeAuthMiddleware for normal routes', async () => { + await request(expressApp).get('/test').expect(200); + + expect(beforeAuthSpy).toHaveBeenCalled(); + }); + }); }); diff --git a/src/types.ts b/src/types.ts index e2d343f..8771e36 100644 --- a/src/types.ts +++ b/src/types.ts @@ -45,6 +45,7 @@ export interface SecuritySchemeObject { export interface OpenApiRegistryConfig { enabled?: boolean; + skipMount?: boolean; path?: string; version?: string; title?: string; From fb36d4a59c123d835bbe86c288d4dca274c7b962 Mon Sep 17 00:00:00 2001 From: Samat Jorobekov Date: Mon, 27 Apr 2026 14:09:22 +0300 Subject: [PATCH 2/2] feat: cleanup --- src/openapi-registry.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/openapi-registry.ts b/src/openapi-registry.ts index ded7239..fdb1775 100644 --- a/src/openapi-registry.ts +++ b/src/openapi-registry.ts @@ -406,7 +406,7 @@ export function createOpenApiRegistry(config: OpenApiRegistryConfig) { const mountPath = config.path ?? '/api/docs'; if (config.skipMount) { - return {...routes}; + return routes; } return {