From ca1ab17595de782e4a64e86f7bc575aabf008e03 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B6rn=20Fromme?= Date: Tue, 29 Sep 2026 12:14:59 +0200 Subject: [PATCH] docs: document the travel export endpoint and the new hotel fields Also corrects the token TTL: the guide claimed one hour for client_credentials, which stopped being true when the tagged grant service was dropped. Every grant now issues a PT10M token. Co-Authored-By: Claude Opus 5 --- docs/api-consumer-guide.md | 119 +++++++++++++++++++++++++++++++++++-- 1 file changed, 114 insertions(+), 5 deletions(-) diff --git a/docs/api-consumer-guide.md b/docs/api-consumer-guide.md index 63e4d118..9bafcce1 100644 --- a/docs/api-consumer-guide.md +++ b/docs/api-consumer-guide.md @@ -10,8 +10,10 @@ A Symfony application exposing a read-mostly JSON API under the `/api` prefix. A - Token endpoint: `POST /token` (unauthenticated). - Enabled grants: **client_credentials**, **authorization_code** (PKCE required for public clients), **refresh_token**. Password and implicit grants are disabled. -- Access token TTL depends on the grant: **1 hour** for client_credentials, **10 minutes** for authorization_code. - Do not hardcode either value — read `expires_in` from the token response, it is authoritative and may be tuned. +- Access token TTL is **10 minutes** for every grant. + Do not hardcode it — read `expires_in` from the token response, it is authoritative and may be tuned. + A token can therefore expire part-way through a long page walk of `GET /api/travels/export`; re-request one on + 401 and resume at the page you were on. At the default `perPage=500` the whole export is a single page today. - Available scopes: `email`, `id`, `profile`, `roles`, `api`. Default scope if none requested: `email`. - Each granted scope maps to a role `ROLE_OAUTH2_` (e.g. scope `api` → `ROLE_OAUTH2_API`). - Send the token as `Authorization: Bearer `. @@ -48,7 +50,9 @@ There is no single error envelope; three shapes occur: - `{"error": "...", "type": "api_error"}` — upstream failures on some endpoints. **Caution:** on `/api/products` and `/api/travels/{dateId}/{hotelId}/{dateTo}/availability` this shape is returned with HTTP **200**, so clients must check for the presence of an `error` key, not only the status code. - `{"success": false, "message": "..."}` — the write endpoints (contact form, newsletter). -Validation failures on endpoints using mapped query strings / payloads (`/api/contingents/*`, `/api/newsletter-subscriptions`) return **400** with Symfony's constraint-violation body. Missing/invalid token yields **401**; insufficient scope yields **403**. +Validation failures on endpoints using mapped query strings / payloads (`/api/contingents/*`, `/api/travels/export`, `/api/newsletter-subscriptions`) return **400** with Symfony's constraint-violation body. Missing/invalid token yields **401**; insufficient scope yields **403**. + +`GET /api/travels/export` deliberately uses real status codes throughout and never the 200-with-an-`error`-key shape: a failure there is a 5xx, so retry logic can key on the status code alone. The HTTP methods listed below are the intended ones. Several read routes do not restrict methods server-side; clients should still use `GET` for them. @@ -60,11 +64,23 @@ The HTTP methods listed below are the intended ones. Several read routes do not #### `GET /api/hotels` — scope `api` -List of hotels. Array of objects: `id` (int), `code` (string), `name`, `city`, `street`, `phone`, `country`. +List of hotels. Array of objects: `id` (int), `code` (string), `name`, `city`, `street`, `phone`, `country`, +`costUnit`, `countryCode`. + +- `country` is the **raw** upstream token, unnormalised, and is the join key into `GET /api/countries[].token`. + It mixes conventions: ISO-2 (`CH`, `NL`), European vehicle registration codes (`D`, `A`, `F`, `S`, `FL`) and + ISO-3 (`AUT`, `PRT`, `HRV`), sometimes several for the same country. +- `countryCode` (`string|null`) is that token mapped to a lowercase ISO 3166-1 alpha-2 code — use this one to pick + a flag. A token with no confident mapping yields **`null`**, never a guess and never `""`, so treat null as + "unknown" rather than as an error. New tokens appear upstream from time to time; they surface as null here until + the mapping table is extended. +- `costUnit` (`string|null`) is the raw upstream cost centre. It is a **string, not a number**: most hotels have + none, and some carry a space-separated list such as `"84 85"`. Store it as text and do not parse it. #### `GET /api/hotels/{id}` — scope `api` -Single hotel by numeric id, same field set as the list. `404 {"message":"Not found"}` if unknown. +Single hotel by numeric id, same field set as the list, including `costUnit` and `countryCode`. +`404 {"message":"Not found"}` if unknown. #### `GET /api/countries` — scope `api` @@ -129,6 +145,99 @@ This is a full overwrite, not a merge. Index/mapping of all known travel dates. Object keyed by numeric travel id, each value `{id, code, label, dateFrom, dateTo, hotels: [...]}`. +#### `GET /api/travels/export` — scope `api` + +Bulk export of every travel date × hotel pair with its boarding points resolved, for clients that mirror the +disposition locally. One request replaces a walk over `GET /api/travels` plus a per-pair detail call. + +This is the only paginated endpoint and the only one with a `meta` envelope. + +Query parameters: + +| Parameter | Type | Default | Notes | +|---|---|---|---| +| `page` | int ≥ 1 | `1` | Out of range is **not** clamped — see below | +| `perPage` | int 1–1000 | `500` | | +| `changedSince` | — | — | **Reserved, not implemented.** Sending it today is ignored; it is named here only so it is not claimed for anything else. | + +A `page`/`perPage` outside those bounds returns **400** with Symfony's constraint-violation body (send +`Accept: application/json` to get it as JSON rather than an HTML error page). A `page` past the last one is *not* +an error: it returns `200` with an empty `data` array and the usual `meta`. + +```json +{ + "data": [ + { + "busProId": 12794, + "code": "PCJSER261226", + "product": "Semesterende - Portes du Soleil - Joliment", + "dateFrom": "2026-12-26", + "dateTo": "2026-12-30", + "hotel": { + "busProId": 225444, + "code": "PCJSER1", + "name": "NEU SER Jolimont 1", + "costUnit": "863", + "country": "CH", + "countryCode": "ch" + }, + "pickups": [ + { + "busProId": 18, + "code": "ESS-Route", + "city": "Essen", + "street": "Am Hauptbahnhof (Südausgang Fernbushaltestelle)", + "time": "20:15", + "datetime": "2026-12-25T20:15:00+01:00" + } + ] + } + ], + "meta": { + "page": 1, + "perPage": 500, + "total": 415, + "totalPages": 1, + "skipped": 0, + "pickupsWithoutMasterData": 0, + "generatedAt": "2026-09-29T12:10:06+02:00" + } +} +``` + +**Row identity.** `(busProId, hotel.busProId)` is unique across the whole export — upsert on that pair. `busProId` +is always an int and never null; `hotel` is always an object and never null; `pickups` is always an array, possibly +empty, never null. + +**`meta` describes the whole export, not the page.** `total`, `totalPages`, `skipped`, `pickupsWithoutMasterData` +and `generatedAt` are identical on every page, so you can read them once on page 1 and decide whether to walk the +rest. `total` is the row count **after** skips, and `totalPages` is `ceil(total / perPage)`. + +- `skipped` — (travel, hotel) pairs dropped as unusable: a travel date that could not be parsed, or a hotel absent + from the hotel master data. Expect `0`. A non-zero value means upstream data is inconsistent, not that you have + lost rows you previously had. +- `pickupsWithoutMasterData` — count of **distinct** pickup ids referenced by a travel but absent from + `GET /api/pickups`. Those entries are still emitted, with `code`, `city` and `street` null: a departure point + with no name is more useful to a disposition view than one that silently vanished. + +**Pickup times.** `pickups` is in **source order** — the order the boarding list is driven in — not sorted by time. + +- `time` is `"HH:MM"` for display; `datetime` is the same moment as a full ISO-8601 string with a + `Europe/Berlin` offset (`+01:00` / `+02:00` depending on DST). **Prefer `datetime`.** +- Upstream states a boarding time either as a full date and time or as a bare time. A bare time is resolved + against the departure date and, where the sequence demands it, rolled onto the following calendar day — so an + `02:15` listed after a `23:59` comes back on the next date, not the same one. `datetime` is emitted for + bare-time sources too, and carries that resolution. +- A boarding point with no time upstream comes back with **both** `time` and `datetime` null. They are always + null together. +- Caveat: where upstream states full datetimes for a whole boarding list, those dates are taken verbatim. Some + lists date every stop to the evening before departure, including stops after midnight, so an entry can appear a + day earlier than the running order implies. This is upstream data, not a transformation applied here. + +**Consistency.** The export is built from the same cached master data as `GET /api/hotels` and `GET /api/pickups`, +under one cache tag, so all three move together on an upstream sync rather than drifting between your calls. +It is cached for 3 hours; `generatedAt` is when the cached copy was assembled, not when you requested it. + #### `GET /api/travels/{dateId}/{hotelId}` — scope `api` Single travel by **numeric** ids (`hotelId` optional). Route matches only when both segments are digits.