๐ŸฅฆNdiro
Chapter 8 of 13

The JSON API: both sides of fetch

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.)

The surface

EndpointMethodsDoes
/api/mealsGET, POSTList a window of days (query params) / add a meal (form data + optional photo)
/api/meals/<date>/<meal_id>PUT, DELETEEdit / delete one meal
/api/estimate-fiber, /api/estimate-photoPOSTAI estimates (JSON body / multipart photo)
/api/shares, /api/invitesGET, POSTList / create share links and invites
/api/shares/<token>, /api/invites/<token>DELETERevoke one
/api/settings/nutrient, /api/account/deletePOSTSettings; full account deletion
/s/<token>/mealsGETThe 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 client side of a call

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):

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.

The server side: parse, validate, refuse

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.

Where's the schema?

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).

๐Ÿ˜ From your world

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.

๐Ÿ”ฌ Try it

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.

๐Ÿ