---
title: "Converting GraphQL to REST APIs: A Deep Dive into Truto's Proxy Architecture"
slug: converting-graphql-to-rest-apis-a-deep-dive-into-trutos-proxy-architecture
date: 2026-03-03
author: Uday Gajavalli
categories: [Engineering]
excerpt: Learn how Truto's config-driven Proxy API seamlessly translates GraphQL-backed integrations into standard RESTful CRUD resources without writing custom code.
tldr: Truto uses a config-driven approach with placeholder substitution to automatically translate standard REST CRUD requests into GraphQL queries and mutations.
canonical: https://truto.one/blog/converting-graphql-to-rest-apis-a-deep-dive-into-trutos-proxy-architecture/
---

# 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](https://truto.one/blog/your-unified-apis-are-lying-to-you-the-hidden-cost-of-rigid-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`](https://www.npmjs.com/package/@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`](https://www.npmjs.com/package/@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](https://truto.one/images/content/converted-linear-rest-apis-in-truto-ui.png)

### The Resource Configuration

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

```json
{
  "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:

```http
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:

```json
{
  "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:

```http
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:

```json
"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:

```http
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:

```json
"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.

## Navigating the Quirks of GraphQL

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](https://truto.one/blog/declarative-pagination-system-in-truto-unified-real-time-api/)—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](https://truto.one/blog/why-schema-normalization-is-the-hardest-problem-in-saas-integrations/). 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.

> Want to stop writing custom integration code? Let Truto handle the heavy lifting of API normalization.
>
> [Talk to us](https://cal.com/truto/partner-with-truto)
