Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
132 changes: 54 additions & 78 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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** 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
- `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 '@gravity-ui/expresskit-api';
import jwt from 'jsonwebtoken';

// Add OpenAPI security scheme metadata to your auth handler
Expand Down Expand Up @@ -289,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',
Expand All @@ -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):
Expand Down
10 changes: 9 additions & 1 deletion src/index.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,12 @@
import './types';

export {createOpenApiRegistry} from './openapi-registry';
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';
115 changes: 73 additions & 42 deletions src/openapi-registry.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,17 @@
import type {OpenApiRegistryConfig, OpenApiSchemaObject, SecuritySchemeObject} from './types';
import type {
OpenApiOperation,
OpenApiRegistryConfig,
OpenApiSchemaObject,
OpenApiSecurityRequirement,
SecuritySchemeObject,
} from './types';
import {serveFiles, setup} from 'swagger-ui-express';

import {
AppErrorHandler,
AppMiddleware,
AppMountHandler,
AppRouteDescription,
AppRouteHandler,
AppRoutes,
AuthPolicy,
RouteContract,
Expand Down Expand Up @@ -159,16 +164,10 @@ export function createOpenApiRegistry(config: OpenApiRegistryConfig) {
return openApiSchema;
}

function registerRoute(
method: HttpMethod,
routePath: string,
routeHandler: AppRouteHandler,
function getOperationSecurity(
authHandler?: AppMiddleware | RequestHandler,
): void {
const apiConfig = getContract(routeHandler);
if (!apiConfig) return;

const security = [];
): OpenApiSecurityRequirement[] {
const security: OpenApiSecurityRequirement[] = [];
if (authHandler) {
const securityScheme = getSecurityScheme(authHandler);
if (securityScheme) {
Expand All @@ -178,33 +177,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<string, unknown> = {
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<string, unknown>[];

if (apiConfig.request?.params) {
Expand All @@ -218,19 +194,68 @@ export function createOpenApiRegistry(config: OpenApiRegistryConfig) {
if (apiConfig.request?.headers) {
parameters.push(...createParameters('header', apiConfig.request.headers));
}
return parameters;
}

operation.parameters = parameters;
function registerRoute(
method: HttpMethod,
routePath: string,
description: AppRouteDescription,
authHandler?: AppMiddleware | RequestHandler,
transformOperation?: (
operation: OpenApiOperation,
context: {
method: HttpMethod;
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}');

if (['post', 'put', 'patch'].includes(method.toLowerCase()) && apiConfig.request?.body) {
const pathItem = openApiSchema.paths[openApiPath] || {};
const operation: OpenApiOperation = {
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) && apiConfig.request?.body) {
operation.requestBody = createRequestBody(
apiConfig.request.body,
apiConfig.request.contentType,
);
}

operation.responses = createResponses(apiConfig.response);
const finalOperation = transformOperation
? transformOperation(operation, {
method,
path: openApiPath,
route: description,
})
: operation;

pathItem[method] = finalOperation;

pathItem[method.toLowerCase()] = operation;
openApiSchema.paths[openApiPath] = pathItem;
}

Expand Down Expand Up @@ -362,7 +387,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';
Expand Down
Loading
Loading