A JSON API from flows: nine endpoints, no server

The diary API is nine HTTP endpoints, and not a single one is written in a server-side language. Each endpoint is a platform flow — a graph of condition, graphql, and respond_json nodes bound to a route; Liquid templates the documents in it, while the values are computed by the expr fields (body_expr, variables_expr, predicate_expr).
A flow as an endpoint
A route is a dozen lines of JSON:
{
"path": "/api/entries/save",
"methods": ["POST"],
"cache": { "defaultTtl": 0 },
"membersOnly": true,
"loginPath": "/login"
}
membersOnly hands an anonymous visitor a 403 with the code AUTHENTICATION_REQUIRED, and defaultTtl: 0 switches off response caching — in member areas the two settings travel as a pair. The flow reads the request body from the transport. A JSON client gets its fields under json.*, an HTML form under form.*. The respond_json node builds the response, and it accepts a status as well: 200 on success, 400 with {"ok":false,"error":"INVALID_REQUEST"} for a malformed request.
One detail of the contract is easy to work around once you know about it. The platform gate behaves according to the Accept header: a browser without a cookie gets a 302 to /login, while a client sending Accept: application/json gets an honest 403 in JSON. Our PWA client therefore sends that header with every request and, on a 403, sends the user to the login screen itself.
The diary API contract
| Method and path | What it does |
|---|---|
POST /api/entries/save |
create or update an entry (upsert on client_uuid) |
POST /api/entries/delete |
delete an entry by client_uuid |
GET /api/entries?from=&to= |
the diary for a period, up to 500 entries |
POST /api/foods |
add a personal food |
GET /api/foods?q= |
search: public catalog plus personal foods, 50 per page |
GET /api/profile |
the profile without the password |
POST /api/profile |
partial profile update |
POST /api/weights |
a weight measurement (upsert) |
GET /api/weights |
the last 100 weight measurements |
Condition nodes validate the input before any graphql query runs. client_uuid must be non-empty, date must match ^[0-9]{4}-[0-9]{2}-[0-9]{2}$, and meal must be one of the four meals.
The client_uuid upsert: the core of sync
The client generates a client_uuid when it creates an entry and never changes it afterwards. The flow looks the entry up by key and owner:
entries(where: {client_uuid: {eq: $uuid},
owner: {eq: $member}}, limit: 1) { id }
entriesCount(where: {client_uuid: {eq: $uuid},
owner: {eq: $member}})
A count above zero routes the then branch into updateEntry. Zero sends the flow down the else branch, into createEntry with owner: $member. The flow fills $member server-side from the signed cookie; the client never sends the identifier. We assembled the update branch as a merge: only fields present in the request body make it into the mutation. Liquid builds the graphql arguments piece by piece, and the comma lives inside the condition. Here is the update branch from the real flow:
mutation($id: ID!) {
updateEntry(id: $id, input: {
client_uuid: {{ json.client_uuid | json }},date: {{ json.date | json }},meal: {{ json.meal | json }}
{%- assign fd = json.food_id | strip -%}{% if fd != "" %},food: {{ fd | json }}{% endif %}
{%- assign nm = json.name | strip -%}{% if nm != "" %},name: {{ nm | json }}{% endif %}
{%- assign g = json.grams | number -%}{% if g != nil %},grams: {{ g | json }}{% endif %}
{%- assign k = json.kcal | number -%}{% if k != nil %},kcal: {{ k | json }}{% endif %}
{%- assign p = json.protein | number -%}{% if p != nil %},protein: {{ p | json }}{% endif %}
{%- assign ft = json.fat | number -%}{% if ft != nil %},fat: {{ ft | json }}{% endif %}
{%- assign cb = json.carb | number -%}{% if cb != nil %},carb: {{ cb | json }}{% endif %}
}) { id }
}
If the client left a field out, the old value stays on the server untouched. The same rule drives POST /api/profile: you can send any subset of fields, each under its own assign+strip guard, and the targets and the weight won’t clobber each other.
The Liquid assembly of mutation arguments inside the query text is gone for good: since v1.4.16 Liquid is removed from the query text, the mutation is static, and variables_expr builds the whole variable map. A branch with optional fields is now expressible there too: the stringmap helper assembles the map from toPairs(json), and filter drops absent, null and blank fields — “leave the field alone” is finally expressible:
stringmap(fromPairs(map(filter(toPairs(json),
#[1] != nil && (type(#[1]) != "string" || trim(#[1]) != "")),
[#[0], type(#[1]) == "string" ? trim(#[1]) : #[1]])))
This is the expression in the profile-save variables_expr: a field that came in rides into the mutation, a field that didn’t never reaches the server, and the stored value stays untouched.
This is what makes save idempotent. A repeat with the same uuid returns the same id and creates no duplicates — our e2e check ran save twice with different grams, the ids matched, and the entry count didn’t move. Deleting a uuid that doesn’t exist also answers {"ok":true}, so a repeated DELETE is safe. These properties are exactly what lets the offline queue redeliver the same entry as many times as it needs to.
The diary queries live in the query library. A graphql node carries a library_name label for this: at execution time, the platform substitutes the text from the library entry. Edit the entry, publish, and every flow linked to it is already running the new version — no re-release needed. Not all of our queries started life this way. The library validator accepted only pure GraphQL, and the JSON for update mutations was assembled with Liquid, so the mutations had to stay inline in the nodes. The knot was cut from the other side: since v1.4.16 Liquid is removed from the query text, the mutations became static — and now they live in the library alongside the selects without ceremony. One rule remains: a library entry can’t be deleted while a live flow references it. Cleaning up the query catalog starts with detaching the nodes.
Gotchas
JSON is assembled by hand in Liquid — a legacy build. The body_template shapes the response, and the canon we earned back then: every value passes through the | json filter (it quotes a string, leaves a number a number, and turns emptiness into null), optional keys carry their comma inside the condition, and forloop.last cuts off the tail of the list:
{"ok":true,"items":[{% for i in results.lst.entries %}
{"id":{{ i.id | json }},"date":{{ i.date | json }},
"food_id":{{ i.food.id | json }},...}
{% unless forloop.last %},{% endunless %}{% endfor %}]}
Leave a comma outside the {% if %} and the first entry without a brand guarantees broken JSON.
Today the same answer is one body_expr line on the respond_json node:
{ "ok": true, "items": results.lst.entries }
The list is embedded as data: arrays stay arrays, numbers stay numbers, | json is gone, and invalid JSON is impossible by construction. The Liquid recipe above is history: since v1.4.16 Liquid in the “JSON response” body_template is removed from the engine (the key became a static string), so old template bodies with substitutions no longer exist — a computed answer is built by body_expr alone.
An entity field expands into an object. The first version of the list selected the food field without a sub-selection, and the platform stuffed the entire product into the response — owner, service dates, everything except the id we actually needed. The PWA couldn’t extract a product id from the diary. Two places fix it: food { id } in the query and i.food.id | json in the template. For entries without a food, a response with "food_id": null is valid and expected.
orderBy takes ENUMs. Sorting is written as orderBy: [{field: DATE, direction: ASC}] — uppercase, no quotes. A "date" string doesn’t survive the flow.
Catalog pagination. Food search answers with at most 50 entries per request — enough for suggestions in a search field. The full catalog pool moves in pages: the server caps the output at 100 entries, and the client pulls limit=100 with an offset shift. A catalog of 131 products takes two pages.
The owner=null phantom. In our schema, the public catalog is all foods with an empty owner. One day a “food” with a gibberish name surfaced in the test users’ search results. It was leftover junk from a test run, created without an owner, and the empty owner silently made it public. Since then, we delete test junk on the spot — it stays part of the results for as long as it sits in the database. The phantoms in the client’s local index and the pruneFoods() cleanup are a separate story — that’s part four.


What’s next
The next article is “Offline-first”: how the IndexedDB queue is built, why LWW on client_uuid is enough for a diary, and what happens when two devices conflict.
All posts