The backend and
JavaScript chapters covered the two halves;
this one is about the contract between them. Ndiro's API is a dozen JSON endpoints under
/api/โฆ, designed by one rule: the URL names the
resource, the method names the action, the status code names the
outcome, and errors are always {"error": "human message"}
with a 4xx/5xx status. (One deliberate exception:
_meals_payload degrades a storage failure to a 200 with an
error field and empty days, so the page still renders a
timeline โ an availability-over-strictness call, made once and
commented.)
| Endpoint | Methods | Does |
|---|---|---|
/api/meals | GET, POST | List a window of days (query params) / add a meal (form data + optional photo) |
/api/meals/<date>/<meal_id> | PUT, DELETE | Edit / delete one meal |
/api/estimate-fiber, /api/estimate-photo | POST | AI estimates (JSON body / multipart photo) |
/api/shares, /api/invites | GET, POST | List / create share links and invites |
/api/shares/<token>, /api/invites/<token> | DELETE | Revoke one |
/api/settings/nutrient, /api/account/delete | POST | Settings; full account deletion |
/s/<token>/meals | GET | The same meals payload, scoped by share token instead of session |
Notice there is no user_id anywhere in that table.
Identity comes exclusively from the session cookie (or the share
token's owner); the client cannot even express "give me user
X's meals". That absence is the tenant-isolation design, and
tests/probe_cross_user.py exists to keep it true.
The text estimator shows the full JSON-request pattern in one screen:
templates/log.html โconst resp = await fetch('/api/estimate-fiber', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ description: description })
});
if (resp.status === 401) { // session died: go sign in
window.location.href = '/login?next=/log';
return;
}
const result = await resp.json().catch(() => ({}));
if (!resp.ok) { // any 4xx/5xx: show the server's message
showAiError(result.error || 'Estimate failed.');
return;
}
renderEstimate(result);
Read the error ladder carefully โ it's the pattern to copy for any new fetch (the app's simpler calls, like the settings saves and the auto-log poll, collapse some rungs where less recovery is needed):
next so
they come back).resp.ok is false;
surface the server's error string. The
.catch(() => ({})) guards against a non-JSON error
body.try/catch shows a generic network message. Note fetch
does not reject on 4xx/5xx โ an answered request is a
resolved promise; you must check the status yourself. This
surprises everyone once.Uploads use the other body type: FormData (multipart)
instead of JSON, because photos are binary โ see
estimateFromPhoto and saveMeal. The browser
sets the content type, boundary and encoding automatically.
Every handler treats its input as hostile, and the validation is strict in ways worth studying. The date validator is a lesson in itself:
app.py โdef _valid_date(date_str):
"""Return the string if it is a strictly canonical YYYY-MM-DD date, else None.
strptime alone is lenient ('2026-8-5' parses), which would produce a sort
key that zero-padded month queries never match (silent data loss) and 500
when fed to date.fromisoformat as an anchor. Require the round trip.
"""
A merely-parseable date isn't enough: 2026-8-5 would
happily become a DynamoDB sort key that the zero-padded month query
(begins_with('2026-08-')) never finds again โ accepted
input, silently invisible data. So the rule is canonical form or 400.
The same spirit shows in _nutrients_from_form: reject
NaN and Infinity (they parse as Decimals but
detonate downstream) and cap values at a sanity bound so a
1e999 can't 500 the storage layer. Validation isn't about
the honest user's typos; it's about keeping every reachable state
legal.
Coming from Thrift/Avro/protobuf, you'll notice: there is no IDL. The
contract lives in convention ({"error": โฆ}, the payload
shapes) and is enforced by the stub tests, which drive the real routes
and assert on real payloads. At two-people-and-one-repo scale where both
halves deploy atomically โ the HTML and the API always ship together โ
that's a reasonable trade. The one place a version skew can
happen is a stale open tab, and the API handles it explicitly: the meal
form posts a hidden nutrient_key, and the server rejects a
mismatch with a "reload this page" 400 rather than silently dropping the
typed amount (_stale_nutrient_form).
Rate limiting (the 429s) is admission control, and
the AI cap is a quota system with a two-phase charge: the counter is
incremented before the OpenAI call with a conditional
write, and refunded only on upstream failure โ so a race can't
overspend the quota. It's optimistic concurrency with compensation,
the same shape as a reservation system. The code is
try_consume_ai_use/refund_ai_use in
db.py.
On the log page, open Network, add a meal, and inspect the
POST /api/meals entry: the form-data body, the 200, the
JSON reply. Then use the Console:
await (await fetch('/api/meals?days=2&anchor=2026-09-03')).json()
โ you just called your own API by hand with your session riding
along. Try days=abc and read the 400; then
days=999 and notice it succeeds with 31 days โ the
server clamps out-of-range numbers rather than rejecting them, a
policy choice worth spotting in _meals_payload.