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
### `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
@@ -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/);
});
});
+38 -3
View File
@@ -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<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() {
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<Record<string, unknown>>(
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":
@@ -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",
@@ -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) {
switch (target.kind) {
case "rest":
@@ -147,6 +166,16 @@ export function OperationDetailPage() {
<h3>Execution config</h3>
<pre>{JSON.stringify(snapshot.execution_config, null, 2)}</pre>
</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>
</PageSection>
) : null}