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
48 changes: 34 additions & 14 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:

Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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):
Expand Down
5 changes: 4 additions & 1 deletion src/example/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand All @@ -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();
Expand Down
74 changes: 43 additions & 31 deletions src/openapi-registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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<AppMountHandler>[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<typeof createRouter> {
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,
Expand Down
44 changes: 43 additions & 1 deletion src/tests/integration.test.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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();
});
});
});
1 change: 1 addition & 0 deletions src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ export interface SecuritySchemeObject {

export interface OpenApiRegistryConfig {
enabled?: boolean;
skipMount?: boolean;
path?: string;
version?: string;
title?: string;
Expand Down
Loading