From aa2d6c33a0f13aeaeeff5a0dc2dd399c3ee97b86 Mon Sep 17 00:00:00 2001 From: Samat Jorobekov Date: Tue, 17 Mar 2026 17:56:02 +0300 Subject: [PATCH 1/3] feat: allow swagger operation customization via transformOperation --- README.md | 130 +++++++++++-------------- src/index.ts | 2 +- src/openapi-registry.ts | 110 +++++++++++++-------- src/tests/openapi-registry.test.ts | 148 +++++++++++++++++++++++++++++ src/types.ts | 22 ++++- 5 files changed, 292 insertions(+), 120 deletions(-) diff --git a/README.md b/README.md index 969311c..0f11dde 100644 --- a/README.md +++ b/README.md @@ -12,38 +12,14 @@ This package provides OpenAPI/Swagger integration for [ExpressKit](https://githu 2. Wrap your routes before passing them to `ExpressKit`: - **Simple approach** (without global auth handlers): - ```typescript -import { - ExpressKit, - withContract, - AppRoutes, - RouteContract, - AuthPolicy, -} from '@gravity-ui/expresskit'; +import {ExpressKit, withContract, AppRoutes, RouteContract} from '@gravity-ui/expresskit'; import {NodeKit} from '@gravity-ui/nodekit'; import {z} from 'zod'; -import {createOpenApiRegistry, bearerAuth, apiKeyAuth} from '@gravity-ui/expresskit-api'; +import {createOpenApiRegistry} from '@gravity-ui/expresskit-api'; const {registerRoutes} = createOpenApiRegistry({title: 'Super API'}); -const apiKeyHandler = apiKeyAuth( - 'apiKeyAuth', // scheme name - 'header', // location: 'header', 'query', or 'cookie' - 'X-API-Key', // parameter name - ['read:items'], // optional scopes -)(function authenticate(req, res, next) { - const apiKey = req.headers['x-api-key']; - - if (apiKey !== 'valid_api_key') { - res.status(401).json({error: 'Unauthorized: Invalid API key'}); - return; - } - - next(); -}); - const CreateItemConfig = { operationId: 'createItem', summary: 'Create a new item', @@ -80,57 +56,9 @@ const createItemHandler = withContract(CreateItemConfig)(async (req, res) => { export const routes: AppRoutes = { 'POST /items': { handler: createItemHandler, - authHandler: apiKeyHandler, - authPolicy: AuthPolicy.required, - }, -}; - -const app = new ExpressKit(nodekit, registerRoutes(routes, nodekit)); - -app.run(); // Open http://localhost:3030/api/docs -``` - -**Using setup parameter** (with global auth handlers support): - -```typescript -import { - ExpressKit, - withContract, - AppRoutes, - RouteContract, - AuthPolicy, -} from '@gravity-ui/expresskit'; -import {NodeKit} from '@gravity-ui/nodekit'; -import {z} from 'zod'; -import {createOpenApiRegistry, bearerAuth} from '@gravity-ui/expresskit-api'; - -const {registerRoutes} = createOpenApiRegistry({title: 'Super API'}); - -// Global auth handler configured in NodeKit -const globalAuthHandler = bearerAuth('jwtAuth')(function authenticate(req, res, next) { - const token = req.headers.authorization?.replace('Bearer ', ''); - if (token !== 'valid_token') { - res.status(401).json({error: 'Unauthorized'}); - return; - } - next(); -}); - -const nodekit = new NodeKit({ - config: { - appAuthHandler: globalAuthHandler, - appAuthPolicy: AuthPolicy.required, - }, -}); - -const routes: AppRoutes = { - 'POST /items': { - handler: createItemHandler, - // No authHandler specified - will use global appAuthHandler }, }; -// Use setup parameter to access nodekit context const app = new ExpressKit(nodekit, registerRoutes(routes, nodekit)); app.run(); // Open http://localhost:3030/api/docs @@ -195,19 +123,19 @@ ExpressKit supports automatic generation of security requirements in OpenAPI doc ### Features -- **HOC Wrappers**: `withSecurityScheme` allows you to add security metadata to any authentication handler. +- **HOC Wrappers** allows you to add security metadata to any authentication handler. - **Predefined Security Schemes**: Ready-to-use wrappers for common authentication types: - `bearerAuth`: JWT/Bearer token authentication - `apiKeyAuth`: API key authentication - `basicAuth`: Basic authentication - `oauth2Auth`: OAuth2 authentication - `oidcAuth`: OpenID Connect authentication -- **Automatic Documentation**: Security requirements are automatically included in OpenAPI documentation. +- **Automatic Documentation**: Security requirements are automatically included in OpenAPI documentation. **Schemas are supported for both per-route `authHandler`s and global `appAuthHandler`s configured via NodeKit.** ### Basic Usage ```typescript -import {bearerAuth} from 'expresskit'; +import {bearerAuth} from 'expresskit-api'; import jwt from 'jsonwebtoken'; // Add OpenAPI security scheme metadata to your auth handler @@ -302,6 +230,54 @@ const customAuthHandler = withSecurityScheme({ })(authFunction); ``` +### Customizing the OpenAPI operation + +You can customize the generated OpenAPI operation using the `transformOperation` callback in `createOpenApiRegistry`. + +This allows you to patch operations based on the route path, method, or route description properties. This is especially useful if you are using custom authentication handlers that aren't wrapped with `withSecurityScheme`, or if you want to apply global tags. + +```typescript +const {registerRoutes, registerSecurityScheme} = createOpenApiRegistry({ + title: 'My API', + transformOperation: (operation, {path, route}) => { + // Patch by path + if (path === '/items') { + return { + ...operation, + security: [{customApiKey: []}], + }; + } + + // Patch by route property + if (route.authPolicy === 'disabled') { + return { + ...operation, + description: `(Public) ${operation.description || ''}`, + }; + } + + return operation; + }, +}); + +// 1. Register a custom security scheme globally +registerSecurityScheme('customApiKey', { + type: 'apiKey', + in: 'header', + name: 'X-API-Key', +}); + +const routes = { + 'POST /items': { + handler: createItemHandler, + authHandler: customAuthMiddleware, // Not wrapped with withSecurityScheme + }, +}; + +// 2. Register routes +registerRoutes(routes, nodekit); +``` + ## 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/index.ts b/src/index.ts index 1441431..18c281e 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,4 +1,4 @@ import './types'; -export {createOpenApiRegistry} from './openapi-registry'; +export {createOpenApiRegistry, type OpenApiRegistry} from './openapi-registry'; export {bearerAuth, apiKeyAuth, basicAuth, oauth2Auth, oidcAuth} from './security-schemas'; diff --git a/src/openapi-registry.ts b/src/openapi-registry.ts index b8ee0a0..b5a5d4c 100644 --- a/src/openapi-registry.ts +++ b/src/openapi-registry.ts @@ -1,4 +1,9 @@ -import type {OpenApiRegistryConfig, OpenApiSchemaObject, SecuritySchemeObject} from './types'; +import type { + OpenApiOperation, + OpenApiRegistryConfig, + OpenApiSchemaObject, + SecuritySchemeObject, +} from './types'; import {serveFiles, setup} from 'swagger-ui-express'; import { @@ -6,7 +11,6 @@ import { AppMiddleware, AppMountHandler, AppRouteDescription, - AppRouteHandler, AppRoutes, AuthPolicy, RouteContract, @@ -159,15 +163,7 @@ export function createOpenApiRegistry(config: OpenApiRegistryConfig) { return openApiSchema; } - function registerRoute( - method: HttpMethod, - routePath: string, - routeHandler: AppRouteHandler, - authHandler?: AppMiddleware | RequestHandler, - ): void { - const apiConfig = getContract(routeHandler); - if (!apiConfig) return; - + function getOperationSecurity(authHandler?: AppMiddleware | RequestHandler) { const security = []; if (authHandler) { const securityScheme = getSecurityScheme(authHandler); @@ -178,33 +174,10 @@ export function createOpenApiRegistry(config: OpenApiRegistryConfig) { }); } } + return security; + } - // Convert Express path to OpenAPI path - const openApiPath = routePath.replace(/\/:([^/]+)/g, '/{$1}'); - - const pathItem = openApiSchema.paths[openApiPath] || {}; - const operation: Record = { - parameters: [], - responses: {}, - }; - - if ('summary' in apiConfig && apiConfig.summary) { - operation.summary = apiConfig.summary; - } - if ('description' in apiConfig && apiConfig.description) { - operation.description = apiConfig.description; - } - if ('tags' in apiConfig && apiConfig.tags) { - operation.tags = apiConfig.tags; - } - if ('operationId' in apiConfig && apiConfig.operationId) { - operation.operationId = apiConfig.operationId; - } - - if (security.length > 0) { - operation.security = security; - } - + function getOperationParameters(apiConfig: RouteContract) { const parameters = [] as Record[]; if (apiConfig.request?.params) { @@ -218,9 +191,51 @@ export function createOpenApiRegistry(config: OpenApiRegistryConfig) { if (apiConfig.request?.headers) { parameters.push(...createParameters('header', apiConfig.request.headers)); } + return parameters; + } + + function registerRoute( + method: HttpMethod, + routePath: string, + description: AppRouteDescription, + authHandler?: AppMiddleware | RequestHandler, + transformOperation?: ( + operation: OpenApiOperation, + context: { + method: string; + path: string; + route: AppRouteDescription; + }, + ) => OpenApiOperation, + ): void { + const routeHandler = description.handler; + const apiConfig = getContract(routeHandler); + if (!apiConfig) return; + + // Convert Express path to OpenAPI path + const openApiPath = routePath.replace(/\/:([^/]+)/g, '/{$1}'); - operation.parameters = parameters; + const pathItem = openApiSchema.paths[openApiPath] || {}; + const operation: Record = { + parameters: getOperationParameters(apiConfig), + responses: createResponses(apiConfig.response), + }; + // Add metadata + if ('summary' in apiConfig && apiConfig.summary) operation.summary = apiConfig.summary; + if ('description' in apiConfig && apiConfig.description) + operation.description = apiConfig.description; + if ('tags' in apiConfig && apiConfig.tags) operation.tags = apiConfig.tags; + if ('operationId' in apiConfig && apiConfig.operationId) + operation.operationId = apiConfig.operationId; + + // Add security + const security = getOperationSecurity(authHandler); + if (security.length > 0) { + operation.security = security; + } + + // Add request body if (['post', 'put', 'patch'].includes(method.toLowerCase()) && apiConfig.request?.body) { operation.requestBody = createRequestBody( apiConfig.request.body, @@ -228,9 +243,16 @@ export function createOpenApiRegistry(config: OpenApiRegistryConfig) { ); } - operation.responses = createResponses(apiConfig.response); + const finalOperation = transformOperation + ? transformOperation(operation, { + method: method.toLowerCase(), + path: openApiPath, + route: description, + }) + : operation; + + pathItem[method.toLowerCase()] = finalOperation; - pathItem[method.toLowerCase()] = operation; openApiSchema.paths[openApiPath] = pathItem; } @@ -362,7 +384,13 @@ export function createOpenApiRegistry(config: OpenApiRegistryConfig) { ? ctx.config.appAuthHandler : undefined); - registerRoute(methodLower as HttpMethod, routePath, description.handler, authHandler); + registerRoute( + methodLower as HttpMethod, + routePath, + description, + authHandler, + config.transformOperation, + ); }); const mountPath = config.path ?? '/api/docs'; diff --git a/src/tests/openapi-registry.test.ts b/src/tests/openapi-registry.test.ts index facf9fd..c45a4ad 100644 --- a/src/tests/openapi-registry.test.ts +++ b/src/tests/openapi-registry.test.ts @@ -784,4 +784,152 @@ describe('openapi-registry', () => { expect(schema.components?.securitySchemes).toEqual({}); }); }); + + describe('registerRoutes with transformOperation', () => { + let nodekit: NodeKit; + + beforeEach(() => { + nodekit = new NodeKit(); + }); + + it('should patch operation by path', () => { + const {registerRoutes, getOpenApiSchema, registerSecurityScheme} = + createOpenApiRegistry({ + title: 'Test API', + transformOperation: (op, {path}) => { + if (path === '/legacy/test') { + return { + ...op, + security: [{legacyApiKey: []}], + }; + } + return op; + }, + }); + + registerSecurityScheme('legacyApiKey', { + type: 'apiKey', + in: 'header', + name: 'X-API-Key', + }); + + const handler = withContract({ + operationId: 'test', + request: {}, + response: {content: {200: z.object({})}}, + })(async (_req, res) => { + res.sendTyped(200, {}); + }); + + const routes = { + 'GET /api/test': handler, + }; + + registerRoutes(routes, nodekit); + + const schema = getOpenApiSchema(); + const operation = schema.paths['/api/test'].get as Record; + expect(operation.security).toEqual([{legacyApiKey: []}]); + }); + + it('should patch operation by description property', () => { + const {registerRoutes, getOpenApiSchema} = createOpenApiRegistry({ + title: 'Test API', + transformOperation: (op, {route}) => { + if (route.authPolicy === AuthPolicy.disabled) { + return { + ...op, + description: 'Public route', + }; + } + return op; + }, + }); + + const handler = withContract({ + operationId: 'test', + request: {}, + response: {content: {200: z.object({})}}, + })(async (_req, res) => { + res.sendTyped(200, {}); + }); + + const routes = { + 'GET /test': { + handler, + authPolicy: AuthPolicy.disabled, + }, + }; + + registerRoutes(routes, nodekit); + + const schema = getOpenApiSchema(); + const operation = schema.paths['/test'].get as Record; + expect(operation.description).toBe('Public route'); + }); + + it('should apply global patch to all routes', () => { + const {registerRoutes, getOpenApiSchema} = createOpenApiRegistry({ + title: 'Test API', + transformOperation: (op) => ({ + ...op, + tags: [...(op.tags || []), 'GlobalTag'], + }), + }); + + const handler = withContract({ + operationId: 'test', + request: {}, + response: {content: {200: z.object({})}}, + })(async (_req, res) => { + res.sendTyped(200, {}); + }); + + const routes = { + 'GET /test1': handler, + 'POST /test2': handler, + }; + + registerRoutes(routes, nodekit); + + const schema = getOpenApiSchema(); + const op1 = schema.paths['/test1'].get as Record; + const op2 = schema.paths['/test2'].post as Record; + + expect(op1.tags).toEqual(['GlobalTag']); + expect(op2.tags).toEqual(['GlobalTag']); + }); + + it('should provide correct context to transformer', () => { + const transformerSpy = jest.fn((op) => op); + + const {registerRoutes} = createOpenApiRegistry({ + title: 'Test API', + transformOperation: transformerSpy, + }); + + const handler = withContract({ + operationId: 'test', + request: {}, + response: {content: {200: z.object({})}}, + })(async (_req, res) => { + res.sendTyped(200, {}); + }); + + const routes = { + 'GET /items/:id': handler, + }; + + registerRoutes(routes, nodekit); + + expect(transformerSpy).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ + method: 'get', + path: '/items/{id}', + route: expect.objectContaining({handler}), + }), + ); + }); + }); }); diff --git a/src/types.ts b/src/types.ts index e01cac0..e9cce83 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,5 +1,5 @@ import type {SwaggerUiOptions} from 'swagger-ui-express'; -import '@gravity-ui/expresskit'; +import type {AppRouteDescription} from '@gravity-ui/expresskit'; // OpenAPI Security Scheme Object types export interface SecuritySchemeObject { @@ -64,6 +64,26 @@ export interface OpenApiRegistryConfig { }[]; swaggerUi?: SwaggerUiOptions; swaggerJsonPath?: string; + transformOperation?: ( + operation: OpenApiOperation, + context: { + method: string; + path: string; + route: AppRouteDescription; + }, + ) => OpenApiOperation; +} + +export interface OpenApiOperation { + tags?: string[]; + summary?: string; + description?: string; + operationId?: string; + parameters?: Record[]; + requestBody?: Record; + responses?: Record; + security?: Record[]; + [key: string]: unknown; } export interface OpenApiSchemaObject { From 59f97709c38cdab68e7466c26f52d51574887184 Mon Sep 17 00:00:00 2001 From: Samat Jorobekov Date: Tue, 17 Mar 2026 17:58:33 +0300 Subject: [PATCH 2/3] feat: fixes --- src/tests/openapi-registry.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/tests/openapi-registry.test.ts b/src/tests/openapi-registry.test.ts index c45a4ad..b73b7fb 100644 --- a/src/tests/openapi-registry.test.ts +++ b/src/tests/openapi-registry.test.ts @@ -797,7 +797,7 @@ describe('openapi-registry', () => { createOpenApiRegistry({ title: 'Test API', transformOperation: (op, {path}) => { - if (path === '/legacy/test') { + if (path === '/api/test') { return { ...op, security: [{legacyApiKey: []}], From b69e7b2f2ccecd2033cfd772ddc85bcbe82853ab Mon Sep 17 00:00:00 2001 From: Samat Jorobekov Date: Wed, 18 Mar 2026 11:58:24 +0300 Subject: [PATCH 3/3] feat: fixes --- README.md | 6 +++--- src/index.ts | 8 ++++++++ src/openapi-registry.ts | 17 ++++++++++------- src/types.ts | 6 ++++-- 4 files changed, 25 insertions(+), 12 deletions(-) diff --git a/README.md b/README.md index 0f11dde..3e81099 100644 --- a/README.md +++ b/README.md @@ -123,7 +123,7 @@ ExpressKit supports automatic generation of security requirements in OpenAPI doc ### Features -- **HOC Wrappers** allows you to add security metadata to any authentication handler. +- **HOC Wrappers** allow you to add security metadata to any authentication handler. - **Predefined Security Schemes**: Ready-to-use wrappers for common authentication types: - `bearerAuth`: JWT/Bearer token authentication - `apiKeyAuth`: API key authentication @@ -135,7 +135,7 @@ ExpressKit supports automatic generation of security requirements in OpenAPI doc ### Basic Usage ```typescript -import {bearerAuth} from 'expresskit-api'; +import {bearerAuth} from '@gravity-ui/expresskit-api'; import jwt from 'jsonwebtoken'; // Add OpenAPI security scheme metadata to your auth handler @@ -217,7 +217,7 @@ const oidcHandler = oidcAuth( If you need a custom security scheme, you can use the `withSecurityScheme` function directly: ```typescript -import {withSecurityScheme} from 'expresskit'; +import {withSecurityScheme} from '@gravity-ui/expresskit-api'; const customAuthHandler = withSecurityScheme({ name: 'myCustomScheme', diff --git a/src/index.ts b/src/index.ts index 18c281e..45ed896 100644 --- a/src/index.ts +++ b/src/index.ts @@ -2,3 +2,11 @@ import './types'; export {createOpenApiRegistry, type OpenApiRegistry} from './openapi-registry'; export {bearerAuth, apiKeyAuth, basicAuth, oauth2Auth, oidcAuth} from './security-schemas'; + +export type { + OpenApiRegistryConfig, + OpenApiOperation, + SecuritySchemeObject, + OpenApiSchemaObject, + HttpMethod, +} from './types'; diff --git a/src/openapi-registry.ts b/src/openapi-registry.ts index b5a5d4c..a34c09d 100644 --- a/src/openapi-registry.ts +++ b/src/openapi-registry.ts @@ -2,6 +2,7 @@ import type { OpenApiOperation, OpenApiRegistryConfig, OpenApiSchemaObject, + OpenApiSecurityRequirement, SecuritySchemeObject, } from './types'; import {serveFiles, setup} from 'swagger-ui-express'; @@ -163,8 +164,10 @@ export function createOpenApiRegistry(config: OpenApiRegistryConfig) { return openApiSchema; } - function getOperationSecurity(authHandler?: AppMiddleware | RequestHandler) { - const security = []; + function getOperationSecurity( + authHandler?: AppMiddleware | RequestHandler, + ): OpenApiSecurityRequirement[] { + const security: OpenApiSecurityRequirement[] = []; if (authHandler) { const securityScheme = getSecurityScheme(authHandler); if (securityScheme) { @@ -202,7 +205,7 @@ export function createOpenApiRegistry(config: OpenApiRegistryConfig) { transformOperation?: ( operation: OpenApiOperation, context: { - method: string; + method: HttpMethod; path: string; route: AppRouteDescription; }, @@ -216,7 +219,7 @@ export function createOpenApiRegistry(config: OpenApiRegistryConfig) { const openApiPath = routePath.replace(/\/:([^/]+)/g, '/{$1}'); const pathItem = openApiSchema.paths[openApiPath] || {}; - const operation: Record = { + const operation: OpenApiOperation = { parameters: getOperationParameters(apiConfig), responses: createResponses(apiConfig.response), }; @@ -236,7 +239,7 @@ export function createOpenApiRegistry(config: OpenApiRegistryConfig) { } // Add request body - if (['post', 'put', 'patch'].includes(method.toLowerCase()) && apiConfig.request?.body) { + if (['post', 'put', 'patch'].includes(method) && apiConfig.request?.body) { operation.requestBody = createRequestBody( apiConfig.request.body, apiConfig.request.contentType, @@ -245,13 +248,13 @@ export function createOpenApiRegistry(config: OpenApiRegistryConfig) { const finalOperation = transformOperation ? transformOperation(operation, { - method: method.toLowerCase(), + method, path: openApiPath, route: description, }) : operation; - pathItem[method.toLowerCase()] = finalOperation; + pathItem[method] = finalOperation; openApiSchema.paths[openApiPath] = pathItem; } diff --git a/src/types.ts b/src/types.ts index e9cce83..f91473f 100644 --- a/src/types.ts +++ b/src/types.ts @@ -67,13 +67,15 @@ export interface OpenApiRegistryConfig { transformOperation?: ( operation: OpenApiOperation, context: { - method: string; + method: HttpMethod; path: string; route: AppRouteDescription; }, ) => OpenApiOperation; } +export type OpenApiSecurityRequirement = Record; + export interface OpenApiOperation { tags?: string[]; summary?: string; @@ -82,7 +84,7 @@ export interface OpenApiOperation { parameters?: Record[]; requestBody?: Record; responses?: Record; - security?: Record[]; + security?: OpenApiSecurityRequirement[]; [key: string]: unknown; }