From 4244ed40fa76e822cee1ae97bec098a87bc9349e Mon Sep 17 00:00:00 2001 From: Samat Jorobekov Date: Tue, 12 May 2026 13:59:30 +0300 Subject: [PATCH] fix(validator): preserve handler name for telemetry and logs --- docs/VALIDATOR-ru.md | 8 +++- docs/VALIDATOR.md | 8 +++- src/tests/with-contact.test.ts | 80 ++++++++++++++++++++++++++++++++++ src/validator/types.ts | 1 + src/validator/with-contract.ts | 5 +++ 5 files changed, 98 insertions(+), 4 deletions(-) diff --git a/docs/VALIDATOR-ru.md b/docs/VALIDATOR-ru.md index 2debaef..2f81168 100644 --- a/docs/VALIDATOR-ru.md +++ b/docs/VALIDATOR-ru.md @@ -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), @@ -121,6 +119,7 @@ const app = new ExpressKit(nodekit, routes); ```typescript interface RouteContract { + operationId?: string; // Опциональное поле контракта, которое становится именем обработчика, используемым в логах, контексте запроса и телеметрии. request?: { contentType?: string | string[]; // Разрешенные типы контента запроса. По умолчанию: 'application/json' body?: z.ZodType; // Схема для req.body @@ -143,6 +142,11 @@ const app = new ExpressKit(nodekit, routes); } ``` + Ключевые свойства: + + - `operationId`: Опциональное поле контракта, которое становится именем обработчика, используемым в логах, контексте запроса и телеметрии — согласовано с `operationId` объекта операции OpenAPI. Приоритет разрешения имени обработчика: `handlerName` на уровне роута > `config.operationId` > `handler.name` > `unnamedController`. + - `request` / `response`: Определяют схемы для валидации запроса и ответа. + - **`settings`**: Опциональные настройки для контракта. ```typescript diff --git a/docs/VALIDATOR.md b/docs/VALIDATOR.md index 4d73077..97917b9 100644 --- a/docs/VALIDATOR.md +++ b/docs/VALIDATOR.md @@ -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), @@ -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; // Schema for req.body @@ -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 diff --git a/src/tests/with-contact.test.ts b/src/tests/with-contact.test.ts index 0cd2020..e885097 100644 --- a/src/tests/with-contact.test.ts +++ b/src/tests/with-contact.test.ts @@ -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'}); + }); + }); }); diff --git a/src/validator/types.ts b/src/validator/types.ts index 451c28d..b1533cd 100644 --- a/src/validator/types.ts +++ b/src/validator/types.ts @@ -78,6 +78,7 @@ interface TypedResponseMethods