Skip to content
C.W.K.
Lesson 01 of 06 · published

Same Routes, Two Faces

~13 min · api, clients, fastapi, swiftui, react

Level 0Mise en Place
0 XP0/43 lessons0/12 achievements
0/100 XP to next level100 XP to go0% complete

One engine, two faces

The first lines of Sizzle's engine say it plainly: the web client and the iPhone app use the same routes, and cwkPippa is reached only from the engine, over loopback. That sentence sets the shape of this whole track. The engine is one FastAPI process that owns the journal, the mirror, the photos and the reading worker. The web client is a React 19 page the engine serves from its own port. The iPhone and iPad app is one universal SwiftUI target. Neither client holds a brain credential, and neither asks cwkPippa for anything a brain answers. When Pippa is part of an answer, the engine asked her. The engine's header is broader than the code, though: the phone does make one call to cwkPippa, a read of its open host-profile document on the engine's host, which says where the family's services live (lesson 6). That call carries no brain and no conversation.

That makes the route list the real interface of the product. It is short enough to hold in your head:

AreaRoutesWhat they carry
HealthGET /api/healththe app's name and the product version
PlatesGET, POST /api/encounters, GET, PATCH /api/encounters/{id}, POST …/reread, …/corrections, …/linksthe list (up to 500 a page, with an offset), capture, one plate with its reading, Dad's edits
PhotosGET /api/photos/{id}/{display,thumb,original}, POST /api/staged-uploadsphoto bytes that never change, and one photo staged per request
TreeGET /api/tree, GET, PATCH /api/classes/{id}, POST …/adopt, …/rename, …/variantsprinciples, dishes and variants
ThreadsGET /api/threads/{key}, POST …/ask, POST /api/turns/{turn}/actions/{index}/applyDad's conversations, and the button that applies one of Pippa's proposals
Pippa's mediaGET /api/pippa/avatar/{emotion}, GET /api/pippa/uploads/{id}, …/thumbher chef avatar and the images she quotes in an answer, fetched by the engine over loopback and served from Sizzle's own origin
BrainsGET /api/brain-control/slots, …/status, PUT …/slots/{slot}which brain runs each Sizzle function

Everything else is the web shell. The kit's static mount is added last, after every API route, so a mistyped API path stays a 404 and never comes back as an HTML page that the caller then fails to parse as JSON.

Two typed clients, one wire

The web client keeps its types in api.ts: Encounter, EncounterDetail, ClassRow, Turn, and a discriminated union for the six actions Pippa can propose. The phone's Models.swift opens by saying its wire shapes are mirrored from that file. There is no generated SDK and no shared schema package. The two files are kept in step by hand. The Swift file's opening comment says its reading fields decode leniently, so one odd field does not cost the whole plate. That promises more than the code does. Every reading field is an optional on a synthesized Codable struct, so a field that is missing or null decodes as nil. A field of the wrong type, a number where a string belongs, still throws, and the whole plate detail fails to decode. What actually keeps a model's odd JSON off the phone is the engine: it validates every reading against its pydantic rubric before it records one, so the types are settled before any client reads them.

Both clients read a refusal from the same field. FastAPI puts the reason in a detail field, either a string or a list of validation items. The web's call helper turns any answer that is not OK into an ApiError carrying that detail, or the bare status code when the body does not parse. The phone's failure function reads the same field. A string detail, such as kind must be one of out, home, market, other, therefore reads the same on both screens. A validation list does not. The web serializes the whole list with JSON.stringify, so the user sees the raw items, locations and all, while the phone keeps only each item's msg and joins them with semicolons. Sizzle's own refusals are strings, which is why the difference rarely shows: a list appears only when FastAPI rejects a request before any Sizzle code runs.

Different pipes for different weights

The phone does not push every request through one session. EngineClient carries three transports with three policies. JSON gets a 15-second request timeout, 30 seconds per resource and an 8 MB budget. Images get 30 and 60 seconds and 24 MB. Uploads get 60 seconds with no byte moving and 300 seconds for the whole resource, timed by the comment above them for one staged photo or the plate's small form. The byte budget is a different thing in all three. In the kit's HTTPSessionPolicy it caps the bytes a reply may bring back, so the uploads' 1 MB is room for the engine's small JSON answer, not a limit on the photo going up. The transports are injected, so the unit tests drive the same client with a scripted transport and never reach an engine. The UI target is another matter. Its tests launch the real app, and the real app reads its plate list and starts its offline sync through the default transports, against whatever engine it is set to. The photo-removal test asserts nothing about that traffic and passes whether an engine answers or not. Only the opt-in upload benchmark aims the app at an engine on purpose and measures it (track 8, lesson 4). The web uses fetch for JSON and XMLHttpRequest for staging, because fetch cannot report how much of a request body has left (lesson 4).

What the phone does not have

The architecture doc says the phone covers the web's surfaces on the same routes. That is mostly true, and the code shows exactly where it stops:

  • The web's class page has a "Make my version" form that posts to /api/classes/{id}/variants. The phone's client has no variants call. On the phone a variant arrives only as one of Pippa's variant actions, applied from a thread.
  • Neither client ties a plate to an existing class by hand. The web defines api.link and never calls it, and the phone has no such call. That tie is made through Pippa's link action. The web does have one manual tie, inside the variant form: it lists the class's home plates, and the engine links the plate picked there to the new variant it creates.
  • The web shows the verdict box only on variants and the parameters only on dishes and variants. The phone shows both on every class.
  • The kind labels differ. The web says "Eaten out", "Cooked at home" and "Seen at a market"; the phone says "Eating out", "Home" and "Market". The stored values are the same four words.

The phone also has what the web lacks: an outbox, device copies and a native conversation view. Each client is its own projection of the engine. Neither is a copy of the other.

Comments about the other client rot first

One small trap is worth naming. The capture route's docstring still says the web client sends its photos in the request body. The phone's client still carries a multipart capture function whose comment calls it the web client's shape. Both lines were written in 1021db2, and fifteen minutes later 00c4e83 moved the web to staging. Nothing in either client calls that multipart function now. The route keeps accepting a photos body, and the engine's own tests use it, but no client sends one. When code describes a sibling, grep the call sites before you believe it.

Principle: Treat the route list as the interface and each client as one implementation of it. Two implementations may differ in what they show, as long as they agree on every shape they send and every refusal they read.

That is the same idea oo-quest's Track 7 calls polymorphism, applied to a product with a web page and a phone.

Code

cwkSizzle, frontend/src/api.ts: the web's one request helper, which turns a refusal into the engine's own words (verbatim, lines 136-149)·typescript
async function call<T>(path: string, init?: RequestInit): Promise<T> {
  const response = await fetch(path, init);
  if (!response.ok) {
    let detail = `${response.status}`;
    try {
      const body = await response.json();
      detail = typeof body.detail === "string" ? body.detail : JSON.stringify(body.detail);
    } catch {
      /* keep the status */
    }
    throw new ApiError(detail);
  }
  return response.json() as Promise<T>;
}
cwkSizzle, clients/apple/SizzleMobile/Sources/EngineClient.swift: the phone reads the same detail field, string or validation list (verbatim, lines 257-268)·swift
    /// FastAPI puts the reason in `detail`, a string or a validation list.
    static func failure(_ reply: HTTPReply) -> Failure {
        var detail = "HTTP \(reply.statusCode)"
        if let object = try? JSONSerialization.jsonObject(with: reply.data) as? [String: Any] {
            if let text = object["detail"] as? String {
                detail = text
            } else if let items = object["detail"] as? [[String: Any]] {
                detail = items.compactMap { $0["msg"] as? String }.joined(separator: "; ")
            }
        }
        return Failure(status: reply.statusCode, detail: detail)
    }
cwkSizzle, clients/apple/SizzleMobile/Sources/EngineClient.swift: three session policies, one per kind of traffic (verbatim, lines 17-21)·swift
    static let imagePolicy = HTTPSessionPolicy(requestTimeout: 30, resourceTimeout: 60, byteBudget: 24 << 20)
    /// One staged photo (~1 MB) or a plate's small form: 60 s with no byte
    /// moving is a dead link, and 300 s covers a 1.5 MB photo at 5 KB/s.
    static let uploadPolicy = HTTPSessionPolicy(requestTimeout: 60, resourceTimeout: 300, byteBudget: 1 << 20)
    static let jsonPolicy = HTTPSessionPolicy(requestTimeout: 15, resourceTimeout: 30, byteBudget: 8 << 20)

External links

Exercise

Pick an app of yours with two clients, or one client and a script that calls the same server. Write its route list as a table: method, path, and what the route carries. Then mark, for each route, which client calls it. Every route that only one client calls is either a deliberate gap you can write down in one sentence, or a missing feature. Finally, make both clients turn a server refusal into the same message, read from the same field.
Hint
Grep each client for the path strings instead of trusting a doc or a comment. A function that exists but has no call site counts as not called. If your server returns a validation list, join its messages the same way on both sides so the user sees one sentence.

Progress

Progress is local-only — sign in to sync across devices.
Spotted a bug or have feedback on this page?Report an Issue

Comments 0

🔔 Reply notifications (sign in)
Sign in — Please sign in to comment.

No comments yet — be the first.