Skip to content

REST Transport

FraiseQL’s REST transport exposes HTTP endpoints from your compiled schema. Resources are derived at compile time from CQRS naming conventions — most endpoints require zero annotation. Override auto-derived routes with explicit rest_path and rest_method annotations when needed.

REST shares the same compiled schema, Executor, connection pool, auth middleware, and rate limiting as GraphQL. There is no separate service to run.

Add rest_path and rest_method parameters to any @fraiseql.query or @fraiseql.mutation decorator to override auto-derived routes.

@fraiseql.query(
sql_source="v_post",
rest_path="/posts",
rest_method="GET",
)
def posts(limit: int = 10) -> list[Post]: ...
@fraiseql.query(
sql_source="v_post",
rest_path="/posts/{id}",
rest_method="GET",
)
def post(id: UUID) -> Post: ...
@fraiseql.mutation(
sql_source="create_post",
operation="CREATE",
rest_path="/posts",
rest_method="POST",
)
def create_post(title: str, author_id: UUID) -> Post: ...

Operations without rest_path rely on auto-derived routes from CQRS naming conventions. GraphQL-only operations remain GraphQL-only.

Curly-brace placeholders in rest_path extract path parameters:

@fraiseql.query(
sql_source="v_post",
rest_path="/posts/{id}",
rest_method="GET",
)
def post(id: UUID) -> Post: ...

GET /rest/v1/posts/abc-123 extracts id = "abc-123" and coerces it to UUID automatically. Type coercion follows GraphQL scalar rules — invalid values return 400.

Two compile-time constraints apply:

  • Duplicate (method, path) pairs are rejected at compile time.
  • Path parameter names must match function argument names exactly.

For single-resource routes, the REST handler auto-detects the ID parameter: the first required non-nullable argument of type Int, ID, or UUID whose name matches id, pk, or pk_*. If no match, the heuristic falls back to pk_* fields on the return type.

When your path parameter uses a different name, simply match it in the function signature:

@fraiseql.query(
sql_source="v_post",
rest_path="/posts/{slug}",
rest_method="GET",
)
def post_by_slug(slug: str) -> Post: ...

GET requests map query string parameters to function arguments:

GET /rest/v1/posts?limit=10&offset=0

maps to posts(limit: 10, offset: 0).

Three filtering syntaxes are supported, from simple to complex:

GET /rest/v1/users?name=Alice&status=active

Converted to {"name": {"eq": "Alice"}, "status": {"eq": "active"}} internally.

GET /rest/v1/users?name[icontains]=Ali&age[gte]=18

Bracket operators map to the FraiseQL operator registry:

OperatorSQLExample
eq=?name[eq]=Alice
neq!=?status[neq]=archived
gt, gte, lt, lte>, >=, <, <=?age[gte]=18
in, ninIN, NOT IN?status[in]=active,pending
contains@> (JSONB)?tags[contains]=rust
icontainsILIKE '%...%'?name[icontains]=ali
startswith, endswithLIKE patterns?name[startswith]=A
like, ilikeLIKE, ILIKE?name[ilike]=a%25ce
is_nullIS NULL?deleted_at[is_null]=true

Rich filter operators from semantic scalar types are also available. For example, ?email[domainEq]=example.com or ?location[distanceWithin]=....

For complex queries, use the ?filter= parameter with the full FraiseQL where DSL (same JSON shape as the GraphQL where input):

GET /rest/v1/users?filter={"name":{"startsWith":"A"},"age":{"gte":18}}

The JSON filter accepts the full 50+ operator set including JSONB containment, vector distance, full-text search, ltree, network/IP, and range operators.

Combine filters with or=(), and=(), and not=():

GET /rest/v1/users?or=(status[eq]=active,status[eq]=pending)
GET /rest/v1/users?and=(age[gte]=18,verified[eq]=true)
GET /rest/v1/users?or=(and=(age[gte]=18,active[eq]=true),name[eq]=admin)

Logical groups can be nested and combined with regular filter parameters. Nesting depth is enforced.

  • Maximum size: configurable via max_filter_bytes (default 4096)
  • Field names validated against the type definition — unknown fields return 400 with available field list
  • Operators validated against the operator registry — unknown operators return 400 with available operator list
GET /rest/v1/users?sort=name # ascending
GET /rest/v1/users?sort=-name # descending (- prefix)
GET /rest/v1/users?sort=name,-age # multi-column

Sort fields are validated against the resource’s type definition.

GET /rest/v1/users?select=id,name,email

Omitting select returns all fields. Flat fields only in v1 — selecting a nested field (e.g., address) returns the full nested object.

Standard queries use limit and offset:

GET /rest/v1/posts?limit=10&offset=20

Response includes meta.limit, meta.offset, and navigation links:

{
"data": [ ... ],
"meta": { "limit": 10, "offset": 20 },
"links": {
"self": "/rest/v1/posts?limit=10&offset=20",
"next": "/rest/v1/posts?limit=10&offset=30",
"prev": "/rest/v1/posts?limit=10&offset=10",
"first": "/rest/v1/posts?limit=10&offset=0"
}
}

Add Prefer: count=exact to include meta.total and links.last. Without it, no count query is issued.

Relay queries use first/after (or last/before):

GET /rest/v1/users?first=10&after=eyJpZCI6NDJ9
{
"data": [ ... ],
"meta": {
"hasNextPage": true,
"hasPreviousPage": false
},
"links": {
"self": "/rest/v1/users?first=10",
"next": "/rest/v1/users?first=10&after=eyJpZCI6NDJ9"
}
}

Relay vs offset is determined at compile time. Cross-pagination guards apply: a relay query receiving ?limit=/?offset= returns 400 (“use first/after for cursor-based pagination on this endpoint”), and vice versa.

limit values are clamped to max_page_size (default: 100). When not specified, default_page_size (default: 20) is used.

All REST responses use a JSON envelope:

Operation typeEnvelope shape
Query returning single object{ "data": {...} }
Query returning list{ "data": [...], "meta": {...}, "links": {...} }
Mutation (create){ "data": {...} } + Location header + 201
Mutation (update){ "data": {...} }
Mutation (delete, no_content)empty body + 204
Mutation (delete, entity){ "data": {...} }

Null results for single-object endpoints return 404.

GET responses include an ETag header (xxHash64 of JSON body, hex-encoded, W/"..." weak validator). Send If-None-Match to receive 304 Not Modified:

Terminal window
# First request — returns full response + ETag
curl -i https://api.example.com/rest/v1/posts/abc-123
# ETag: W/"a1b2c3d4e5f6g7h8"
# Subsequent request — returns 304 if unchanged
curl -H 'If-None-Match: W/"a1b2c3d4e5f6g7h8"' \
https://api.example.com/rest/v1/posts/abc-123

ETag support integrates with FraiseQL’s query result cache for zero-cost validation on cache hits. Disable with etag = false in [rest].

The Prefer header controls response behavior. Multiple preferences can be combined:

Prefer: return=representation, count=exact
PreferenceValuesEffect
returnrepresentation, minimalControls mutation response body
countexactInclude meta.total in collections (parallel COUNT query)
resolutionmerge-duplicates, ignore-duplicatesUpsert behavior for bulk operations
max-affectedintegerSafety guard for bulk PATCH/DELETE — returns 400 if more rows would be affected

Response includes Preference-Applied listing honored preferences. Unknown preferences are silently ignored per RFC 7240.

The compiler classifies update mutations based on writable field coverage:

  • Full-coverage (mutation accepts all writable fields): generates both PUT and PATCH on the canonical resource path. PUT requires all writable fields; PATCH accepts any subset.
  • Partial-coverage (mutation accepts a subset): generates PATCH only, mounted as an action sub-resource (e.g., PATCH /rest/v1/users/{id}/update-email).

Writable fields exclude pk_* (auto-generated identity), id (auto-generated or function-set), computed fields, and auto-generated fields.

PUT requests with missing required writable fields return 422 with field-level error details.

ErrorHTTP Status
Malformed request / unknown param / unknown operator400
Authentication failure401
Permission denied403
Not found404
Method not allowed405
Unique constraint conflict409
Validation error / type mismatch / PUT missing fields422
Rate limited429
Server error500

All error responses use the structured envelope:

{
"error": {
"code": "NOT_FOUND",
"message": "Post not found",
"details": {}
}
}

The compiler generates an OpenAPI 3.0.3 spec from your schema. No manual YAML required.

Compile-time (canonical):

Terminal window
fraiseql-cli openapi schema.compiled.json -o openapi.json

Runtime (convenience):

Available at GET /rest/v1/openapi.json (configurable via openapi_path) when openapi_enabled = true in [rest].

Import into Swagger UI, Postman, or any OpenAPI-compatible tool.

[rest]
enabled = true
path = "/rest/v1" # base URL path (default: "/rest/v1")
require_auth = false # require OIDC/JWT for all REST endpoints
include = [] # whitelist operations (empty = all)
exclude = [] # blacklist operations
delete_response = "no_content" # "no_content" (204) or "entity" (200 + body)
max_page_size = 100 # maximum limit value
default_page_size = 20 # default limit when not specified
etag = true # enable ETag / If-None-Match
max_filter_bytes = 4096 # maximum ?filter= JSON size
max_embedding_depth = 3 # maximum nesting depth for ?select=posts(comments(...))
openapi_enabled = true # serve OpenAPI spec
openapi_path = "/rest/v1/openapi.json"
title = "My API"
api_version = "1.0.0"

See TOML Configuration Reference for all fields.

REST transport requires the rest-transport Cargo feature at compile time. The published Docker image from ghcr.io/fraiseql/server:latest includes it. When building from source:

Terminal window
cargo build --features rest-transport
  1. List all posts

    Terminal window
    curl https://api.example.com/rest/v1/posts?limit=20
    {
    "data": [
    { "id": "550e8400-...", "title": "Hello World", "body": "..." },
    { "id": "6ba7b810-...", "title": "Second Post", "body": "..." }
    ],
    "meta": { "limit": 20, "offset": 0 },
    "links": {
    "self": "/rest/v1/posts?limit=20&offset=0",
    "next": null,
    "prev": null,
    "first": "/rest/v1/posts?limit=20&offset=0"
    }
    }
  2. Get a post by ID

    Terminal window
    curl https://api.example.com/rest/v1/posts/550e8400-e29b-41d4-a716-446655440000
    {
    "data": { "id": "550e8400-...", "title": "Hello World", "body": "..." }
    }

    Returns 404 if not found.

  3. Create a post

    Terminal window
    curl -X POST https://api.example.com/rest/v1/posts \
    -H "Authorization: Bearer $TOKEN" \
    -H "Content-Type: application/json" \
    -d '{"title": "My First Post", "authorId": "user-uuid-here"}'
    {
    "data": { "id": "c3d4e5f6-...", "title": "My First Post", "authorId": "user-uuid-here" }
    }

    Returns 201 with Location: /rest/v1/posts/c3d4e5f6-... header.

  4. Update a post

    Terminal window
    curl -X PATCH https://api.example.com/rest/v1/posts/550e8400-e29b-41d4-a716-446655440000 \
    -H "Authorization: Bearer $TOKEN" \
    -H "Content-Type: application/json" \
    -d '{"title": "Updated Title"}'
    {
    "data": { "id": "550e8400-...", "title": "Updated Title", "body": "..." }
    }
  5. Delete a post

    Terminal window
    curl -X DELETE https://api.example.com/rest/v1/posts/550e8400-e29b-41d4-a716-446655440000 \
    -H "Authorization: Bearer $TOKEN"

    Returns 204 No Content by default. Use Prefer: return=representation to get the deleted entity.

  6. Filter and sort

    Terminal window
    curl 'https://api.example.com/rest/v1/posts?title[icontains]=graphql&sort=-created_at&limit=5'
    {
    "data": [
    { "id": "...", "title": "Getting Started with GraphQL", "created_at": "..." }
    ],
    "meta": { "limit": 5, "offset": 0 },
    "links": { "self": "/rest/v1/posts?title[icontains]=graphql&sort=-created_at&limit=5", "next": null, "prev": null, "first": "/rest/v1/posts?title[icontains]=graphql&sort=-created_at&limit=5" }
    }

REST endpoints share the same Executor, auth, and rate limiting as GraphQL. No new SQL is generated — REST uses the same JSON-shaped views that serve GraphQL queries.

Fetch related resources in a single request using parenthesized ?select= syntax:

GET /rest/v1/users?select=id,name,posts(id,title,comments(id,body))
{
"data": [
{
"id": "...", "name": "Alice",
"posts": [
{ "id": "...", "title": "Hello", "comments": [{ "id": "...", "body": "Great post" }] }
]
}
],
"meta": { "limit": 20, "offset": 0 },
"links": { ... }
}

Embedding supports:

  • Nesting up to max_embedding_depth (default 3, configurable in [rest])
  • Cardinality-aware: one-to-many returns arrays, many-to-one returns an object or null
  • Rename syntax: ?select=author:fk_user(id,name) — rename the embedded field
  • Count syntax: ?select=posts.count — return a count instead of the full collection

Relationships are inferred from foreign key naming conventions (fk_userpk_user on tb_user).

Multi-row inserts, filter-based updates, and filter-based deletes:

Terminal window
# Bulk insert
curl -X POST https://api.example.com/rest/v1/users/bulk \
-H "Content-Type: application/json" \
-d '[{"name": "Alice", "email": "alice@a.com"}, {"name": "Bob", "email": "bob@b.com"}]'
# Bulk update (filter-based)
curl -X PATCH https://api.example.com/rest/v1/users?status[eq]=inactive \
-H "Content-Type: application/json" \
-H "Prefer: return=representation, max-affected=100" \
-d '{"status": "archived"}'
# Bulk delete (filter-based)
curl -X DELETE https://api.example.com/rest/v1/users?status[eq]=archived \
-H "Prefer: max-affected=100"

Bulk operations support:

  • Upsert via Prefer: resolution=merge-duplicates or resolution=ignore-duplicates
  • Safety guard via Prefer: max-affected=N — returns 400 if more rows would be affected (default cap: 1000)
  • Dry run via Prefer: tx=rollback — executes in a transaction then rolls back
  • Response includes X-Rows-Affected header

For large result sets, request Newline-Delimited JSON:

Terminal window
curl -H "Accept: application/x-ndjson" \
https://api.example.com/rest/v1/events?limit=100000

Response (chunked transfer encoding):

{"id":"...","type":"click","timestamp":"2026-03-20T08:00:00Z"}
{"id":"...","type":"view","timestamp":"2026-03-20T08:00:01Z"}
...

The server streams rows in batches using a database cursor (FETCH FORWARD N in a transaction). Memory usage is constant regardless of result set size — only one batch is buffered at a time.

Returns one JSON object per line with Content-Type: application/x-ndjson. No envelope — each line is a standalone JSON object. Pagination parameters (offset, first/after) and Prefer: count=exact are not compatible with NDJSON streaming.

POST mutations support idempotent retries via the Idempotency-Key header:

Terminal window
curl -X POST https://api.example.com/rest/v1/orders \
-H "Idempotency-Key: 550e8400-e29b-41d4-a716-446655440000" \
-H "Content-Type: application/json" \
-d '{"product_id": "...", "quantity": 1}'
  • Same key + same body → replays the stored response (no duplicate creation)
  • Same key + different body → 422 IDEMPOTENCY_CONFLICT
  • Keys expire after 24 hours by default. Configure via idempotency_ttl_seconds in [rest]:
    [rest]
    idempotency_ttl_seconds = 43200 # 12 hours
  • Only applies to POST mutations (Insert + custom actions). PUT and DELETE are naturally idempotent.

In-memory (default): Idempotency keys are stored in a per-process DashMap with TTL expiry. Suitable for single-replica deployments or when clients use sticky sessions.

Redis (multi-replica): When the redis-apq or redis-rate-limiting Cargo feature is enabled and REDIS_URL is set, idempotency keys are automatically stored in Redis. Key format: fraiseql:idempotency:{key}:{mutation_name}.

If Redis is configured but unavailable, the server falls back to in-memory storage (fail-open) and logs a warning.

GET responses include Cache-Control and Vary headers:

  • Authenticated requests: Cache-Control: private, max-age={ttl}
  • Unauthenticated requests: Cache-Control: public, max-age={ttl}
  • Mutations: Cache-Control: no-store
  • Vary: Authorization, Accept, Prefer (always included on GETs)

Per-query cache TTL can be set via cache_ttl_seconds in fraiseql.config(). The default TTL is configurable via default_cache_ttl in [rest] (default: 60 seconds).

[rest]
default_cache_ttl = 120 # 2-minute max-age on GET responses

Set cdn_max_age to add an s-maxage directive to Cache-Control headers on public GET responses. This tells CDN proxies (Cloudflare, Fastly, CloudFront) to cache for a different duration than browsers.

[rest]
default_cache_ttl = 60 # Browser cache: 1 minute
cdn_max_age = 300 # CDN cache: 5 minutes

Response header:

Cache-Control: public, max-age=60, s-maxage=300
Vary: Authorization, Accept, Prefer

When cdn_max_age is not set, no s-maxage directive is included. Mutations always return Cache-Control: no-store regardless of config.

Limit the number of rows affected by bulk PATCH/DELETE operations:

[rest]
max_bulk_affected = 500 # Limit bulk DELETE/PATCH to 500 rows

Subscribe to real-time entity changes via Server-Sent Events:

Terminal window
curl -N -H "Accept: text/event-stream" \
https://api.example.com/rest/v1/orders/stream

Response (SSE wire format):

event: insert
id: 550e8400-e29b-41d4-a716-446655440000
data: {"id":"...","status":"pending","total":99.99}
event: update
id: 6ba7b810-9dad-11d1-80b4-00c04fd430c8
data: {"id":"...","status":"shipped","total":99.99}
event: ping
data:

Each resource gets a /{resource}/stream endpoint automatically. The server subscribes to observer events for the resource’s entity type and forwards them as SSE events.

  • Event types: insert, update, delete, custom, ping (heartbeat)
  • Reconnection: Send Last-Event-ID header to resume from a specific event
  • Heartbeat: ping events are sent every 30 seconds to keep the connection alive
  • Requires: The observers feature must be enabled. Without it, the endpoint returns 501 Not Implemented.

Search across fields marked as searchable in the type definition:

GET /rest/v1/posts?search=graphql+tutorial

The server uses PostgreSQL’s websearch_to_tsquery() for natural-language search syntax. Multiple searchable fields are combined with OR — a match in any field returns the row.

When ?search= is active and no explicit ?sort= is provided, results are ordered by relevance (_relevance desc).

Combine with regular filters:

GET /rest/v1/posts?search=rust&status[eq]=published&limit=10

The search clause is ANDed with other filters.

  • Prefer: handling=strict/lenient: strict mode rejects unknown parameters; lenient ignores them

See REST vs GraphQL for guidance on when to expose operations via REST, GraphQL, or both. See REST API Reference for the full endpoint reference.