Skip to content

Coming from REST ​

You do not have to move everything at once. A Rayfold server can serve REST-style routes from the same schema, so the usual path is: describe what you have, serve it both ways, and move screens one at a time.

How the ideas map ​

RESTRayfold
A resource and its URL, GET /books/{id}A query, query book(id: ID): Book?, and if you keep the URL, @http(method: GET, path: "/books/{id}")
POST/PUT/PATCH/DELETE handlersCommands. Each carries an idempotency key, so a retry never runs twice
?fields= or a family of endpoints per screenA shape: { title author { name } }. Without one, the type's default view
Several requests per screen, often in wavesOne batch; a later op can use an earlier result ({ "$ref": "1.id" })
OpenAPI written beside the codeThe schema is the contract; GET /rayfold/openapi.json is generated from it
ETag, Cache-Control, 304The same, derived from @cache and the read policies (spec 07)
If-Match and 412ifVersion on commands, or If-Match on bound routes; a conflict is a typed VersionConflict with the current entity
Status codes plus an error body16 error codes, plus the errors a command declares with throws, with typed data
Middleware for authorization@allow/@deny in the schema, evaluated on every path: queries, commands, live updates, MCP
Webhooks or SSE for changeslive: true on any query; commands return patches that keep every client cache current

Start from the document you have ​

If you publish an OpenAPI document, the first draft of the schema can be read from it:

sh
npx @rayfold/cli import openapi openapi.json --out api.rayfold

A GET becomes a query, anything that changes data becomes a command, components.schemas become types, and every operation keeps the URL it already has with @http. A JSON request body keeps its place too: a body that names a schema becomes an input argument (body: input), and a body written inline becomes one argument per property (body: "*"). An object with a non-null id becomes an entity, since the id gives the cache and the patches something to address. Parameters become arguments, including those declared on the path and through $ref. A name like first-name becomes firstName, and the @http path follows the new name.

An optional schema becomes X?. That covers anyOf: [X, { type: "null" }], which is how Rayfold itself publishes one, and OpenAPI 3.0's allOf: [X] beside nullable: true. A document a Rayfold server publishes therefore imports back to the same types.

A renamed parameter or request-body property keeps its original name as its wire name, so existing clients go on sending what they send today:

rayfold
input NewPerson {
  firstName: String @http(name: "first-name")
}

query people(maxCount: Int? @http(name: "max-count")): [Person]
  @http(method: GET, path: "/people")

The bindings read ?max-count= and {"first-name": ...}, and the published OpenAPI document names them that way again (see REST routes and OpenAPI). A response has no wire names, so a renamed field of a result type is the one rename still listed on stderr.

Since 0.2.2

Part of this section came with 0.2.2: request bodies bound with body:, parameters declared on the path or through $ref, names such as first-name renamed with their wire names, and optional schemas read as X?. The 0.2.1 importer binds no request body, skips those parameters, keeps parameter and property names as the document spells them, and reads an optional nested schema as JSON.

The importer does not invent what the document cannot say: what may be cached, who may read what, which errors a command throws, which events it emits. You add those by hand. Everything the importer had to assume is listed on stderr, and stdout carries only the schema.

A migration in five steps ​

  1. Write the schema for the resources you have. Entities for your resources, queries for your GET endpoints, commands for everything that changes data. Keep the names clients already know.
  2. Keep your URLs. Add @http to each operation that has a REST route today: command placeOrder(input: OrderInput): Order @http(method: POST, path: "/orders", body: "*"). Existing clients keep calling the same routes, with the same ETag/If-Match behaviour, now served by the Rayfold runtime.
  3. Move the resolvers. A REST handler becomes a resolver that returns the entity; the loop that fetched each author becomes a batch field loader that gets every book of the level at once.
  4. Move one screen. Replace its requests with one batch through @rayfold/client (or the Kotlin client). Its data now lands in the normalized cache, so other screens reading the same books stay current.
  5. Retire routes by usage, not by version. Mark what is going away with @deprecated(sunset: "2027-06-30"); rayfold check new.rayfold --against old.rayfold refuses removing it before that date.

What to watch for ​

  • Idempotency keys. Commands need a key of 16 to 128 characters. The clients add one; curl users must too, or the route opts out with @idempotent(false). Bound PUT, PATCH and DELETE routes do not need one.
  • Cross-site requests. Rayfold accepts only JSON and its binary format (application/rayfold) as bodies. A browser cannot send either cross-site without a preflight. The server also checks the Origin of every request that can change data. Behind a proxy that rewrites Host, list your public origin in allowedOrigins.
  • Costs. Every batch has a budget (1 000 by default); a list's cost grows with its page size. Raise it for trusted internal callers instead of turning it off.

Released under the Apache-2.0 license.