From 8c5d585b7f73dc298be00b3a0353731f392f90e6 Mon Sep 17 00:00:00 2001 From: "a.tolmachev" Date: Thu, 26 Mar 2026 00:25:52 +0300 Subject: [PATCH] feat: expose request header configuration --- TASKS.md | 12 +++--- .../src/features/operation-form/model.test.ts | 33 ++++++++++++++- apps/ui/src/features/operation-form/model.ts | 41 +++++++++++++++++-- .../operation-form/operation-form.tsx | 14 +++++++ apps/ui/src/pages/operation-detail/page.tsx | 29 +++++++++++++ 5 files changed, 119 insertions(+), 10 deletions(-) diff --git a/TASKS.md b/TASKS.md index bdf4da4..e699058 100644 --- a/TASKS.md +++ b/TASKS.md @@ -2,17 +2,17 @@ ## Current -### `feat/ui-ux-pass` +### `feat/header-fields` Status: completed DoD: -- shell выглядит как цельный продуктовый интерфейс, а не как набор отдельных секций -- operation list показывает обзор состояния системы, а не только список карточек -- create page объясняет оператору различия между `REST`, `GraphQL` и `gRPC` -- ключевые сценарии имеют пустые состояния и более понятную визуальную иерархию -- UI build и tests остаются зелеными после UX-pass +- форма создания явно поддерживает настройку headers, а не прячет их только в сыром JSON +- `execution headers` доступны для всех протоколов +- `static headers` доступны для REST target +- detail view показывает сохраненные headers +- UI build и tests остаются зелеными ## Next diff --git a/apps/ui/src/features/operation-form/model.test.ts b/apps/ui/src/features/operation-form/model.test.ts index dea833c..924c4f9 100644 --- a/apps/ui/src/features/operation-form/model.test.ts +++ b/apps/ui/src/features/operation-form/model.test.ts @@ -4,11 +4,33 @@ import { buildOperationPayload, defaultOperationFormValues } from "./model"; describe("buildOperationPayload", () => { it("creates a REST operation payload from form values", () => { - const payload = buildOperationPayload(defaultOperationFormValues); + const payload = buildOperationPayload({ + ...defaultOperationFormValues, + restStaticHeadersText: JSON.stringify( + { + "x-app-source": "rmcp", + }, + null, + 2, + ), + executionHeadersText: JSON.stringify( + { + "x-trace-id": "trace-123", + }, + null, + 2, + ), + }); expect(payload.protocol).toBe("rest"); expect(payload.target.kind).toBe("rest"); expect(payload.target.method).toBe("POST"); + expect(payload.target.static_headers).toEqual({ + "x-app-source": "rmcp", + }); + expect(payload.execution_config.headers).toEqual({ + "x-trace-id": "trace-123", + }); expect(payload.input_mapping).toEqual({ rules: [ { @@ -111,4 +133,13 @@ describe("buildOperationPayload", () => { }), ).toThrow(/Input schema contains invalid JSON/); }); + + it("throws when header values are not string maps", () => { + expect(() => + buildOperationPayload({ + ...defaultOperationFormValues, + executionHeadersText: JSON.stringify({ "x-trace-id": 42 }, null, 2), + }), + ).toThrow(/Execution headers.x-trace-id must be a string value/); + }); }); diff --git a/apps/ui/src/features/operation-form/model.ts b/apps/ui/src/features/operation-form/model.ts index 4a0d291..b91bda0 100644 --- a/apps/ui/src/features/operation-form/model.ts +++ b/apps/ui/src/features/operation-form/model.ts @@ -17,6 +17,7 @@ export const operationFormSchema = z restBaseUrl: z.string(), restMethod: z.enum(["GET", "POST", "PUT", "PATCH", "DELETE"]), restPathTemplate: z.string(), + restStaticHeadersText: jsonTextSchema, graphqlEndpoint: z.string(), graphqlOperationType: z.enum(["query", "mutation"]), graphqlOperationName: z.string(), @@ -32,6 +33,7 @@ export const operationFormSchema = z outputSchemaText: jsonTextSchema, inputMappingText: jsonTextSchema, outputMappingText: jsonTextSchema, + executionHeadersText: jsonTextSchema, executionConfigText: jsonTextSchema, }) .superRefine((values, context) => { @@ -111,6 +113,22 @@ export const operationFormSchema = z export type OperationFormValues = z.infer; +function parseStringMap(raw: string, fieldName: string) { + const value = safeParseJson>(raw, fieldName); + + if (value === null || Array.isArray(value) || typeof value !== "object") { + throw new Error(`${fieldName} must be a JSON object`); + } + + for (const [key, entry] of Object.entries(value)) { + if (typeof entry !== "string") { + throw new Error(`${fieldName}.${key} must be a string value`); + } + } + + return value as Record; +} + function defaultInputSchemaText() { return JSON.stringify( { @@ -265,6 +283,7 @@ export const defaultOperationFormValues: OperationFormValues = { restBaseUrl: "https://api.example.com", restMethod: "POST", restPathTemplate: "/v1/leads", + restStaticHeadersText: JSON.stringify({}, null, 2), graphqlEndpoint: "https://api.example.com/graphql", graphqlOperationType: "mutation", graphqlOperationName: "CreateLead", @@ -285,6 +304,7 @@ export const defaultOperationFormValues: OperationFormValues = { outputSchemaText: defaultOutputSchemaText(), inputMappingText: defaultRestInputMappingText(), outputMappingText: defaultRestOutputMappingText(), + executionHeadersText: JSON.stringify({}, null, 2), executionConfigText: defaultExecutionConfigText(), }; @@ -300,6 +320,8 @@ export function getProtocolPreset( toolDescription: "Creates a CRM lead from MCP input fields.", inputMappingText: defaultRestInputMappingText(), outputMappingText: defaultRestOutputMappingText(), + restStaticHeadersText: JSON.stringify({}, null, 2), + executionHeadersText: JSON.stringify({}, null, 2), }; case "graphql": return { @@ -310,6 +332,7 @@ export function getProtocolPreset( "Executes a fixed GraphQL mutation and returns the selected lead payload.", inputMappingText: defaultGraphqlInputMappingText(), outputMappingText: defaultGraphqlOutputMappingText(), + executionHeadersText: JSON.stringify({}, null, 2), }; case "grpc": return { @@ -320,6 +343,7 @@ export function getProtocolPreset( "Executes a unary gRPC method using a descriptor-driven request contract.", inputMappingText: defaultGrpcInputMappingText(), outputMappingText: defaultGrpcOutputMappingText(), + executionHeadersText: JSON.stringify({}, null, 2), }; } } @@ -330,10 +354,18 @@ export function buildOperationPayload(rawValues: OperationFormValues) { const outputSchema = safeParseJson(values.outputSchemaText, "Output schema"); const inputMapping = safeParseJson(values.inputMappingText, "Input mapping"); const outputMapping = safeParseJson(values.outputMappingText, "Output mapping"); - const executionConfig = safeParseJson( + const executionHeaders = parseStringMap( + values.executionHeadersText, + "Execution headers", + ); + const executionConfig = safeParseJson>( values.executionConfigText, "Execution config", ); + const normalizedExecutionConfig = { + ...executionConfig, + headers: executionHeaders, + }; const basePayload = { name: values.name, @@ -342,7 +374,7 @@ export function buildOperationPayload(rawValues: OperationFormValues) { output_schema: outputSchema, input_mapping: inputMapping, output_mapping: outputMapping, - execution_config: executionConfig, + execution_config: normalizedExecutionConfig, tool_description: { title: values.toolTitle, description: values.toolDescription, @@ -361,7 +393,10 @@ export function buildOperationPayload(rawValues: OperationFormValues) { base_url: values.restBaseUrl, method: values.restMethod, path_template: values.restPathTemplate, - static_headers: {}, + static_headers: parseStringMap( + values.restStaticHeadersText, + "REST static headers", + ), }, }; case "graphql": diff --git a/apps/ui/src/features/operation-form/operation-form.tsx b/apps/ui/src/features/operation-form/operation-form.tsx index 947acd4..d654928 100644 --- a/apps/ui/src/features/operation-form/operation-form.tsx +++ b/apps/ui/src/features/operation-form/operation-form.tsx @@ -41,6 +41,13 @@ const restFields: FieldConfig[] = [ { name: "restBaseUrl", label: "Base URL" }, { name: "restMethod", label: "HTTP method" }, { name: "restPathTemplate", label: "Path template" }, + { + name: "restStaticHeadersText", + label: "Static headers", + description: "Always sent for this REST target before dynamic request headers are merged.", + rows: 8, + wide: true, + }, ]; const graphqlFields: FieldConfig[] = [ @@ -89,6 +96,13 @@ const schemaFields: FieldConfig[] = [ const mappingFields: FieldConfig[] = [ { name: "inputMappingText", label: "Input mapping", rows: 12, wide: true }, { name: "outputMappingText", label: "Output mapping", rows: 12, wide: true }, + { + name: "executionHeadersText", + label: "Execution headers", + description: "Shared transport headers available for REST, GraphQL and gRPC requests.", + rows: 8, + wide: true, + }, { name: "executionConfigText", label: "Execution config", diff --git a/apps/ui/src/pages/operation-detail/page.tsx b/apps/ui/src/pages/operation-detail/page.tsx index fffc5da..9a682bd 100644 --- a/apps/ui/src/pages/operation-detail/page.tsx +++ b/apps/ui/src/pages/operation-detail/page.tsx @@ -40,6 +40,25 @@ function describeTarget(target: OperationTarget) { } } +function readObjectRecord(value: unknown) { + if (value === null || typeof value !== "object" || Array.isArray(value)) { + return {}; + } + + return value as Record; +} + +function readExecutionHeaders(value: unknown) { + const config = readObjectRecord(value); + const headers = config.headers; + + if (headers === null || typeof headers !== "object" || Array.isArray(headers)) { + return {}; + } + + return headers as Record; +} + function draftSubtitle(target: OperationTarget) { switch (target.kind) { case "rest": @@ -147,6 +166,16 @@ export function OperationDetailPage() {

Execution config

{JSON.stringify(snapshot.execution_config, null, 2)}
+
+

Transport headers

+
{JSON.stringify(readExecutionHeaders(snapshot.execution_config), null, 2)}
+
+ {snapshot.target.kind === "rest" ? ( +
+

REST static headers

+
{JSON.stringify(snapshot.target.static_headers ?? {}, null, 2)}
+
+ ) : null} ) : null}