History

refactor: consolidate JSON parsing functions into json-form module

Rhys Sullivan committed 7 months ago

Commit af98c571 parent b8a09bd4 files · +120 −158Browse files

Unified · Split

4 files
  1. apps/web/src/views/add-source.tsx +1 −62
  2. apps/web/src/views/json-form.ts +74 −0
  3. apps/web/src/views/source-editor.tsx +1 −62
  4. packages/platform/control-plane/src/runtime/execution/live.ts +44 −34
apps/web/src/views/add-source.tsx+1−62
@@ -51,6 +51,7 @@ import { · 50 unmodified lines
5151 type McpTransportFields,
5252 type McpTransportValue,
5353 } from "./mcp-transport-state";
54+import { parseJsonStringArray, parseJsonStringMap } from "./json-form";
5455 import { getDomain } from "tldts";
5556
5657 // ---------------------------------------------------------------------------
@@ -214,68 +215,6 @@ const googleDiscoveryDefaultsFromUrl = ( · 157 unmodified lines
214215 }
215216 };
216217
217−const parseJsonStringMap = (
218− label: string,
219− text: string,
220−): Record<string, string> | null => {
221− const trimmed = text.trim();
222− if (trimmed.length === 0) {
223− return null;
224− }
225−
226− let parsed: unknown;
227− try {
228− parsed = JSON.parse(trimmed);
229− } catch {
230− throw new Error(`${label} must be valid JSON.`);
231− }
232−
233− if (parsed === null || Array.isArray(parsed) || typeof parsed !== "object") {
234− throw new Error(`${label} must be a JSON object with string values.`);
235− }
236−
237− const entries = Object.entries(parsed as Record<string, unknown>);
238− const normalized: Record<string, string> = {};
239− for (const [key, value] of entries) {
240− if (typeof value !== "string") {
241− throw new Error(`${label} must only contain string values.`);
242− }
243− normalized[key] = value;
244− }
245−
246− return Object.keys(normalized).length === 0 ? null : normalized;
247−};
248−
249−const parseJsonStringArray = (
250− label: string,
251− text: string,
252−): Array<string> | null => {
253− const trimmed = text.trim();
254− if (trimmed.length === 0) {
255− return null;
256− }
257−
258− let parsed: unknown;
259− try {
260− parsed = JSON.parse(trimmed);
261− } catch {
262− throw new Error(`${label} must be valid JSON.`);
263− }
264−
265− if (
266− !Array.isArray(parsed) ||
267− !parsed.every((value) => typeof value === "string")
268− ) {
269− throw new Error(`${label} must be a JSON array of strings.`);
270− }
271−
272− const normalized = parsed
273− .map((value) => value.trim())
274− .filter((value) => value.length > 0);
275−
276− return normalized.length > 0 ? normalized : null;
277−};
278−
279218 const stringifyStringMap = (
280219 value: Readonly<Record<string, string>> | null | undefined,
281220 ): string =>
apps/web/src/views/json-form.ts+74−0
@@ -0,0 +1,74 @@
1+import * as Either from "effect/Either";
2+import * as ParseResult from "effect/ParseResult";
3+import * as Schema from "effect/Schema";
4+
5+const JsonStringMapSchema = Schema.parseJson(
6+ Schema.Record({
7+ key: Schema.String,
8+ value: Schema.String,
9+ }),
10+);
11+
12+const JsonStringArraySchema = Schema.parseJson(
13+ Schema.Array(Schema.String),
14+);
15+
16+const formatJsonFieldError = (
17+ label: string,
18+ error: ParseResult.ParseError,
19+): Error =>
20+ new Error(
21+ `${label} is invalid: ${ParseResult.TreeFormatter.formatErrorSync(error)}`,
22+ );
23+
24+const decodeJsonField = <A>(input: {
25+ label: string;
26+ text: string;
27+ schema: Schema.Schema<A, string>;
28+}): A | null => {
29+ const trimmed = input.text.trim();
30+ if (trimmed.length === 0) {
31+ return null;
32+ }
33+
34+ const decoded = Schema.decodeUnknownEither(input.schema)(trimmed);
35+ if (Either.isLeft(decoded)) {
36+ throw formatJsonFieldError(input.label, decoded.left);
37+ }
38+
39+ return decoded.right;
40+};
41+
42+export const parseJsonStringMap = (
43+ label: string,
44+ text: string,
45+): Record<string, string> | null => {
46+ const decoded = decodeJsonField({
47+ label,
48+ text,
49+ schema: JsonStringMapSchema,
50+ });
51+
52+ return decoded && Object.keys(decoded).length > 0 ? decoded : null;
53+};
54+
55+export const parseJsonStringArray = (
56+ label: string,
57+ text: string,
58+): Array<string> | null => {
59+ const decoded = decodeJsonField({
60+ label,
61+ text,
62+ schema: JsonStringArraySchema,
63+ });
64+
65+ if (!decoded) {
66+ return null;
67+ }
68+
69+ const normalized = decoded
70+ .map((value) => value.trim())
71+ .filter((value) => value.length > 0);
72+
73+ return normalized.length > 0 ? normalized : null;
74+};
apps/web/src/views/source-editor.tsx+1−62
@@ -44,6 +44,7 @@ import { · 43 unmodified lines
4444 type McpTransportFields,
4545 type McpTransportValue,
4646 } from "./mcp-transport-state";
47+import { parseJsonStringArray, parseJsonStringMap } from "./json-form";
4748 import { sourceTemplates, type SourceTemplate } from "./source-templates";
4849 import { getDomain } from "tldts";
4950
@@ -357,36 +358,6 @@ const readBindingTransport = (source: Source): McpTransportValue => { · 307 unmodified lines
357358 : "";
358359 };
359360
360−const parseJsonStringArray = (
361− label: string,
362− text: string,
363−): Array<string> | null => {
364− const trimmed = text.trim();
365− if (trimmed.length === 0) {
366− return null;
367− }
368−
369− let parsed: unknown;
370− try {
371− parsed = JSON.parse(trimmed);
372− } catch {
373− throw new Error(`${label} must be valid JSON.`);
374− }
375−
376− if (
377− !Array.isArray(parsed) ||
378− !parsed.every((value) => typeof value === "string")
379− ) {
380− throw new Error(`${label} must be a JSON array of strings.`);
381− }
382−
383− const normalized = parsed
384− .map((value) => value.trim())
385− .filter((value) => value.length > 0);
386−
387− return normalized.length > 0 ? normalized : null;
388−};
389−
390361 const buildSyntheticMcpStdioEndpoint = (input: {
391362 name?: string | null;
392363 endpoint?: string | null;
@@ -507,38 +478,6 @@ const formStateFromSource = (source: Source): SourceFormState => ({ · 114 unmodified lines
507478 : {}),
508479 });
509480
510−const parseJsonStringMap = (
511− label: string,
512− text: string,
513−): Record<string, string> | null => {
514− const trimmed = text.trim();
515− if (trimmed.length === 0) {
516− return null;
517− }
518−
519− let parsed: unknown;
520− try {
521− parsed = JSON.parse(trimmed);
522− } catch {
523− throw new Error(`${label} must be valid JSON.`);
524− }
525−
526− if (parsed === null || Array.isArray(parsed) || typeof parsed !== "object") {
527− throw new Error(`${label} must be a JSON object with string values.`);
528− }
529−
530− const entries = Object.entries(parsed as Record<string, unknown>);
531− const normalized: Record<string, string> = {};
532− for (const [key, value] of entries) {
533− if (typeof value !== "string") {
534− throw new Error(`${label} must only contain string values.`);
535− }
536− normalized[key] = value;
537− }
538−
539− return Object.keys(normalized).length === 0 ? null : normalized;
540−};
541−
542481 const buildAuthPayload = (
543482 state: SourceFormState,
544483 ): CreateSourcePayload["auth"] => {
packages/platform/control-plane/src/runtime/execution/live.ts+44−34
@@ -193,31 +193,40 @@ export const createLiveExecutionManager = () => { · 192 unmodified lines
193193 response,
194194 };
195195
196− yield* publishState({
197− executionId,
198− state: "waiting_for_interaction",
199− });
200−
201− const resolved = yield* Deferred.await(response);
202− const resolvedAt = Date.now();
203−
204− yield* rows.executionInteractions.update(interaction.id, {
205− status: resolved.action === "cancel" ? "cancelled" : "resolved",
206− responseJson: serializeJson(sanitizePersistedElicitationResponse(resolved)),
207− responsePrivateJson: serializeJson(resolved),
208− updatedAt: resolvedAt,
209− });
210− yield* rows.executions.update(executionId, {
211− status: "running",
212− updatedAt: resolvedAt,
213− });
214− yield* publishState({
215− executionId,
216− state: "running",
217− });
218−
219− run.currentInteraction = null;
220− return resolved;
196+ return yield* Effect.gen(function* () {
197+ yield* publishState({
198+ executionId,
199+ state: "waiting_for_interaction",
200+ });
201+
202+ const resolved = yield* Deferred.await(response);
203+ const resolvedAt = Date.now();
204+
205+ yield* rows.executionInteractions.update(interaction.id, {
206+ status: resolved.action === "cancel" ? "cancelled" : "resolved",
207+ responseJson: serializeJson(sanitizePersistedElicitationResponse(resolved)),
208+ responsePrivateJson: serializeJson(resolved),
209+ updatedAt: resolvedAt,
210+ });
211+ yield* rows.executions.update(executionId, {
212+ status: "running",
213+ updatedAt: resolvedAt,
214+ });
215+ yield* publishState({
216+ executionId,
217+ state: "running",
218+ });
219+
220+ return resolved;
221+ }).pipe(
222+ Effect.ensuring(
223+ Effect.sync(() => {
224+ if (run.currentInteraction?.interactionId === interaction.id) {
225+ run.currentInteraction = null;
226+ }
227+ }),
228+ ),
229+ );
221230 }),
222231
223232 resolveInteraction: ({ executionId, response }) =>
@@ -234,20 +243,21 @@ export const createLiveExecutionManager = () => { · 10 unmodified lines
234243
235244 finishRun: ({ executionId, state }) =>
236245 publishState({ executionId, state }).pipe(
237− Effect.zipRight(
238− clearMcpConnectionPoolRun(executionId).pipe(
239− Effect.zipRight(Effect.sync(() => {
240− runs.delete(executionId);
241− })),
242− ),
246+ Effect.zipRight(clearMcpConnectionPoolRun(executionId)),
247+ Effect.ensuring(
248+ Effect.sync(() => {
249+ runs.delete(executionId);
250+ }),
243251 ),
244252 ),
245253
246254 clearRun: (executionId) =>
247255 clearMcpConnectionPoolRun(executionId).pipe(
248− Effect.zipRight(Effect.sync(() => {
249− runs.delete(executionId);
250− })),
256+ Effect.ensuring(
257+ Effect.sync(() => {
258+ runs.delete(executionId);
259+ }),
260+ ),
251261 ),
252262 } satisfies LiveExecutionManagerShape;
253263