Skip to main content

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:

OperationRouteNotes
Post.CreatePOST /postsbody = the new object
Posts.QueryPUT /postsbody = { "q": "<WebdaQL>" }
Post.GetGET /posts/{slug}
Post.UpdatePUT /posts/{slug}replaces the object
Post.PatchPATCH /posts/{slug}merges the fields sent
Post.DeleteDELETE /posts/{slug}204 No Content
Post.Publish (instance op)PUT /posts/{slug}/publishloads the post, asks canAct(…, "publish")
User.Register (static op)PUT /users/registerasks the static canAct without an object
Publisher.PublishPostPUT /publisher/publishpostservice operation: id lower-cased
Version.GetGET /versioncustom 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 with OFFSET '<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 getPermissionQuery is added to the client query (page 03): anonymous callers only ever see published posts.
  • Private __ fields cannot be queried by clients, and SELECT, UPDATE and DELETE statements are refused.
  • "queryMethod": "GET" on RESTService switches the query route to GET /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​

StatusWhen
200Success with a body
204Success without a body (delete, operations returning void)
400Invalid input (JSON Schema), invalid query, or WebdaError.BadRequest thrown by your code
401WebdaError.Unauthorized (logout without a session)
403canAct refused the action
404Unknown route, missing object, or an object the caller may not read
409WebdaError.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

What's next​

→ 09 — GraphQL