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.
SDK Annotations
Section titled “SDK Annotations”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: ...import { Query, Mutation } from 'fraiseql';
@Query({ sqlSource: 'v_post', restPath: '/posts', restMethod: 'GET' })async function posts(limit: number = 10): Promise<Post[]> {}
@Mutation({ sqlSource: 'create_post', operation: 'CREATE', restPath: '/posts', restMethod: 'POST' })async function createPost(title: string, authorId: string): Promise<Post> {}fraiseql.NewQuery("posts"). SQLSource("v_post"). RESTPath("/posts"). RESTMethod("GET"). Build()Operations without rest_path rely on auto-derived routes from CQRS naming conventions. GraphQL-only operations remain GraphQL-only.
Path Parameters
Section titled “Path Parameters”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.
ID Detection
Section titled “ID Detection”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: ...Query Parameters
Section titled “Query Parameters”GET requests map query string parameters to function arguments:
GET /rest/v1/posts?limit=10&offset=0maps to posts(limit: 10, offset: 0).
Filtering
Section titled “Filtering”Three filtering syntaxes are supported, from simple to complex:
Simple equality
Section titled “Simple equality”GET /rest/v1/users?name=Alice&status=activeConverted to {"name": {"eq": "Alice"}, "status": {"eq": "active"}} internally.
Bracket operator syntax
Section titled “Bracket operator syntax”GET /rest/v1/users?name[icontains]=Ali&age[gte]=18Bracket operators map to the FraiseQL operator registry:
| Operator | SQL | Example |
|---|---|---|
eq | = | ?name[eq]=Alice |
neq | != | ?status[neq]=archived |
gt, gte, lt, lte | >, >=, <, <= | ?age[gte]=18 |
in, nin | IN, NOT IN | ?status[in]=active,pending |
contains | @> (JSONB) | ?tags[contains]=rust |
icontains | ILIKE '%...%' | ?name[icontains]=ali |
startswith, endswith | LIKE patterns | ?name[startswith]=A |
like, ilike | LIKE, ILIKE | ?name[ilike]=a%25ce |
is_null | IS 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]=....
JSON filter DSL
Section titled “JSON filter DSL”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.
Logical operators
Section titled “Logical 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.
Filter validation
Section titled “Filter validation”- 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
Sorting
Section titled “Sorting”GET /rest/v1/users?sort=name # ascendingGET /rest/v1/users?sort=-name # descending (- prefix)GET /rest/v1/users?sort=name,-age # multi-columnSort fields are validated against the resource’s type definition.
Field Selection
Section titled “Field Selection”GET /rest/v1/users?select=id,name,emailOmitting select returns all fields. Flat fields only in v1 — selecting a nested field (e.g., address) returns the full nested object.
Pagination
Section titled “Pagination”Offset-based
Section titled “Offset-based”Standard queries use limit and offset:
GET /rest/v1/posts?limit=10&offset=20Response 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.
Cursor-based (relay)
Section titled “Cursor-based (relay)”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.
Response Format
Section titled “Response Format”All REST responses use a JSON envelope:
| Operation type | Envelope 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.
ETag and Conditional Requests
Section titled “ETag and Conditional Requests”GET responses include an ETag header (xxHash64 of JSON body, hex-encoded, W/"..." weak validator). Send If-None-Match to receive 304 Not Modified:
# First request — returns full response + ETagcurl -i https://api.example.com/rest/v1/posts/abc-123# ETag: W/"a1b2c3d4e5f6g7h8"
# Subsequent request — returns 304 if unchangedcurl -H 'If-None-Match: W/"a1b2c3d4e5f6g7h8"' \ https://api.example.com/rest/v1/posts/abc-123ETag support integrates with FraiseQL’s query result cache for zero-cost validation on cache hits. Disable with etag = false in [rest].
Prefer Header
Section titled “Prefer Header”The Prefer header controls response behavior. Multiple preferences can be combined:
Prefer: return=representation, count=exact| Preference | Values | Effect |
|---|---|---|
return | representation, minimal | Controls mutation response body |
count | exact | Include meta.total in collections (parallel COUNT query) |
resolution | merge-duplicates, ignore-duplicates | Upsert behavior for bulk operations |
max-affected | integer | Safety 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.
PUT vs PATCH
Section titled “PUT vs PATCH”The compiler classifies update mutations based on writable field coverage:
- Full-coverage (mutation accepts all writable fields): generates both
PUTandPATCHon the canonical resource path. PUT requires all writable fields; PATCH accepts any subset. - Partial-coverage (mutation accepts a subset): generates
PATCHonly, 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.
Error-to-Status-Code Mapping
Section titled “Error-to-Status-Code Mapping”| Error | HTTP Status |
|---|---|
| Malformed request / unknown param / unknown operator | 400 |
| Authentication failure | 401 |
| Permission denied | 403 |
| Not found | 404 |
| Method not allowed | 405 |
| Unique constraint conflict | 409 |
| Validation error / type mismatch / PUT missing fields | 422 |
| Rate limited | 429 |
| Server error | 500 |
All error responses use the structured envelope:
{ "error": { "code": "NOT_FOUND", "message": "Post not found", "details": {} }}OpenAPI Spec
Section titled “OpenAPI Spec”The compiler generates an OpenAPI 3.0.3 spec from your schema. No manual YAML required.
Compile-time (canonical):
fraiseql-cli openapi schema.compiled.json -o openapi.jsonRuntime (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.
Configuration
Section titled “Configuration”[rest]enabled = truepath = "/rest/v1" # base URL path (default: "/rest/v1")require_auth = false # require OIDC/JWT for all REST endpointsinclude = [] # whitelist operations (empty = all)exclude = [] # blacklist operationsdelete_response = "no_content" # "no_content" (204) or "entity" (200 + body)max_page_size = 100 # maximum limit valuedefault_page_size = 20 # default limit when not specifiedetag = true # enable ETag / If-None-Matchmax_filter_bytes = 4096 # maximum ?filter= JSON sizemax_embedding_depth = 3 # maximum nesting depth for ?select=posts(comments(...))openapi_enabled = true # serve OpenAPI specopenapi_path = "/rest/v1/openapi.json"title = "My API"api_version = "1.0.0"See TOML Configuration Reference for all fields.
Feature Flag
Section titled “Feature Flag”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:
cargo build --features rest-transportFull Blog API Example
Section titled “Full Blog API Example”-
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"}} -
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
404if not found. -
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. -
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": "..." }} -
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 Contentby default. UsePrefer: return=representationto get the deleted entity. -
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.
Nested Resource Embedding
Section titled “Nested Resource Embedding”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_user → pk_user on tb_user).
Bulk Operations
Section titled “Bulk Operations”Multi-row inserts, filter-based updates, and filter-based deletes:
# Bulk insertcurl -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-duplicatesorresolution=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-Affectedheader
NDJSON Streaming
Section titled “NDJSON Streaming”For large result sets, request Newline-Delimited JSON:
curl -H "Accept: application/x-ndjson" \ https://api.example.com/rest/v1/events?limit=100000Response (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.
Idempotency
Section titled “Idempotency”POST mutations support idempotent retries via the Idempotency-Key header:
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_secondsin[rest]:[rest]idempotency_ttl_seconds = 43200 # 12 hours - Only applies to POST mutations (Insert + custom actions). PUT and DELETE are naturally idempotent.
Storage Backends
Section titled “Storage Backends”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.
HTTP Caching
Section titled “HTTP Caching”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 responsesCDN Integration
Section titled “CDN Integration”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 minutecdn_max_age = 300 # CDN cache: 5 minutesResponse header:
Cache-Control: public, max-age=60, s-maxage=300Vary: Authorization, Accept, PreferWhen cdn_max_age is not set, no s-maxage directive is included. Mutations always return Cache-Control: no-store regardless of config.
Bulk Operation Limits
Section titled “Bulk Operation Limits”Limit the number of rows affected by bulk PATCH/DELETE operations:
[rest]max_bulk_affected = 500 # Limit bulk DELETE/PATCH to 500 rowsSSE Streaming
Section titled “SSE Streaming”Subscribe to real-time entity changes via Server-Sent Events:
curl -N -H "Accept: text/event-stream" \ https://api.example.com/rest/v1/orders/streamResponse (SSE wire format):
event: insertid: 550e8400-e29b-41d4-a716-446655440000data: {"id":"...","status":"pending","total":99.99}
event: updateid: 6ba7b810-9dad-11d1-80b4-00c04fd430c8data: {"id":"...","status":"shipped","total":99.99}
event: pingdata: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-IDheader to resume from a specific event - Heartbeat:
pingevents are sent every 30 seconds to keep the connection alive - Requires: The
observersfeature must be enabled. Without it, the endpoint returns501 Not Implemented.
Full-Text Search
Section titled “Full-Text Search”Search across fields marked as searchable in the type definition:
GET /rest/v1/posts?search=graphql+tutorialThe 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=10The search clause is ANDed with other filters.
Coming Soon
Section titled “Coming Soon”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.