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
| REST | Rayfold |
|---|---|
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 handlers | Commands. Each carries an idempotency key, so a retry never runs twice |
?fields= or a family of endpoints per screen | A shape: { title author { name } }. Without one, the type's default view |
| Several requests per screen, often in waves | One batch; a later op can use an earlier result ({ "$ref": "1.id" }) |
| OpenAPI written beside the code | The schema is the contract; GET /rayfold/openapi.json is generated from it |
ETag, Cache-Control, 304 | The same, derived from @cache and the read policies (spec 07) |
If-Match and 412 | ifVersion on commands, or If-Match on bound routes; a conflict is a typed VersionConflict with the current entity |
| Status codes plus an error body | 16 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 changes | live: 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:
npx @rayfold/cli import openapi openapi.json --out api.rayfoldA 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:
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
- Write the schema for the resources you have. Entities for your resources, queries for your
GETendpoints, commands for everything that changes data. Keep the names clients already know. - Keep your URLs. Add
@httpto 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 sameETag/If-Matchbehaviour, now served by the Rayfold runtime. - 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.
- 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. - Retire routes by usage, not by version. Mark what is going away with
@deprecated(sunset: "2027-06-30");rayfold check new.rayfold --against old.rayfoldrefuses removing it before that date.
What to watch for
- Idempotency keys. Commands need a key of 16 to 128 characters. The clients add one;
curlusers must too, or the route opts out with@idempotent(false). BoundPUT,PATCHandDELETEroutes 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 inallowedOrigins. - 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.