-
Notifications
You must be signed in to change notification settings - Fork 0
Description
Target: v2.x Standard Library & Engine
Goal: Create a dedicated, native GraphQL tool that handles GraphQL-specific error boundaries and eliminates over-fetching by dynamically generating queries based on the Bridge AST.
1. The Context
Currently, the Bridge only has std.http for REST calls. If developers use std.http to call a GraphQL API, they run into two major problems:
- The "200 OK" Problem: GraphQL APIs almost always return HTTP 200, even when the query fails, putting the error in an
"errors"array. A generic HTTP tool will think the call succeeded, bypassing the Bridge'scatchboundaries. - The Over-fetching Problem: Writing deeply nested GraphQL queries in a single hardcoded string (e.g.,
"{ user { id name company } }") is brittle. If the client only asks the Bridge forid, the downstream API is still forced to resolvenameandcompany.
We can solve both problems simultaneously. By exposing the AST's expected data paths to the tool context, our new std.graphql tool can automatically build the perfect selection set and safely throw errors if the API payload contains them.
2. Implementation Steps
Step 1: Upgrade the Engine's ToolContext
Update the ToolContext interface in types.ts to expose the paths the AST expects to pull from the tool.
export interface ToolContext {
logger: Logger;
signal?: AbortSignal;
// NEW: Exposes the exact fields the Bridge intends to pull from this tool instance.
// e.g., [ ["user", "id"], ["user", "company", "name"] ]
requestedPaths?: string[][];
}Step 2: Inject Paths during Scheduling
In ExecutionTree.ts inside the schedule() method, scan the bridge wires right before building the toolContext. Collect the paths of all wires that pull data from the target tool.
// Inside ExecutionTree.schedule(), before calling the tool:
const requestedPaths: string[][] = [];
if (this.bridge) {
for (const w of this.bridge.wires) {
if ("from" in w && sameTrunk(w.from, target)) {
requestedPaths.push(w.from.path);
}
}
}
const toolContext: ToolContext = {
logger: this.logger ?? {},
signal: this.signal,
requestedPaths, // <--- Inject here
};Step 3: Create the GraphQL Tool (tools/graphql.ts)
Create the new tool implementation. It needs a helper to build the braces { ... }, the main fetch logic, and the error trap.
import type { ToolContext } from "../types.ts";
export interface GraphqlInput {
url: string;
rootNode: string; // e.g., "user(login: $username)"
variables?: Record<string, any>;
headers?: Record<string, string>;
}
// Helper: Converts [["user", "id"], ["user", "company", "name"]] -> "{ id company { name } }"
function buildSelectionSet(paths: string[][]): string {
if (!paths || paths.length === 0) return "";
const tree: any = {};
for (const path of paths) {
let current = tree;
for (const segment of path) {
if (/^\d+$/.test(segment)) continue; // Skip array indices
current[segment] = current[segment] || {};
current = current[segment];
}
}
function stringifyTree(node: any): string {
const keys = Object.keys(node);
if (keys.length === 0) return "";
return `{ ${keys.map(k => {
const children = stringifyTree(node[k]);
return children ? `${k} ${children}` : k;
}).join(" ")} }`;
}
return stringifyTree(tree);
}
export async function graphqlCall(input: GraphqlInput, context: ToolContext) {
const { url, rootNode, variables, headers = {} } = input;
if (!url || !rootNode) throw new Error("std.graphql requires 'url' and 'rootNode'");
// 1. Strip the root node from the paths, then build the selection set
const rootName = rootNode.split("(")[0].trim();
const subPaths = (context.requestedPaths || [])
.filter(p => p[0] === rootName)
.map(p => p.slice(1));
const selectionSet = buildSelectionSet(subPaths);
const query = `query { ${rootNode} ${selectionSet} }`;
context.logger.debug?.(`[graphql] Generated Query: ${query}`);
// 2. Execute the fetch call
const res = await fetch(url, {
method: "POST",
headers: { "Content-Type": "application/json", ...headers },
body: JSON.stringify({ query, variables: variables ?? {} }),
signal: context.signal,
});
if (!res.ok) throw new Error(`GraphQL HTTP Error: ${res.status} ${res.statusText}`);
const json = await res.json();
// 3. The GraphQL Error Trap!
if (json.errors?.length) {
throw new Error(`GraphQL Error: ${json.errors[0].message}`);
}
return json.data;
}Step 4: Register the Tool
Export the new tool in tools/index.ts so it is available via with std.graphql.
3. Developer Experience (DX) Target
Once implemented, developers will define GraphQL requests seamlessly without hardcoding nested braces, and they can rely entirely on the Bridge's native error boundaries.
bridge Query.externalUser {
with std.graphql as gql
with input as i
with output as o
gql.url = "https://api.github.com/graphql"
gql.rootNode = "user(login: $username)"
gql.variables.username <- i.loginName
# Engine automatically calculates paths and generates:
# query { user(login: $username) { id company { name } } }
o.id <- gql.user.id
o.companyName <- gql.user.company.name
# Because the tool traps "errors", this catch block will correctly fire!
o.error <- gql.user catch "Failed to load user"
}
4. Acceptance Criteria
-
ToolContextexposesrequestedPaths. - Engine correctly identifies and forwards all
frompaths targeting the scheduled tool instance. -
std.graphqldynamically constructs the string query. -
std.graphqlexecutes thefetchand natively throws on JSON"errors"payloads. - Unit tests added simulating a multi-field GraphQL pull.