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
8 changes: 6 additions & 2 deletions docs/VALIDATOR-ru.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,9 +37,7 @@ const ErrorSchema = z.object({

// Настройте API endpoint
const CreateTaskConfig = {
name: 'CreateTask',
operationId: 'createTaskOperation',
summary: 'Creates a new task',
request: {
body: z.object({
name: z.string().min(1),
Expand Down Expand Up @@ -121,6 +119,7 @@ const app = new ExpressKit(nodekit, routes);

```typescript
interface RouteContract {
operationId?: string; // Опциональное поле контракта, которое становится именем обработчика, используемым в логах, контексте запроса и телеметрии.
request?: {
contentType?: string | string[]; // Разрешенные типы контента запроса. По умолчанию: 'application/json'
body?: z.ZodType<any>; // Схема для req.body
Expand All @@ -143,6 +142,11 @@ const app = new ExpressKit(nodekit, routes);
}
```

Ключевые свойства:

- `operationId`: Опциональное поле контракта, которое становится именем обработчика, используемым в логах, контексте запроса и телеметрии — согласовано с `operationId` объекта операции OpenAPI. Приоритет разрешения имени обработчика: `handlerName` на уровне роута > `config.operationId` > `handler.name` > `unnamedController`.
- `request` / `response`: Определяют схемы для валидации запроса и ответа.

- **`settings`**: Опциональные настройки для контракта.

```typescript
Expand Down
8 changes: 6 additions & 2 deletions docs/VALIDATOR.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,9 +37,7 @@ const ErrorSchema = z.object({

// Configure the API endpoint
const CreateTaskConfig = {
name: 'CreateTask',
operationId: 'createTaskOperation',
summary: 'Creates a new task',
request: {
body: z.object({
name: z.string().min(1),
Expand Down Expand Up @@ -121,6 +119,7 @@ The primary tool is the `withContract` higher-order function, which wraps Expres

```typescript
interface RouteContract {
operationId?: string; // Optional contract field that becomes the handler name used in logs, the per-request context, and telemetry self-stats.
request?: {
contentType?: string | string[]; // Allowed request content types. Default: 'application/json'
body?: z.ZodType<any>; // Schema for req.body
Expand All @@ -143,6 +142,11 @@ The primary tool is the `withContract` higher-order function, which wraps Expres
}
```

Key properties:

- `operationId`: Optional contract field that becomes the handler name used in logs, the per-request context, and telemetry self-stats — aligned with the OpenAPI Operation Object's `operationId`. The handler name resolution priority is: route-level `handlerName` > `config.operationId` > `handler.name` > `unnamedController`.
- `request` / `response`: Define the schemas for request and response validation.

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

```typescript
Expand Down
80 changes: 80 additions & 0 deletions src/tests/with-contact.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -580,4 +580,84 @@ describe('withContract', () => {
});
});
});

describe('handler name propagation', () => {
it('should propagate config.operationId to the wrapper', () => {
const wrapped = withContract({
operationId: 'createTask',
response: {content: {200: {schema: z.object({})}}},
})(async (_req, _res) => {});
expect(wrapped.name).toBe('createTask');
});

it('should propagate named handler when config.operationId is absent', () => {
const wrapped = withContract({
response: {content: {200: {schema: z.object({})}}},
})(function myHandler(_req, _res) {});
expect(wrapped.name).toBe('myHandler');
});

it('should prioritize config.operationId over handler.name', () => {
const wrapped = withContract({
operationId: 'createTask',
response: {content: {200: {schema: z.object({})}}},
})(function differentName(_req, _res) {});
expect(wrapped.name).toBe('createTask');
});

it('should set name to empty string for anonymous handler without config.operationId', () => {
const wrapped = withContract({
response: {content: {200: {schema: z.object({})}}},
})(async (_req, _res) => {});
expect(wrapped.name).toBe('');
});

it('should use config.operationId for req.routeInfo.handlerName in setupRoutes', async () => {
const localRoutes = {
'GET /name-propagation': {
handler: withContract({
operationId: 'getEntry',
response: {content: {200: {schema: z.object({handlerName: z.string()})}}},
})(async (req, res) => {
res.sendTyped(200, {handlerName: req.routeInfo.handlerName || ''});
}),
},
};
const localApp = new ExpressKit(nodekit, localRoutes).express;
const response = await request(localApp).get('/name-propagation').expect(200);
expect(response.body).toEqual({handlerName: 'getEntry'});
});

it('should fall through to unnamedController for anonymous handler in setupRoutes', async () => {
const localRoutes = {
'GET /anonymous-propagation': {
handler: withContract({
response: {content: {200: {schema: z.object({handlerName: z.string()})}}},
})(async (req, res) => {
res.sendTyped(200, {handlerName: req.routeInfo.handlerName || ''});
}),
},
};
const localApp = new ExpressKit(nodekit, localRoutes).express;
const response = await request(localApp).get('/anonymous-propagation').expect(200);
expect(response.body).toEqual({handlerName: 'unnamedController'});
});

it('should prioritize route-level handlerName over config.operationId', async () => {
const localRoutes = {
'GET /explicit-name': {
handlerName: 'explicitName',
handler: withContract({
operationId: 'getEntry',
response: {content: {200: {schema: z.object({handlerName: z.string()})}}},
})(async (req, res) => {
res.sendTyped(200, {handlerName: req.routeInfo.handlerName || ''});
}),
},
};
const localApp = new ExpressKit(nodekit, localRoutes).express;
const response = await request(localApp).get('/explicit-name').expect(200);
expect(response.body).toEqual({handlerName: 'explicitName'});
});
});
});
1 change: 1 addition & 0 deletions src/validator/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,7 @@ interface TypedResponseMethods<TContent extends Record<number, z.ZodType | {sche
}

export interface RouteContract {
operationId?: string;
request?: {
body?: z.ZodType;
params?: z.ZodType;
Expand Down
5 changes: 5 additions & 0 deletions src/validator/with-contract.ts
Original file line number Diff line number Diff line change
Expand Up @@ -200,6 +200,11 @@ export function withContract<
await handler(enhancedReq, enhancedRes);
};

Object.defineProperty(finalHandler, 'name', {
value: config.operationId || handler.name || '',
configurable: true,
});

registerContract(finalHandler, config);

return finalHandler;
Expand Down
Loading