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 <[email protected]>
This commit is contained in:
1 parent
c5685e6d7c
commit
ca1ab17595
1 file changed
+114
-5
+114
-5
@@ -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_<SCOPE_UPPERCASE>` (e.g. scope `api` → `ROLE_OAUTH2_API`).
|
||||
- Send the token as `Authorization: Bearer <access_token>`.
|
||||
@@ -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.
|
||||
|
||||
Reference in new issue
Block a user