-
Notifications
You must be signed in to change notification settings - Fork 3
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
sjorobekov
wants to merge
25
commits into
main
Choose a base branch
from
validator
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
Changes from all commits
Commits
Show all changes
25 commits
Select commit
Hold shift + click to select a range
bdce99d
feat: expresskit validator
sjorobekov 752ba0b
feat: allow content-type customization in validator
sjorobekov c1e098e
feat: allow customize content-type in validator
sjorobekov 1d6a4f7
feat: allow swagger path customization
sjorobekov 922f76c
feat: cache schema per handler
sjorobekov 8cdf953
feat: improve validator naming
sjorobekov 87b0f35
feat: improve validator naming
sjorobekov 97fd607
Merge branch 'main' into validator
sjorobekov fc11334
feat: replace .apiConfig with WeakMap
sjorobekov 90db885
feat: support security schemas
sjorobekov a6e23a1
feat: security schemas
sjorobekov 3701016
feat: validation with security examples
sjorobekov 5f73ace
feat: remove security section from readme.md
sjorobekov e770904
feat: caching schema
sjorobekov 2ccc5a1
feat: replace class by factory function
sjorobekov e609e69
feat: rename test file
sjorobekov 4783b98
feat: allow schemaless responses
sjorobekov 46db153
feat: update VALIDATOR.md
sjorobekov 0090acc
feat: move manualValidation to Settings
sjorobekov a8f4db6
feat: generate schema on registerRoute
sjorobekov 1f26418
feat: move validation error handling to middleware
sjorobekov 77181c7
feat: add withErrorContract
sjorobekov 8c78ecc
feat: update VALIDATOR.mf
sjorobekov 15e818d
feat: update VALIDATOR.md
sjorobekov 637b43a
feat: allow header params
sjorobekov File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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?)`**: | ||
|
||
- 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) { | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Does it work correctly with global declared There was a problem hiding this comment. Choose a reason for hiding this commentThe 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. |
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
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 (nowres.sendTyped()
).res.sendOriginal()
– for original method (nowres.send()
).res.sendValidated()
– without any changes.Or use
res.contract.send()
andres.contract.sendValidated()
.@resure what do you think about this?
There was a problem hiding this comment.
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.
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.
There was a problem hiding this comment.
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?There was a problem hiding this comment.
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()
orres.sendTyped()
, but I'm not the decision maker here.