Skip to content

Expresskit validator #72

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

Open
wants to merge 25 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
bdce99d
feat: expresskit validator
sjorobekov Jun 8, 2025
752ba0b
feat: allow content-type customization in validator
sjorobekov Jun 29, 2025
c1e098e
feat: allow customize content-type in validator
sjorobekov Jun 29, 2025
1d6a4f7
feat: allow swagger path customization
sjorobekov Jun 29, 2025
922f76c
feat: cache schema per handler
sjorobekov Jul 5, 2025
8cdf953
feat: improve validator naming
sjorobekov Jul 6, 2025
87b0f35
feat: improve validator naming
sjorobekov Jul 6, 2025
97fd607
Merge branch 'main' into validator
sjorobekov Jul 7, 2025
fc11334
feat: replace .apiConfig with WeakMap
sjorobekov Jul 13, 2025
90db885
feat: support security schemas
sjorobekov Jul 13, 2025
a6e23a1
feat: security schemas
sjorobekov Jul 14, 2025
3701016
feat: validation with security examples
sjorobekov Jul 14, 2025
5f73ace
feat: remove security section from readme.md
sjorobekov Jul 14, 2025
e770904
feat: caching schema
sjorobekov Jul 14, 2025
2ccc5a1
feat: replace class by factory function
sjorobekov Jul 14, 2025
e609e69
feat: rename test file
sjorobekov Jul 14, 2025
4783b98
feat: allow schemaless responses
sjorobekov Jul 14, 2025
46db153
feat: update VALIDATOR.md
sjorobekov Jul 14, 2025
0090acc
feat: move manualValidation to Settings
sjorobekov Jul 21, 2025
a8f4db6
feat: generate schema on registerRoute
sjorobekov Jul 21, 2025
1f26418
feat: move validation error handling to middleware
sjorobekov Jul 23, 2025
77181c7
feat: add withErrorContract
sjorobekov Jul 24, 2025
8c78ecc
feat: update VALIDATOR.mf
sjorobekov Jul 24, 2025
15e818d
feat: update VALIDATOR.md
sjorobekov Jul 24, 2025
637b43a
feat: allow header params
sjorobekov Jul 25, 2025
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
394 changes: 394 additions & 0 deletions docs/VALIDATOR.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,394 @@
# ExpressKit Validator

Provides request validation (body, params, query, headers) and response serialization and documentation using Zod schemas.

## Table of Contents

- [Quick Start: Automatic Validation](#quick-start-automatic-validation)
- [Core Concepts](#core-concepts)
- [withContract Configuration](#withcontractconfighandler)
- [Enhanced Request](#enhanced-request-contractrequest)
- [Enhanced Response](#enhanced-response-contractresponse)
- [Error Handling Customization](#error-handling-customization)
- [Security Schemes for OpenAPI Documentation](#security-schemes-for-openapi-documentation)
- [Basic Usage](#basic-usage)
- [Available Security Scheme Types](#available-security-scheme-types)
- [Custom Security Schemes](#custom-security-schemes)
- [How It Works](#how-it-works)
- [Best Practices](#best-practices)

---

## Quick Start: Automatic Validation

Here's a common example of using `withContract` for automatic request validation and response serialization:

```typescript
import {ExpressKit, withContract, AppRoutes, RouteContract} from '@gravity-ui/expresskit';
import {NodeKit} from '@gravity-ui/nodekit';
import {z} from 'zod/v4';

// Define your Zod schemas
const TaskSchema = z.object({
id: z.string(),
name: z.string(),
description: z.string().optional(),
createdAt: z.string().datetime(),
});

const ErrorSchema = z.object({
message: z.string(),
code: z.string().optional(),
});

// Configure the API endpoint
const CreateTaskConfig = {
name: 'CreateTask',
operationId: 'createTaskOperation',
summary: 'Creates a new task',
request: {
body: z.object({
name: z.string().min(1),
description: z.string().optional(),
}),
},
response: {
content: {
201: {
schema: TaskSchema,
description: 'Task created successfully.',
},
400: {
schema: ErrorSchema,
description: 'Invalid input data.',
},
},
},
} satisfies RouteContract;

// Create your route handler, wrapped with withContract
const createTaskHandler = withContract(CreateTaskConfig)(async (req, res) => {
// req.body is automatically validated and typed
const {name, description} = req.body;

const newTask = {
id: 'task_' + Date.now(),
name,
description,
createdAt: new Date().toISOString(),
};

// Validates response against TaskSchema and sends it
res.sendValidated(201, newTask);
});

// Example with manual validation
const manualValidationHandler = withContract(CreateTaskConfig, {
manualValidation: true,
})(async (req, res) => {
// Need to manually validate since manualValidation is true
const {body} = await req.validate();
const {name, description} = body;

const newTask = {
id: 'task_' + Date.now(),
name,
description,
createdAt: new Date().toISOString(),
};

res.sendValidated(201, newTask);
});

// Integrate with your Express/ExpressKit routes
const routes: AppRoutes = {
'POST /tasks': createTaskHandler,
};

const nodekit = new NodeKit();
const app = new ExpressKit(nodekit, routes);
```

**Key takeaways:**

- Request body is automatically validated against your schema
- Inside the handler, `req.body` is typed according to your schema
- `res.sendValidated()` validates the response data against your schema
- If validation fails, appropriate errors are thrown and handled

---

## Core Concepts

The primary tool is the `withContract` higher-order function, which wraps Express route handlers to add validation, serialization, and type safety based on Zod schemas.

### `withContract(config, settings?)(handler)`

- **`config` (`RouteContract`)**: An object to configure validation behavior and OpenAPI documentation.

```typescript
interface RouteContract {
name?: string; // Descriptive name for logging/tracing
operationId?: string; // Unique ID for the operation (e.g., for OpenAPI)
summary?: string; // Short summary for OpenAPI
description?: string; // Detailed description for OpenAPI
tags?: string[]; // Tags for grouping (e.g., for OpenAPI)
request?: {
contentType?: string | string[]; // Allowed request content types. Default: 'application/json'
body?: z.ZodType<any>; // Schema for req.body
params?: z.ZodType<any>; // Schema for req.params
query?: z.ZodType<any>; // Schema for req.query
headers?: z.ZodType<any>; // Schema for req.headers
};
// Define response schemas for various HTTP status codes. This field is MANDATORY.
response: {
contentType?: string; // The response content type. Default: 'application/json'
content: Record<
number,
{
schema?: z.ZodType<any>; // Optional Zod schema for this status code's response body
description?: string; // Description for this response (e.g., for OpenAPI)
}
>;
};
}
```

- **`settings`**: Optional settings for the contract.

```typescript
interface WithContractSettings {
manualValidation?: boolean; // Default: false. If true, call req.validate() manually.
}
```

Key properties:

- `manualValidation`: Set to `true` to disable automatic request validation.

- **`handler(req, res)`**: Your Express route handler, receiving enhanced `req` and `res` objects.

### Enhanced Request (`ContractRequest`)

The `req` object in your handler is enhanced:

- **Typed Properties**: `req.body`, `req.params`, `req.query`, `req.headers` are typed based on `RouteContract.request` schemas (if automatic validation is enabled and successful).
- **`req.validate(): Promise<ValidatedData>`**:
- Call this asynchronous method if `manualValidation` is `true`.
- Returns a promise resolving to an object with validated `body`, `params`, `query`, and `headers`.
- Throws `ValidationError` on failure.

### Enhanced Response (`ContractResponse`)

The `res` object in your handler is enhanced with the following methods:

- **`res.sendTyped(statusCode, data?)`**:
Copy link
Contributor

Choose a reason for hiding this comment

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

sendTyped is still not the best name.

I'm not sure if patching the original res.send() is acceptable here, but if it is, I suggest the following naming:

  • res.send() – for static type checking (now res.sendTyped()).
  • res.sendOriginal() – for original method (now res.send()).
  • res.sendValidated() – without any changes.

Or use res.contract.send() and res.contract.sendValidated().

@resure what do you think about this?

Copy link
Contributor

Choose a reason for hiding this comment

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

The original express method should not be patched. Such an override can have a lot of side effects and is probably not worth it.

res.contract.send()

I'm not 100% sure if this is better than sendTyped, but maybe it could be a logical way to move all new helpers to new namespaces (req.contract., res.contract.). The only worry is that it might look pretty long that way.

Copy link
Author

Choose a reason for hiding this comment

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

@imsitnikov @resure what you think about having semantic methods: req.ok(data), req.created(data), req.badRequest(data), etc, depending on what is defined in schema? They will have static validation.
And probably req.ok.validated(data) for dynamic validation?

Copy link
Contributor

Choose a reason for hiding this comment

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

Looks like additional pollution of the req object, and one method will be easier to maintain than many.
Overall, I'm okay with res.contract.send() or res.sendTyped(), but I'm not the decision maker here.


- Sends a JSON response with the given `statusCode`.
- The `data` argument is **type-checked** at compile time against the schema associated with `statusCode`.
- It **does not perform runtime validation** or data transformation.
- Useful if you are certain about the data's structure and want to skip validation overhead.

- **`res.sendValidated(statusCode, data)`**:
- Sends a JSON response with the given `statusCode`.
- **Performs runtime validation** of `data` against the schema associated with `statusCode`.
- **Transforms data** according to that Zod schema (stripping extra fields, applying defaults, etc.).
- Throws a `ResponseValidationError` if validation fails.
- Use this method to ensure strict adherence to the API contract.

### Error Handling Customization

ExpressKit provides a powerful way to customize validation error handling through the combination of `withErrorContract` and `AppConfig.validationErrorHandler`:

#### Custom Error Handling with `withErrorContract` and `validationErrorHandler`

```typescript
import {
withErrorContract,
ErrorContract,
ValidationError,
ResponseValidationError
} from '@gravity-ui/expresskit';
import {z} from 'zod/v4;
import {NodeKit} from '@gravity-ui/nodekit';

// Define your error contract with typed error responses
const CustomErrorContract = {
errors: {
content: {
400: {
name: 'ValidationError',
schema: z.object({
error: z.string(),
code: z.string(),
details: z.array(z.string()).optional(),
requestId: z.string(),
}),
description: 'Custom validation error format',
},
500: {
name: 'ServerError',
schema: z.object({
error: z.string(),
code: z.string(),
requestId: z.string(),
}),
description: 'Server error',
},
},
},
} satisfies ErrorContract;

const config: Partial<AppConfig> = {
validationErrorHandler: (ctx) => {
return withErrorContract(CustomErrorContract)((err, req, res, next) => {
if (err instanceof ValidationError) {
// Use type-safe res.sendError() from withErrorContract
res.sendError(400, {
error: 'Invalid input',
code: 'CUSTOM_VALIDATION_ERROR',
details: err.details?.issues?.map(issue => issue.message) || [],
requestId: req.id,
});
} else if (err instanceof ResponseValidationError) {
res.sendError(500, {
error: 'Internal Server Error',
code: 'RESPONSE_VALIDATION_FAILED',
requestId: req.id,
});
} else {
next(err);
}
});
},
};
```

---

## Security Schemes for OpenAPI Documentation

ExpressKit supports automatic generation of security requirements in OpenAPI documentation based on the authentication handlers used in your routes.

### Features

- **HOC Wrappers**: `withSecurityScheme` 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.

### Basic Usage

```typescript
import {bearerAuth} from 'expresskit';
import jwt from 'jsonwebtoken';

// Add OpenAPI security scheme metadata to your auth handler
const jwtAuthHandler = bearerAuth('myJwtAuth')(function authenticate(req, res, next) {
Copy link
Contributor

Choose a reason for hiding this comment

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

Does it work correctly with global declared appAuthHandler?

Copy link
Author

Choose a reason for hiding this comment

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

yes

// Your authentication logic here
next();
});

// Use in routes
const routes = {
'GET /api/protected': {
handler: protectedRouteHandler,
authHandler: jwtAuthHandler,
},
};
```

### Available Security Scheme Types

#### Bearer Token Authentication

```typescript
const jwtAuthHandler = bearerAuth(
'jwtAuth', // scheme name in OpenAPI docs
['read:users', 'write:users'], // optional scopes
)(authFunction);
```

#### API Key Authentication

```typescript
const apiKeyHandler = apiKeyAuth(
'apiKeyAuth', // scheme name
'header', // location: 'header', 'query', or 'cookie'
'X-API-Key', // parameter name
['read', 'write'], // optional scopes
)(authFunction);
```

#### Basic Authentication

```typescript
const basicAuthHandler = basicAuth(
'basicAuth', // scheme name
['read', 'write'], // optional scopes
)(authFunction);
```

#### OAuth2 Authentication

```typescript
const oauth2Handler = oauth2Auth(
'oauth2Auth', // scheme name
{
implicit: {
authorizationUrl: 'https://example.com/oauth/authorize',
scopes: {
read: 'Read access',
write: 'Write access',
},
},
},
['read', 'write'], // optional scopes for this specific handler
)(authFunction);
```

#### OpenID Connect Authentication

```typescript
const oidcHandler = oidcAuth(
'oidcAuth', // scheme name
'https://example.com/.well-known/openid-configuration',
['profile', 'email'], // optional scopes
)(authFunction);
```

### Custom Security Schemes

If you need a custom security scheme, you can use the `withSecurityScheme` function directly:

```typescript
import {withSecurityScheme} from 'expresskit';

const customAuthHandler = withSecurityScheme({
name: 'myCustomScheme',
scheme: {
type: 'http',
scheme: 'digest',
description: 'Digest authentication',
},
scopes: ['read', 'write'],
})(authFunction);
```

### How It Works

1. When you wrap an authentication handler with one of the security scheme HOCs, it registers the scheme definition.
2. The router detects when a route uses an auth handler with a registered security scheme.
3. The scheme is added to the OpenAPI components.securitySchemes section.
4. A security requirement referencing the scheme is added to the route operation.

### Best Practices

1. **Consistent Naming**: Use consistent names for your security schemes.
2. **Documentation**: Add descriptions to your security schemes to explain the required format.
3. **Scopes**: When using OAuth2 or scoped tokens, be specific about which scopes are required for each endpoint.
4. **Auth Policy**: The security requirement is only added if the route's auth policy is not disabled.
Loading