feat: expose request header configuration
This commit is contained in:
@@ -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/);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
Reference in New Issue
Block a user