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.
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/mutationstring and typedvariables.
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:
- Proxy API Router: Maps incoming REST routes (like
GET /proxy/issues) to internal handlers. - 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. - Integration Config: Defines the base URL and the specific GraphQL templates for each operation.
@truto/replace-placeholders: A public, MIT-licensed npm package that replaces{{...}}templates with values from the request context.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_pathto extract only the relevant data, stripping away thedataenvelope.
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 asnullrather 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.

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_1The 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.
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 OKstatus even when an error occurs, embedding the error details in the response body. We handle this using anerror_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
nullexplicitly clears a field, while omitting it leaves it unchanged. When updating a resource, you must choose your defaults carefully (:nullvs.: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.