Skip to content

Converting GraphQL to REST APIs: A Deep Dive into Truto's Proxy Architecture

Learn how Truto's config-driven Proxy API seamlessly translates GraphQL-backed integrations into standard RESTful CRUD resources without writing custom code.

Uday Gajavalli Uday Gajavalli · · 6 min read
Converting GraphQL to REST APIs: A Deep Dive into Truto's Proxy Architecture

Integrating with third-party APIs often involves navigating a maze of different paradigms. While many modern SaaS vendors provide RESTful endpoints, a significant subset (like Linear) strictly use GraphQL. For developers building unified integrations across dozens of services, having to switch contexts between REST and GraphQL paradigms creates cognitive overhead and architectural complexity.

At Truto, we believe the integration layer should be consistent. If you are interacting with CRM contacts or ticketing issues, the interface should look and feel the same regardless of the underlying API's architecture, without falling into the trap of rigid unified API schemas.

This article explains how Truto's Proxy API seamlessly translates GraphQL-backed integrations into standard RESTful CRUD resources using a config-driven approach.

The Two Worlds: Client vs. Provider

When dealing with GraphQL APIs, you typically send a POST /graphql request with a query or mutation string and a typed variables object. Truto's Proxy layer does not force the third-party API to change. Instead, it translates between two distinct worlds:

  • Client-facing: Familiar REST semantics (GET /proxy/issues, POST /proxy/issues, PATCH /proxy/issues/:id, etc.).
  • Provider-facing: A GraphQL POST with a query/mutation string and typed variables.

The bridge connecting these worlds is entirely config-driven. Each method configuration specifies the GraphQL body as a template with {{placeholders}} and defines a response_path to extract the exact slice of data you need from the GraphQL response envelope.

The Architecture of Translation

The translation process relies on a few core components:

  1. Proxy API Router: Maps incoming REST routes (like GET /proxy/issues) to internal handlers.
  2. Fetch Engine: The core engine that loads the configuration, builds the URL, constructs the body via placeholder substitution, performs the HTTP fetch, and applies the response_path.
  3. Integration Config: Defines the base URL and the specific GraphQL templates for each operation.
  4. @truto/replace-placeholders: A public, MIT-licensed npm package that replaces {{...}} templates with values from the request context.
  5. wild-wild-path: Used to traverse the GraphQL response and extract the data.

The Config-Driven Flow

When a REST request arrives, the router extracts the resource (e.g., issues), the ID, query parameters, and the body. The core engine reads the integration configuration to find the corresponding method.

The configuration drives the entire process:

  • URL: Built from the integration's base URL plus the method's path (usually /graphql).
  • Request Body: The GraphQL template is merged with the incoming body and run through the placeholder substitution engine.
  • Response Extraction: The parsed JSON response is traversed using the response_path to extract only the relevant data, stripping away the data envelope.

Placeholder Substitution: The Engine of Translation

The magic of translating a REST request into a GraphQL payload lies in placeholder substitution. The @truto/replace-placeholders package allows us to map REST inputs directly into GraphQL variables with strict type coercion.

Syntax and Coercion

The basic syntax is {{path:type:default}}. This allows us to handle GraphQL's strict typing requirements directly within the configuration.

  • :int: {{query.limit:int:null}} ensures that a limit passed as a query string is sent as an integer (e.g., 20), not a string ("20").
  • :json: {{query.filter:json:null}} allows structured filter objects to be passed as proper JSON objects in the variables.
  • :str:null: Ensures optional fields are sent as null rather than an empty string, which is crucial for GraphQL's nullable field semantics.

A Complete Walkthrough: Linear Issues

Let's look at how this works in practice using Linear's issues resource. Linear exposes a GraphQL API, but through Truto, you interact with it as a set of REST endpoints.

Converted Linear REST APIs in Truto UI

The Resource Configuration

Here is a simplified view of the configuration for Linear issues:

{
  "issues": {
    "list": {
      "method": "post",
      "path": "/graphql",
      "body_format": "json",
      "body": {
        "query": "query Issues($first: Int, $after: String, $filter: IssueFilter) { issues(first: $first, after: $after, filter: $filter) { nodes { id title } pageInfo { hasNextPage endCursor } } }",
        "variables": {
          "first": "{{query.limit:int:null}}",
          "after": "{{pagination.cursor:str:null}}",
          "filter": "{{query.filter:json:null}}"
        }
      },
      "response_path": "data.issues.nodes"
    },
    "get": {
      "method": "post",
      "path": "/graphql",
      "body_format": "json",
      "body": {
        "query": "query Issues($filter: IssueFilter) { issues(filter: $filter) { nodes { id title description } } }",
        "variables": {
          "filter": {
            "id": {
              "eq": "{{id}}"
            }
          }
        }
      },
      "response_path": "data.**.nodes.0"
    }
  }
}

Notice that both list and get share the same method: "post" and path: "/graphql". The difference lies entirely in the GraphQL query, the variables template, and the response_path.

Handling a List Request

When you make a request like this:

GET /proxy/issues?limit=10&filter={"state":{"name":{"eq":"In Progress"}}}

The configuration maps limit to the $first variable and coercing it to an integer. It maps the filter query parameter to the $filter variable, parsing it as JSON.

What gets sent to Linear is a perfectly formatted GraphQL request:

{
  "query": "query Issues($first: Int, $after: String, $filter: IssueFilter) { ... }",
  "variables": {
    "first": 10,
    "after": null,
    "filter": { "state": { "name": { "eq": "In Progress" } } }
  }
}

When Linear responds with the data wrapped in {"data": {"issues": {"nodes": [...]}}}, the response_path of data.issues.nodes extracts just the array of issues, which is what the REST client expects.

Handling a Get Request

When you request a single issue:

GET /proxy/issues/issue_1

The configuration injects the path parameter issue_1 into the filter variable: "eq": "{{id}}".

The response_path here is data.**.nodes.0. The ** wildcard matches any intermediate keys, and .0 selects the first (and only) element from the array, returning a single object instead of an array.

Handling Mutations (Create/Update)

Mutations follow the same pattern but introduce request body mapping. Let's look at a create operation (POST /proxy/issues).

Here is the proxy configuration for creating an issue:

"create": {
  "method": "post",
  "path": "/graphql",
  "body_format": "json",
  "body": {
    "query": "mutation IssueCreate($input: IssueCreateInput!) { issueCreate(input: $input) { issue { id } } }",
    "variables": {
      "input": {
        "title": "{{body.title:str:null}}",
        "teamId": "{{body.team_id:str:null}}",
        "priority": "{{body.priority:int:null}}"
      }
    }
  },
  "response_path": "data.issueCreate.issue"
}

When a client sends a REST request:

POST /proxy/issues
Content-Type: application/json
 
{
  "title": "Implement OAuth flow",
  "team_id": "team_eng_01",
  "priority": 2
}

The configuration maps the REST body keys (snake_case) to the GraphQL input fields (camelCase). The :int coercion ensures priority is sent as the integer 2, not the string "2". If a field is omitted in the REST request, the :null default ensures it is sent as null in the variables, which matches Linear's strict schema requirements.

Updates (PATCH /proxy/issues/:id) work similarly but combine path parameters and body payloads:

"update": {
  "method": "post",
  "path": "/graphql",
  "body_format": "json",
  "body": {
    "query": "mutation($input: IssueUpdateInput!, $issueUpdateId: String!) { issueUpdate(input: $input, id: $issueUpdateId) { issue { id } } }",
    "variables": {
      "issueUpdateId": "{{id}}",
      "input": {
        "title": "{{body.title:str:null}}",
        "priority": "{{body.priority:int:null}}",
        "stateId": "{{body.state_id:str:null}}"
      }
    }
  },
  "response_path": "data.issueUpdate.issue"
}

Here, {{id}} grabs the issue ID from the URL path, while the input object is populated from the request body.

A critical nuance for updates: In Linear's GraphQL API, sending null for an update input field means "no-op" (do not change this field). This is why :str:null is safe to use for omitted fields. However, other GraphQL APIs might interpret null as a command to clear the field. In those cases, you would use the :undefined modifier (e.g., {{body.title:str:undefined}}) so the placeholder engine strips the key entirely when the value is missing.

Translating between REST and GraphQL isn't without its edge cases.

  • HTTP 200 Errors: GraphQL APIs typically return a 200 OK status even when an error occurs, embedding the error details in the response body. We handle this using an error_expression—a JSONata expression that inspects the raw response and throws the appropriate HTTP error if it detects GraphQL errors.
  • Null vs. Omitted Fields: In many GraphQL APIs, sending null explicitly clears a field, while omitting it leaves it unchanged. When updating a resource, you must choose your defaults carefully (:null vs. :undefined) based on the provider's specific semantics.
  • Pagination: We handle pagination—a topic we've explored in our guide to declarative pagination—by extracting the cursor from the response and exposing it in the context for the next request. The client uses standard REST pagination (?limit=20&next_cursor=...), and the proxy injects the cursor into the GraphQL variables.

The Power of Config-Driven Architecture

The beauty of this approach is that there is zero custom code required per integration or per resource. The entire REST-to-GraphQL conversion lives in the configuration.

By treating the translation layer as a data problem rather than a code problem, we can rapidly support new GraphQL APIs and provide a consistent, predictable REST interface for our users, effectively tackling the schema normalization problem. You get the power and flexibility of the underlying GraphQL API without having to write a single line of GraphQL query strings in your application code.

FAQ

How does Truto handle GraphQL APIs?
Truto uses a config-driven Proxy API that maps standard REST endpoints to specific GraphQL queries and mutations using template placeholders and response paths.
Do I need to write GraphQL queries to use Truto?
No. Truto abstracts the GraphQL complexity. You interact with familiar REST endpoints (GET, POST, PATCH, DELETE) and Truto handles the translation to GraphQL internally.
How does Truto handle GraphQL errors that return a 200 status?
Truto uses JSONata expressions (error_expression) to inspect the raw GraphQL response body. If it detects an error array, it maps it to the appropriate HTTP error code.
How does pagination work with GraphQL APIs in Truto?
Truto extracts the cursor from the GraphQL response and exposes it to the client. The client passes the cursor in the REST query string, which Truto then injects into the next GraphQL request.

More from our Blog