API gateway implementing the Strangler Fig pattern. Receives legacy REST requests, maps them to GraphQL variables, executes against a GraphQL endpoint, and maps the response back to a legacy REST JSON structure.
REST client → restql → GraphQL API → restql → REST client
- Docker
- Bun (for local type checking)
compose.yml
services:
restql:
image: suprbdev/restql:latest
restart: unless-stoppedcompose.override.yml
networks:
proxy:
external: true
services:
restql:
networks:
- proxy
- default
environment:
GRAPHQL_ENDPOINT: "http://your-graphql-service:5000/graphql"
VIRTUAL_HOST: "myproject.localhost"
VIRTUAL_PORT: "3000"
volumes:
- ./routes:/app/routes
- ./mappings:/app/mappingsInstall @suprbdev/restql locally so your editor and LSP can resolve types:
bun add @suprbdev/restql graphql-request graphql{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "bundler",
"strict": true,
"skipLibCheck": true,
"types": ["bun-types"]
},
"include": ["routes/**/*", "mappings/**/*"]
}mappings/product.ts — define the GraphQL response shape and how to transform it to your legacy format:
export interface GqlProduct {
id: string;
name: string;
priceInCents: number;
createdAt: string;
}
export interface LegacyProduct {
id: string;
name: string;
price: number;
created_at: string;
}
export function mapBaseProduct(p: GqlProduct): LegacyProduct {
return {
id: p.id,
name: p.name,
price: p.priceInCents / 100,
created_at: p.createdAt,
};
}routes/product.ts — define endpoints as a config array. Each entry is one HTTP endpoint:
import { gql } from "graphql-request";
import { defineRoutes } from "@suprbdev/restql";
import { mapBaseProduct, type GqlProduct } from "../mappings/product.js";
interface ListProductsResponse {
products: { nodes: GqlProduct[] };
}
interface GetProductResponse {
productById: GqlProduct | null;
}
export default defineRoutes([
{
method: "GET",
path: "/products",
query: gql`
query ListProducts($first: Int = 20) {
products(first: $first) {
nodes { id name priceInCents createdAt }
}
}
`,
schema: {
querystring: {
type: "object",
properties: {
limit: { type: "integer", minimum: 1, maximum: 100, default: 20 },
},
},
},
vars: (req) => ({ first: req.query["limit"] ?? 20 }),
transform: (data: ListProductsResponse) => ({
data: data.products.nodes.map(mapBaseProduct),
total: data.products.nodes.length,
}),
},
{
method: "GET",
path: "/products/:id",
query: gql`
query GetProduct($id: UUID!) {
productById(id: $id) { id name priceInCents createdAt }
}
`,
schema: {
params: {
type: "object",
required: ["id"],
properties: { id: { type: "string", format: "uuid" } },
},
},
vars: (req) => ({ id: req.params["id"] }),
notFound: (data: GetProductResponse) => !data.productById,
transform: (data: GetProductResponse) => ({ data: mapBaseProduct(data.productById!) }),
},
{
method: "POST",
path: "/products",
query: gql`
mutation CreateProduct($input: CreateProductInput!) {
createProduct(input: $input) {
product { id name priceInCents createdAt }
}
}
`,
schema: {
body: {
type: "object",
required: ["name", "price"],
properties: {
name: { type: "string" },
price: { type: "number" },
},
},
},
vars: (req) => ({
input: {
product: {
name: req.body["name"],
priceInCents: Math.round((req.body["price"] as number) * 100),
},
},
}),
transform: (data: { createProduct: { product: GqlProduct } }) => ({
data: mapBaseProduct(data.createProduct.product),
}),
},
]);docker compose up -dRoutes are picked up automatically. Restart the container after adding new route files.
my-project/
├── routes/
│ └── product.ts
├── mappings/
│ └── product.ts
├── package.json
├── tsconfig.json
├── compose.yml
└── compose.override.yml
Route files can be nested for organisation — all files under routes/ are loaded recursively:
routes/
├── product.ts → /products, /products/:id
└── admin/
└── report.ts → /admin/reports
| Field | Required | Description |
|---|---|---|
method |
yes | HTTP method: GET, POST, PUT, PATCH, DELETE |
path |
yes | URL path, supports Fastify params (:id) |
query |
yes | GraphQL query or mutation string |
vars |
yes | (req) => object — builds GraphQL variables from the request |
transform |
yes | (data) => object — shapes the GraphQL response into legacy REST format |
schema |
no | Fastify JSON schema for params, querystring, or body — enables coercion and validation |
notFound |
no | (data) => boolean — return true to send a 404 response |
| Variable | Default | Description |
|---|---|---|
PORT |
3000 |
Port the server listens on |
HOST |
0.0.0.0 |
Host the server binds to |
GRAPHQL_ENDPOINT |
http://localhost:5000/graphql |
GraphQL endpoint to proxy to |
API_PREFIX |
/api |
URL prefix for all routes |
ROUTES_DIR |
/app/routes |
Path to route files inside the container |
LOG_LEVEL |
info |
Fastify log level |
- Bun
- Docker
git clone <this repo>
cd restql
make installmake dev # run with file watching
make typecheck # type checkmake up # start detached
make logs # tail logs
make restart # rebuild image and restart
make down # stop
make shell # shell into containerPlace your own routes/ and mappings/ in the project root — they are gitignored and bind-mounted into the container via compose.override.yml.
restql/
├── src/
│ ├── server.ts # Entry point
│ ├── index.ts # Public package exports
│ └── lib/
│ └── defineRoutes.ts # Core framework
├── routes/ # Dev routes (gitignored)
├── mappings/ # Dev mappings (gitignored)
├── Dockerfile
├── compose.yml
├── compose.override.yml
└── Makefile
make help Show all commands
make up Start via Docker (detached)
make down Stop containers
make build Rebuild Docker image
make restart Rebuild and restart
make logs Tail gateway logs
make shell Shell into running container
make install Install dependencies locally
make dev Run locally with file watching
make typecheck TypeScript type check