Skip to content

Implement std.graphql Tool with Dynamic Field Projection #57

@aarne

Description

@aarne

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:

  1. 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's catch boundaries.
  2. 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 for id, the downstream API is still forced to resolve name and company.

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

  • ToolContext exposes requestedPaths.
  • Engine correctly identifies and forwards all from paths targeting the scheduled tool instance.
  • std.graphql dynamically constructs the string query.
  • std.graphql executes the fetch and natively throws on JSON "errors" payloads.
  • Unit tests added simulating a multi-field GraphQL pull.

Metadata

Metadata

Assignees

No one assigned

    Labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions