api-client

Analysis — the client's reading

Opinion, above conformance. Nothing here is a finding; it is what a careful outside reader would say next, with the evidence each remark rests on. Tags: contract declare it · wire fix it · model a modelling decision. as Markdown

Invariants the contract states, measured

invariantmeasured
availability-le-inventory
availability ≤ inventory ≤ estate — each step only shrinking
210 (day, service, sale type) cells compared against the catalogue's inventory
closed-vs-full
closed (not operating) and full (sold out) are different facts
states seen over the window: {'on-sale': 43, 'full': 17} — the distinction can only be judged when a closed or full day occurs
?quote-lines-sum
a quote's lines account for its total
0 priced quote(s) summed
age-groups-tile
age groups tile 0–120: every age belongs to exactly one group
2 service(s) with age groups
  • accommodation/nambo: child 5–4 is inverted
  • accommodation/lighthouse: child 5–4 is inverted
unenforced-rules-named
a rule with enforced:false is our stated position, not what the system charges
2 stated-not-enforced rule(s): accommodation/nambo: booking-grace, accommodation/lighthouse: booking-grace

Structure — comments and suggestions, most unblocking first

contract weight 3 structure.no-range-availability — GET /availability

30 requests for 30 days.

Why it matters: A month view is the first thing a booking widget paints; the contract gives it no single call.

Suggestion: `GET /availability?from&days` (or `to`), bounded, answering `{days: [{date, entries}]}` — additive, so v1 keeps its shape.

Seen: calendar 2026-09-07 + 30: 30 exchanges

wire weight 3 structure.no-pictures — contentRefs

No service references a picture.

Why it matters: The contract names a static /media/ path and a `contentRefs` map; neither is populated, so a page has text only.

Suggestion: Land `contentRefs` with at least a cover and a gallery per service, as paths under /media/ the front door can list.

Seen: No service payload carries a picture reference — no contentRefs, no URL-shaped value, nothing under /media/. The page shows none rather than a placeholder.

contract weight 2 structure.availability-undeclared-useful — AvailabilityEntry

Every availability entry carries `nights`, `asOf`, which the contract does not declare.

Why it matters: `asOf` is exactly the freshness signal a cache needs and `nights` says what the vacancies were computed for; undeclared, a reader dares not depend on either.

Suggestion: Declare them: `asOf` (date-time, when the feed was read) and `nights` (the stay length the entry answers for).

Seen: seen on 60 entries: {'nights': 60, 'asOf': 60}

model weight 2 structure.cruise-words-on-a-stay — Service

A hotel night carries `itinerary`, `embarkTimeLocal`, `embarkPlaceId`, `disembarkPlaceId`, `durationMinutes` (itinerary.kind = 'route', embarkTimeLocal = '14:00').

Why it matters: A reader building a hotel page meets a boat's vocabulary: is 14:00 an embarkation or a check-in? Does a route apply to a room? The words are the model's, and the model is right to have one Service; the reader still pays.

Suggestion: Either neutral names on the shared Service (`startTimeLocal`, `startPlaceId`, `movement`) with the family docs mapping them, or family-specific optional blocks (`stay: {checkIn, checkOut}`, `cruise: {embark…}`) beside the shared core.

Seen: accommodation/nambo: itinerary={'kind': 'route'}, embarkTimeLocal='14:00', embarkPlaceId=None, disembarkPlaceId=None, durationMinutes=None

model weight 2 structure.variant-without-axes — variants[].axes

`accommodation/nambo` has variant `standard` with no axes at all.

Why it matters: The contract says variantId IS the axis value and nothing is minted; a variant with empty axes is a minted token by construction, and a reader cannot recover what `standard` means.

Suggestion: For an axis-less family, either declare a family axis (`rate-plan`, `room-only`…) whose value this is, or state in the contract that a family with no axes carries exactly one variant named by convention.

Seen: accommodation/nambo: variants = [{'axes': [], 'variantId': 'standard', 'title': 'Nam Bộ Boutique Hotel', 'onSale': True}] (+1 more: accommodation/lighthouse: variants = [{'axes': [], 'variantI)

wire weight 2 structure.empty-array-for-object — content

`content` arrives as `[]`.

Why it matters: The contract declares an object; an empty PHP array serialises as a list, so a reader's `content.title` raises on a list. An absent object and an empty one are different facts and neither is a list.

Suggestion: Emit `{}` (or omit the key) — `(object)[]` at the serialiser, and a test that every declared object is a dict on the wire.

Seen: accommodation/nambo: content = [] (+1 more: accommodation/lighthouse: content = [])

wire weight 1 structure.list-carries-detail — GET /services

The list and the detail are the same object (18 keys); the list is ~11 KB for 2 services.

Why it matters: A catalogue page downloads compositions and refund ladders for services it will never open; at ten services it is a quarter-megabyte first paint.

Suggestion: A light list (identity, title, nights, exclusivity, variants) and the deep answer on the detail — which is what the contract already draws.

Seen: list keys == detail keys: ['ageGroups', 'compositions', 'content', 'counterpart', 'disembarkPlaceId', 'durationMinutes']…