827 lines · 23.7 KB · TypeScript
RawDownload
import { startTransition, useState, type ReactNode } from "react";import type {  Source,  SourceInspectionToolDetail,} from "@executor/react";import {  SecretReferenceField,  defineExecutorPluginHttpApiClient,  Result,  useAtomSet,  useAtomValue,  useExecutorMutation,  useLocalInstallation,  useSource,} from "@executor/react";import {  Alert,  Badge,  Button,  Card,  IconPencil,  Input,  Label,  Select,  SourceToolExplorer,  Textarea,  parseSourceToolExplorerSearch,  useSourcePluginRouteParams,  type SourceToolExplorerSearch,  useSourcePluginNavigation,  useSourcePluginSearch,} from "@executor/react/plugins"; import {  graphqlHttpApiExtension,} from "@executor/plugin-graphql-http";import {  type GraphqlConnectInput,  type GraphqlConnectionAuth,} from "@executor/plugin-graphql-shared"; const getGraphqlHttpClient = defineExecutorPluginHttpApiClient<"GraphqlReactHttpClient">()(  "GraphqlReactHttpClient",  [graphqlHttpApiExtension] as const,); type GraphqlToolRouteParams = {  sourceId: string;  toolPath?: string;}; const defaultGraphqlInput = (): GraphqlConnectInput => ({  name: "My GraphQL Source",  endpoint: "https://example.com/graphql",  defaultHeaders: null,  auth: {    kind: "none",  },}); const DEFAULT_BEARER_HEADER_NAME = "Authorization";const DEFAULT_BEARER_PREFIX = "Bearer "; const presetString = (  search: Record<string, unknown>,  key: string,): string | null => {  const value = search[key];  return typeof value === "string" && value.trim().length > 0    ? value.trim()    : null;}; const firstSearchString = (  search: Record<string, unknown>,  keys: ReadonlyArray<string>,): string | null => {  for (const key of keys) {    const value = presetString(search, key);    if (value) {      return value;    }  }   return null;}; const graphqlInputFromSearch = (  search: Record<string, unknown>,): GraphqlConnectInput => {  const defaults = defaultGraphqlInput();   return {    ...defaults,    name: presetString(search, "presetName") ?? defaults.name,    endpoint:      firstSearchString(search, [        "presetEndpoint",        "endpoint",        "inputUrl",        "pastedUrl",        "url",      ]) ?? defaults.endpoint,  };}; const parseStringMap = (value: string): Record<string, string> | null => {  const trimmed = value.trim();  if (!trimmed) {    return null;  }   const parsed = JSON.parse(trimmed) as unknown;  if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {    throw new Error("Headers must be a JSON object.");  }   const entries = Object.entries(parsed as Record<string, unknown>);  if (!entries.every(([, entry]) => typeof entry === "string")) {    throw new Error("All header values must be strings.");  }   return Object.fromEntries(entries as Array<[string, string]>);}; const stringifyStringMap = (  value: Record<string, string> | null | undefined,): string =>  !value || Object.keys(value).length === 0    ? ""    : JSON.stringify(value, null, 2); const secretValue = (input: GraphqlConnectionAuth): string =>  input.kind === "bearer"    ? JSON.stringify(input.tokenSecretRef)    : ""; const bearerHeaderNameValue = (input: GraphqlConnectionAuth): string =>  input.kind === "bearer"    ? input.headerName?.trim() || DEFAULT_BEARER_HEADER_NAME    : DEFAULT_BEARER_HEADER_NAME; const bearerPrefixValue = (input: GraphqlConnectionAuth): string =>  input.kind === "bearer"    ? input.prefix ?? DEFAULT_BEARER_PREFIX    : DEFAULT_BEARER_PREFIX; const trimToNull = (value: string): string | null => {  const trimmed = value.trim();  return trimmed.length > 0 ? trimmed : null;}; const authFromSecretValue = (  authKind: GraphqlConnectionAuth["kind"],  value: string,  headerName: string,  prefix: string,): GraphqlConnectionAuth => {  if (authKind === "none") {    return {      kind: "none",    };  }   if (!value) {    throw new Error("Select a secret for bearer auth.");  }   return {    kind: "bearer",    tokenSecretRef: JSON.parse(value) as GraphqlConnectionAuth & { tokenSecretRef: never }["tokenSecretRef"],    headerName: trimToNull(headerName),    prefix: prefix === DEFAULT_BEARER_PREFIX ? null : prefix,  };}; const graphqlToolRoutePath = (toolPath: string): string =>  `tool/${encodeURIComponent(toolPath)}`; const readGraphqlToolPath = (params: GraphqlToolRouteParams): string | null => {  if (!params.toolPath || params.toolPath.length === 0) {    return null;  }   try {    return decodeURIComponent(params.toolPath);  } catch {    return params.toolPath;  }}; function GraphqlSourceForm(props: {  initialValue: GraphqlConnectInput;  mode: "create" | "edit";  onSubmit: (input: GraphqlConnectInput) => Promise<void>;}) {  const submitMutation = useExecutorMutation<GraphqlConnectInput, void>(props.onSubmit);  const [name, setName] = useState(props.initialValue.name);  const [endpoint, setEndpoint] = useState(props.initialValue.endpoint);  const [headersText, setHeadersText] = useState(    stringifyStringMap(props.initialValue.defaultHeaders),  );  const [authKind, setAuthKind] = useState<GraphqlConnectionAuth["kind"]>(    props.initialValue.auth.kind,  );  const [secretRef, setSecretRef] = useState(secretValue(props.initialValue.auth));  const [authHeaderName, setAuthHeaderName] = useState(    bearerHeaderNameValue(props.initialValue.auth),  );  const [authPrefix, setAuthPrefix] = useState(    bearerPrefixValue(props.initialValue.auth),  );  const [error, setError] = useState<string | null>(null);   return (    <div className="space-y-6 rounded-lg border border-border bg-card p-6 text-sm ring-1 ring-foreground/[0.04]">      <div className="grid gap-4">        <div className="grid gap-2">          <Label>Name</Label>          <Input            value={name}            onChange={(event) => setName(event.target.value)}          />        </div>         <div className="grid gap-2">          <Label>Endpoint</Label>          <Input            value={endpoint}            onChange={(event) => setEndpoint(event.target.value)}            className="font-mono text-xs"          />        </div>         <div className="grid gap-2">          <Label>Default Headers</Label>          <Textarea            value={headersText}            onChange={(event) => setHeadersText(event.target.value)}            rows={3}            placeholder='{"x-api-version":"2026-03-23"}'          />        </div>         <div className="grid gap-2">          <Label>Auth</Label>          <Select            value={authKind}            onChange={(event) =>              setAuthKind(event.target.value as GraphqlConnectionAuth["kind"])}          >            <option value="none">None</option>            <option value="bearer">Bearer Secret</option>          </Select>        </div>         {authKind === "bearer" && (          <div className="space-y-4 rounded-lg border border-border bg-muted/20 p-4">            <SecretReferenceField              label="Secret"              value={secretRef}              emptyLabel="Select a secret"              draftNamePlaceholder="GraphQL bearer token"              draftValuePlaceholder="Paste the token value"              onChange={setSecretRef}            />             <div className="grid gap-4 sm:grid-cols-2">              <div className="grid gap-2">                <Label>Header Name</Label>                <Input                  value={authHeaderName}                  onChange={(event) => setAuthHeaderName(event.target.value)}                  placeholder={DEFAULT_BEARER_HEADER_NAME}                  className="font-mono text-xs"                />              </div>               <div className="grid gap-2">                <Label>Prefix</Label>                <Input                  value={authPrefix}                  onChange={(event) => setAuthPrefix(event.target.value)}                  placeholder={DEFAULT_BEARER_PREFIX}                  className="font-mono text-xs"                />              </div>            </div>          </div>        )}      </div>       {error && (        <Alert variant="destructive">          {error}        </Alert>      )}       <div className="flex items-center justify-end">        <Button          type="button"          onClick={() => {            void (async () => {              setError(null);              try {                await submitMutation.mutateAsync({                  name: name.trim(),                  endpoint: endpoint.trim(),                  defaultHeaders: parseStringMap(headersText),                  auth: authFromSecretValue(                    authKind,                    secretRef,                    authHeaderName,                    authPrefix,                  ),                });              } catch (cause) {                setError(cause instanceof Error ? cause.message : "Failed saving GraphQL source.");              }            })();          }}          disabled={submitMutation.status === "pending"}        >          {submitMutation.status === "pending"            ? props.mode === "create" ? "Creating..." : "Saving..."            : props.mode === "create" ? "Create Source" : "Save Changes"}        </Button>      </div>    </div>  );} export function GraphqlAddPage() {  const navigation = useSourcePluginNavigation();  const initialValue = graphqlInputFromSearch(useSourcePluginSearch());  const installation = useLocalInstallation();  const client = getGraphqlHttpClient();  const createSource = useAtomSet(    client.mutation("graphql", "createSource"),    { mode: "promise" },  );    if (installation.status !== "ready") {    return <div className="text-sm text-muted-foreground">Loading workspace...</div>;  }   return (    <div className="min-h-0 flex-1 overflow-y-auto">      <div className="mx-auto w-full max-w-3xl px-6 py-10">        <div className="mb-8">          <h1 className="font-display text-2xl tracking-tight text-foreground">            Add GraphQL Source          </h1>          <p className="mt-1.5 text-sm leading-6 text-muted-foreground">            Connect a GraphQL endpoint and expose its operations as tools.          </p>        </div>         <GraphqlSourceForm          initialValue={initialValue}          mode="create"          onSubmit={async (input) => {            const source = await createSource({              path: {                workspaceId: installation.data.scopeId,              },              payload: input,              reactivityKeys: {                sources: [installation.data.scopeId],              },            });             startTransition(() => {              void navigation.detail(source.id, {                tab: "model",              });            });          }}        />      </div>    </div>  );} export function GraphqlEditPage(props: {  source: Source;}) {  const navigation = useSourcePluginNavigation();  const installation = useLocalInstallation();  const client = getGraphqlHttpClient();  const configResult = useAtomValue(    installation.status === "ready"      ? client.query("graphql", "getSourceConfig", {          path: {            workspaceId: installation.data.scopeId,            sourceId: props.source.id,          },          reactivityKeys: {            source: [installation.data.scopeId, props.source.id],          },          timeToLive: "30 seconds",        })      : client.query("local", "installation", {          timeToLive: "1 second",        }) as never,  );  const updateSource = useAtomSet(    client.mutation("graphql", "updateSource"),    { mode: "promise" },  );   if (installation.status !== "ready") {    return <div className="text-sm text-muted-foreground">Loading workspace...</div>;  }   if (Result.isFailure(configResult)) {    return (      <Alert variant="destructive">        Failed loading source configuration.      </Alert>    );  }   if (!Result.isSuccess(configResult)) {    return <div className="text-sm text-muted-foreground">Loading configuration...</div>;  }   return (    <div className="min-h-0 flex-1 overflow-y-auto">      <div className="mx-auto w-full max-w-3xl px-6 py-10">        <div className="mb-8">          <h1 className="font-display text-2xl tracking-tight text-foreground">            Edit GraphQL Source          </h1>          <p className="mt-1.5 text-sm leading-6 text-muted-foreground">            Update the connection settings for {props.source.name}.          </p>        </div>         <GraphqlSourceForm          initialValue={configResult.value}          mode="edit"          onSubmit={async (input) => {            const source = await updateSource({              path: {                workspaceId: installation.data.scopeId,                sourceId: props.source.id,              },              payload: input,              reactivityKeys: {                sources: [installation.data.scopeId],                source: [installation.data.scopeId, props.source.id],                sourceInspection: [installation.data.scopeId, props.source.id],                sourceInspectionTool: [installation.data.scopeId, props.source.id],                sourceDiscovery: [installation.data.scopeId, props.source.id],              },            });             startTransition(() => {              void navigation.detail(source.id, {                tab: "model",              });            });          }}        />      </div>    </div>  );} function GraphqlDetailExplorer(props: {  source: Source;  selectedToolPath: string | null;}) {  const navigation = useSourcePluginNavigation();  const search = parseSourceToolExplorerSearch(    useSourcePluginSearch(),  ) satisfies SourceToolExplorerSearch;  const installation = useLocalInstallation();  const client = getGraphqlHttpClient();  const removeSource = useAtomSet(    client.mutation("graphql", "removeSource"),    { mode: "promise" },  );  const configResult = useAtomValue(    installation.status === "ready"      ? client.query("graphql", "getSourceConfig", {          path: {            workspaceId: installation.data.scopeId,            sourceId: props.source.id,          },          reactivityKeys: {            source: [installation.data.scopeId, props.source.id],          },          timeToLive: "30 seconds",        })      : client.query("local", "installation", {          timeToLive: "1 second",        }) as never,

Lines 1–500 of 827.