08 — REST API Tour
Goal: Understand how the REST routes you used in pages 02–07 are derived from the models and services, and how queries, errors and permissions show up over HTTP.
Files touched: (read-only tour — no new files)
Concepts: route derivation, the query operation (PUT on a collection), WebdaQL, status codes, error bodies, OpenAPI.
Walkthrough
Routes come from operations
DomainService registers operations for every model, and RESTOperationsTransport maps each one to a route. The collection path is the model's plural name in camelCase (Post → /posts, UserFollow → /userFollows), followed by the primary key fields:
| Operation | Route | Notes |
|---|---|---|
Post.Create | POST /posts | body = the new object |
Posts.Query | PUT /posts | body = { "q": "<WebdaQL>" } |
Post.Get | GET /posts/{slug} | |
Post.Update | PUT /posts/{slug} | replaces the object |
Post.Patch | PATCH /posts/{slug} | merges the fields sent |
Post.Delete | DELETE /posts/{slug} | 204 No Content |
Post.Publish (instance op) | PUT /posts/{slug}/publish | loads the post, asks canAct(…, "publish") |
User.Register (static op) | PUT /users/register | asks the static canAct without an object |
Publisher.PublishPost | PUT /publisher/publishpost | service operation: id lower-cased |
Version.Get | GET /version | custom rest option on @Operation |
Every operation validates its input against the JSON Schema generated by webdac build, then checks permissions, then runs. The same operations are what GraphQL, gRPC and MCP expose.
When the server runs with webda debug, the OpenAPI document of all these routes is served at GET / (exposeOpenAPI parameter of RESTOperationsTransport).
Queries
PUT on a collection runs a WebdaQL query. A query in the body has no length limit and no URL escaping issues:
curl -s -X PUT http://localhost:18080/posts -H "Content-Type: application/json" \
-d "{\"q\":\"status = 'published' ORDER BY title LIMIT 10\"}" | jq
{
"results": [{ "slug": "hello-world", "title": "Hello World", "status": "published", "...": "..." }],
"continuationToken": "eyJhbGciOi…"
}
{"q": ""}returns everything the caller may read.- When more results may follow, the response has a
continuationToken. Send the same query withOFFSET '<token>'to get the next page:"q": "status = 'published' ORDER BY title LIMIT 10 OFFSET 'eyJhbGciOi…'". The token is only accepted for the same query and the same caller. - The model's
getPermissionQueryis added to the client query (page 03): anonymous callers only ever see published posts. - Private
__fields cannot be queried by clients, andSELECT,UPDATEandDELETEstatements are refused. "queryMethod": "GET"onRESTServiceswitches the query route toGET /posts?q=….
PUT vs PATCH
PUT /posts/{slug} is meant to send the whole object, PATCH only the fields to change. In both cases the key comes from the URL, @readonly and protected attributes (author) are never applied, and the permission is checked on the stored object before anything changes.
Status codes and errors
| Status | When |
|---|---|
200 | Success with a body |
204 | Success without a body (delete, operations returning void) |
400 | Invalid input (JSON Schema), invalid query, or WebdaError.BadRequest thrown by your code |
401 | WebdaError.Unauthorized (logout without a session) |
403 | canAct refused the action |
404 | Unknown route, missing object, or an object the caller may not read |
409 | WebdaError.Conflict (register with a used email) |
Errors share one body format:
curl -s http://localhost:18080/posts/does-not-exist
{ "error": { "code": "NOT_FOUND", "message": "Object not found" } }
Validation errors name the failing field and add the AJV errors under details:
{
"error": {
"code": "BAD_REQUEST",
"message": "User.Patch InvalidInput: /name must NOT have fewer than 2 characters",
"details": {
"errors": [
{
"instancePath": "/name",
"schemaPath": "#/properties/name/minLength",
"keyword": "minLength",
"params": { "limit": 2 },
"message": "must NOT have fewer than 2 characters"
}
]
}
}
}
A missing object and an object you may not read answer the same 404 Object not found: a client cannot probe for other authors' drafts. A readable object whose action is refused answers 403.
Sessions
register and login open a session, kept in a cookie. Send it back on each request (curl -c cookies.txt -b cookies.txt, or credentials: "include" with fetch) and context.getCurrentUserId() returns the user in canAct, prepareCreate and operations.
In 4.0.0-beta.6 the cookie is not written yet: see the note on page 02.
Verify
With the server running, walk the full cycle on a fresh post (logged in as Alice):
curl -s -b cookies.txt -X POST http://localhost:18080/posts -H "Content-Type: application/json" \
-d '{"title":"REST tour","slug":"rest-tour","content":"Walking through every REST route.","status":"draft","viewCount":0}' > /dev/null
curl -s -b cookies.txt -X PATCH http://localhost:18080/posts/rest-tour \
-H "Content-Type: application/json" -d '{"viewCount":10}' | jq .viewCount
# → 10
curl -s -o /dev/null -w "%{http_code}\n" http://localhost:18080/posts/rest-tour
# → 404 (a draft, read anonymously)
curl -s -b cookies.txt -o /dev/null -w "%{http_code}\n" -X DELETE http://localhost:18080/posts/rest-tour
# → 204