feat: expose request header configuration

This commit is contained in:
a.tolmachev
2026-03-26 00:25:52 +03:00
parent bc5b55d0ab
commit 8c5d585b7f
5 changed files with 119 additions and 10 deletions
+6 -6
View File
@@ -2,17 +2,17 @@
## Current ## Current
### `feat/ui-ux-pass` ### `feat/header-fields`
Status: completed Status: completed
DoD: DoD:
- shell выглядит как цельный продуктовый интерфейс, а не как набор отдельных секций - форма создания явно поддерживает настройку headers, а не прячет их только в сыром JSON
- operation list показывает обзор состояния системы, а не только список карточек - `execution headers` доступны для всех протоколов
- create page объясняет оператору различия между `REST`, `GraphQL` и `gRPC` - `static headers` доступны для REST target
- ключевые сценарии имеют пустые состояния и более понятную визуальную иерархию - detail view показывает сохраненные headers
- UI build и tests остаются зелеными после UX-pass - UI build и tests остаются зелеными
## Next ## Next
@@ -4,11 +4,33 @@ import { buildOperationPayload, defaultOperationFormValues } from "./model";
describe("buildOperationPayload", () => { describe("buildOperationPayload", () => {
it("creates a REST operation payload from form values", () => { 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.protocol).toBe("rest");
expect(payload.target.kind).toBe("rest"); expect(payload.target.kind).toBe("rest");
expect(payload.target.method).toBe("POST"); 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({ expect(payload.input_mapping).toEqual({
rules: [ rules: [
{ {
@@ -111,4 +133,13 @@ describe("buildOperationPayload", () => {
}), }),
).toThrow(/Input schema contains invalid JSON/); ).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/);
});
}); });
+38 -3
View File
@@ -17,6 +17,7 @@ export const operationFormSchema = z
restBaseUrl: z.string(), restBaseUrl: z.string(),
restMethod: z.enum(["GET", "POST", "PUT", "PATCH", "DELETE"]), restMethod: z.enum(["GET", "POST", "PUT", "PATCH", "DELETE"]),
restPathTemplate: z.string(), restPathTemplate: z.string(),
restStaticHeadersText: jsonTextSchema,
graphqlEndpoint: z.string(), graphqlEndpoint: z.string(),
graphqlOperationType: z.enum(["query", "mutation"]), graphqlOperationType: z.enum(["query", "mutation"]),
graphqlOperationName: z.string(), graphqlOperationName: z.string(),
@@ -32,6 +33,7 @@ export const operationFormSchema = z
outputSchemaText: jsonTextSchema, outputSchemaText: jsonTextSchema,
inputMappingText: jsonTextSchema, inputMappingText: jsonTextSchema,
outputMappingText: jsonTextSchema, outputMappingText: jsonTextSchema,
executionHeadersText: jsonTextSchema,
executionConfigText: jsonTextSchema, executionConfigText: jsonTextSchema,
}) })
.superRefine((values, context) => { .superRefine((values, context) => {
@@ -111,6 +113,22 @@ export const operationFormSchema = z
export type OperationFormValues = z.infer<typeof operationFormSchema>; export type OperationFormValues = z.infer<typeof operationFormSchema>;
function parseStringMap(raw: string, fieldName: string) {
const value = safeParseJson<Record<string, unknown>>(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<string, string>;
}
function defaultInputSchemaText() { function defaultInputSchemaText() {
return JSON.stringify( return JSON.stringify(
{ {
@@ -265,6 +283,7 @@ export const defaultOperationFormValues: OperationFormValues = {
restBaseUrl: "https://api.example.com", restBaseUrl: "https://api.example.com",
restMethod: "POST", restMethod: "POST",
restPathTemplate: "/v1/leads", restPathTemplate: "/v1/leads",
restStaticHeadersText: JSON.stringify({}, null, 2),
graphqlEndpoint: "https://api.example.com/graphql", graphqlEndpoint: "https://api.example.com/graphql",
graphqlOperationType: "mutation", graphqlOperationType: "mutation",
graphqlOperationName: "CreateLead", graphqlOperationName: "CreateLead",
@@ -285,6 +304,7 @@ export const defaultOperationFormValues: OperationFormValues = {
outputSchemaText: defaultOutputSchemaText(), outputSchemaText: defaultOutputSchemaText(),
inputMappingText: defaultRestInputMappingText(), inputMappingText: defaultRestInputMappingText(),
outputMappingText: defaultRestOutputMappingText(), outputMappingText: defaultRestOutputMappingText(),
executionHeadersText: JSON.stringify({}, null, 2),
executionConfigText: defaultExecutionConfigText(), executionConfigText: defaultExecutionConfigText(),
}; };
@@ -300,6 +320,8 @@ export function getProtocolPreset(
toolDescription: "Creates a CRM lead from MCP input fields.", toolDescription: "Creates a CRM lead from MCP input fields.",
inputMappingText: defaultRestInputMappingText(), inputMappingText: defaultRestInputMappingText(),
outputMappingText: defaultRestOutputMappingText(), outputMappingText: defaultRestOutputMappingText(),
restStaticHeadersText: JSON.stringify({}, null, 2),
executionHeadersText: JSON.stringify({}, null, 2),
}; };
case "graphql": case "graphql":
return { return {
@@ -310,6 +332,7 @@ export function getProtocolPreset(
"Executes a fixed GraphQL mutation and returns the selected lead payload.", "Executes a fixed GraphQL mutation and returns the selected lead payload.",
inputMappingText: defaultGraphqlInputMappingText(), inputMappingText: defaultGraphqlInputMappingText(),
outputMappingText: defaultGraphqlOutputMappingText(), outputMappingText: defaultGraphqlOutputMappingText(),
executionHeadersText: JSON.stringify({}, null, 2),
}; };
case "grpc": case "grpc":
return { return {
@@ -320,6 +343,7 @@ export function getProtocolPreset(
"Executes a unary gRPC method using a descriptor-driven request contract.", "Executes a unary gRPC method using a descriptor-driven request contract.",
inputMappingText: defaultGrpcInputMappingText(), inputMappingText: defaultGrpcInputMappingText(),
outputMappingText: defaultGrpcOutputMappingText(), outputMappingText: defaultGrpcOutputMappingText(),
executionHeadersText: JSON.stringify({}, null, 2),
}; };
} }
} }
@@ -330,10 +354,18 @@ export function buildOperationPayload(rawValues: OperationFormValues) {
const outputSchema = safeParseJson(values.outputSchemaText, "Output schema"); const outputSchema = safeParseJson(values.outputSchemaText, "Output schema");
const inputMapping = safeParseJson(values.inputMappingText, "Input mapping"); const inputMapping = safeParseJson(values.inputMappingText, "Input mapping");
const outputMapping = safeParseJson(values.outputMappingText, "Output mapping"); const outputMapping = safeParseJson(values.outputMappingText, "Output mapping");
const executionConfig = safeParseJson( const executionHeaders = parseStringMap(
values.executionHeadersText,
"Execution headers",
);
const executionConfig = safeParseJson<Record<string, unknown>>(
values.executionConfigText, values.executionConfigText,
"Execution config", "Execution config",
); );
const normalizedExecutionConfig = {
...executionConfig,
headers: executionHeaders,
};
const basePayload = { const basePayload = {
name: values.name, name: values.name,
@@ -342,7 +374,7 @@ export function buildOperationPayload(rawValues: OperationFormValues) {
output_schema: outputSchema, output_schema: outputSchema,
input_mapping: inputMapping, input_mapping: inputMapping,
output_mapping: outputMapping, output_mapping: outputMapping,
execution_config: executionConfig, execution_config: normalizedExecutionConfig,
tool_description: { tool_description: {
title: values.toolTitle, title: values.toolTitle,
description: values.toolDescription, description: values.toolDescription,
@@ -361,7 +393,10 @@ export function buildOperationPayload(rawValues: OperationFormValues) {
base_url: values.restBaseUrl, base_url: values.restBaseUrl,
method: values.restMethod, method: values.restMethod,
path_template: values.restPathTemplate, path_template: values.restPathTemplate,
static_headers: {}, static_headers: parseStringMap(
values.restStaticHeadersText,
"REST static headers",
),
}, },
}; };
case "graphql": case "graphql":
@@ -41,6 +41,13 @@ const restFields: FieldConfig[] = [
{ name: "restBaseUrl", label: "Base URL" }, { name: "restBaseUrl", label: "Base URL" },
{ name: "restMethod", label: "HTTP method" }, { name: "restMethod", label: "HTTP method" },
{ name: "restPathTemplate", label: "Path template" }, { 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[] = [ const graphqlFields: FieldConfig[] = [
@@ -89,6 +96,13 @@ const schemaFields: FieldConfig[] = [
const mappingFields: FieldConfig[] = [ const mappingFields: FieldConfig[] = [
{ name: "inputMappingText", label: "Input mapping", rows: 12, wide: true }, { name: "inputMappingText", label: "Input mapping", rows: 12, wide: true },
{ name: "outputMappingText", label: "Output 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", name: "executionConfigText",
label: "Execution config", label: "Execution config",
@@ -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<string, unknown>;
}
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<string, unknown>;
}
function draftSubtitle(target: OperationTarget) { function draftSubtitle(target: OperationTarget) {
switch (target.kind) { switch (target.kind) {
case "rest": case "rest":
@@ -147,6 +166,16 @@ export function OperationDetailPage() {
<h3>Execution config</h3> <h3>Execution config</h3>
<pre>{JSON.stringify(snapshot.execution_config, null, 2)}</pre> <pre>{JSON.stringify(snapshot.execution_config, null, 2)}</pre>
</div> </div>
<div className="result-panel">
<h3>Transport headers</h3>
<pre>{JSON.stringify(readExecutionHeaders(snapshot.execution_config), null, 2)}</pre>
</div>
{snapshot.target.kind === "rest" ? (
<div className="result-panel">
<h3>REST static headers</h3>
<pre>{JSON.stringify(snapshot.target.static_headers ?? {}, null, 2)}</pre>
</div>
) : null}
</div> </div>
</PageSection> </PageSection>
) : null} ) : null}