docs: include previously ignored documentation files
This commit is contained in:
@@ -0,0 +1,354 @@
|
||||
# API Consumer Guide
|
||||
|
||||
This document describes the public JSON API exposed under `/api`, written for developers implementing a **client** in another application. It is self-contained: no access to this codebase is required to use it.
|
||||
|
||||
## Overview
|
||||
|
||||
A Symfony application exposing a read-mostly JSON API under the `/api` prefix. All `/api/*` requests are stateless and authenticated via OAuth2 Bearer tokens (League OAuth2 Server, JWT access tokens). Responses are `application/json`. HTTPS is enforced on all routes.
|
||||
|
||||
## Authentication
|
||||
|
||||
- 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: **10 minutes** — clients must refresh/re-request frequently.
|
||||
- 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>`.
|
||||
|
||||
Scope required per endpoint:
|
||||
|
||||
| Scope needed | Endpoints |
|
||||
|---|---|
|
||||
| `api` | everything except the two below |
|
||||
| `profile` | `GET /api/crm-attributes` |
|
||||
| `email` | `GET /api/userinfo` (optional extra scopes `id`, `profile`, `roles` widen the response) |
|
||||
|
||||
`/api/userinfo` and `/api/crm-attributes` act on the **authenticated end user**, so they require an authorization_code token issued for a user. All other endpoints are machine-to-machine and work with client_credentials.
|
||||
|
||||
### Machine-to-machine example
|
||||
|
||||
```
|
||||
POST /token
|
||||
Content-Type: application/x-www-form-urlencoded
|
||||
|
||||
grant_type=client_credentials&client_id=...&client_secret=...&scope=api
|
||||
```
|
||||
|
||||
```
|
||||
GET /api/hotels
|
||||
Authorization: Bearer eyJ0eXAi...
|
||||
```
|
||||
|
||||
## Error conventions
|
||||
|
||||
There is no single error envelope; three shapes occur:
|
||||
|
||||
- `{"message": "..."}` — commonly with 404 / 400.
|
||||
- `{"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**.
|
||||
|
||||
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.
|
||||
|
||||
---
|
||||
|
||||
## Endpoints
|
||||
|
||||
### Reference data (BusProNet master data)
|
||||
|
||||
#### `GET /api/hotels` — scope `api`
|
||||
|
||||
List of hotels. Array of objects: `id` (int), `code` (string), `name`, `city`, `street`, `phone`, `country`.
|
||||
|
||||
#### `GET /api/hotels/{id}` — scope `api`
|
||||
|
||||
Single hotel by numeric id, same field set as the list. `404 {"message":"Not found"}` if unknown.
|
||||
|
||||
#### `GET /api/countries` — scope `api`
|
||||
|
||||
Array of `{id, name, token, nationality}`.
|
||||
|
||||
#### `GET /api/products` — scope `api`
|
||||
|
||||
Product master data from the upstream BusProNet system, cached server-side for 1 hour. On upstream failure returns `{"error": "...", "type": "api_error"}` **with status 200**.
|
||||
|
||||
#### `GET /api/last-update` — scope `api`
|
||||
|
||||
Timestamp of the last upstream data export: `{"timestamp": "YYYY-MM-DD HH:MM:SS"}`. Note: if no export marker file exists the current implementation errors (500) rather than returning null — treat a non-JSON/500 response as "unknown".
|
||||
|
||||
---
|
||||
|
||||
### Pickups (bus boarding points)
|
||||
|
||||
#### `GET /api/pickups` — scope `api`
|
||||
|
||||
Array of `{id, code, city, postalCode, street}`.
|
||||
|
||||
#### `GET /api/pickups/{id}` — scope `api`
|
||||
|
||||
Single pickup by numeric id, same fields as the list.
|
||||
|
||||
#### `GET /api/pickups-planning/{travelCode}` — scope `api`
|
||||
|
||||
Planned outbound (HIN) boarding stops for one travel code. Returns an array, chronologically sorted:
|
||||
|
||||
```json
|
||||
[{"city": "...", "location": "...", "datetime": "YYYY-MM-DD HH:MM", "busNumber": "..."}]
|
||||
```
|
||||
|
||||
`404 {"error":"Planning data not available"}` if no planning has been uploaded yet, `404 {"error":"Travel code not found"}` for an unknown code, `500 {"error":"Failed to read planning data"}` on storage errors.
|
||||
|
||||
#### `POST /api/pickups-planning` — scope `api`
|
||||
|
||||
Bulk-replaces the planning dataset. Body: a JSON **array** of raw planning rows using the upstream German keys:
|
||||
|
||||
```json
|
||||
[
|
||||
{
|
||||
"PlanungsArt": "HIN",
|
||||
"Reisecode": "ABC123",
|
||||
"Zustieg/Ausstieg Ort": "Köln",
|
||||
"Zustieg/Ausstieg Strasse": "Hauptbahnhof",
|
||||
"Zeit": "24.12.2026 07:30",
|
||||
"BusNummer": "1"
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
Rows without `PlanungsArt === "HIN"` or without `Reisecode` are dropped; duplicates (same city + location + datetime + bus) are de-duplicated; results are grouped by `Reisecode` and sorted by time. Response `{"status":"ok"}` (200). `400 {"error":"Invalid JSON payload"}`, `500 {"error":"Failed to write pickups planning data"}`.
|
||||
|
||||
This is a full overwrite, not a merge.
|
||||
|
||||
---
|
||||
|
||||
### Travels
|
||||
|
||||
#### `GET /api/travels` — scope `api`
|
||||
|
||||
Index/mapping of all known travel dates. Object keyed by numeric travel id, each value `{id, code, label, dateFrom, dateTo, hotels: [...]}`.
|
||||
|
||||
#### `GET /api/travels/{dateId}/{hotelId}` — scope `api`
|
||||
|
||||
Single travel by **numeric** ids (`hotelId` optional). Route matches only when both segments are digits.
|
||||
|
||||
#### `GET /api/travels/{dateCode}/{hotelCode}` — scope `api`
|
||||
|
||||
Same, addressed by **string** codes (`hotelCode` optional). The date code is sanitized (separators stripped) before lookup. `404 {"message":"Not found"}` if the date code is unknown, `404 {"message":"Travel not found"}` if no data exists.
|
||||
|
||||
Both variants accept optional query parameters:
|
||||
|
||||
- `source=local` — force the local XML/snapshot data.
|
||||
- `source=remote` — force a live upstream call.
|
||||
- `prefer_remote=1` — used only when `source` is absent; prefers upstream with local fallback.
|
||||
|
||||
Response fields: `id`, `code`, `productCode`, `productId`, `label`, `hotelId`, `hotel` (hotel object), `dateFrom`, `dateTo`, `type`, `status`, `allowedBookingStatus[]`, `priceFrom`, `selectionGroups[]`, `additionalServices[]`, `transportationServices[]`, `pickups[]`, `dropOffs[]`, `rooms[]`, `guide`, `insurances[]`.
|
||||
|
||||
#### `GET /api/travels/{dateId}/{hotelId}/{dateTo}/availability` — scope `api`
|
||||
|
||||
Live hotel availability from the upstream system. `dateTo` must be `Y-m-d`. On upstream failure returns `{"error":"...","type":"api_error"}` **with status 200**.
|
||||
|
||||
---
|
||||
|
||||
### Contingents & accommodation prices
|
||||
|
||||
#### `GET /api/contingents/prices?hotelCode=…&year=…` — scope `api`
|
||||
|
||||
Price timeline for one accommodation and calendar year, collapsed into contiguous segments (adjacent days with the same effective price are merged into one row). The timeline never starts before today; if the requested year is entirely in the past the response is `[]`.
|
||||
|
||||
- `hotelCode`: required, max 16 chars, `[A-Za-z0-9_-]+` (matches the accommodation's calendar code).
|
||||
- `year`: required int, 1000–9999.
|
||||
|
||||
Each row:
|
||||
|
||||
```json
|
||||
{
|
||||
"dateFrom": "2026-06-01",
|
||||
"dateTo": "2026-06-14",
|
||||
"season": "high|low|...|null",
|
||||
"includedPax": 2,
|
||||
"pricePerNight": 89.5,
|
||||
"priceAdditionalPerson": 25.0,
|
||||
"defaultPricePerNight": 99.0,
|
||||
"defaultPriceAdditionalPerson": 30.0,
|
||||
"currency": "EUR",
|
||||
"type": "discount|null"
|
||||
}
|
||||
```
|
||||
|
||||
Prices are **decimal major units** (converted from integer minor units server-side). `default*` fields are non-null only when the winning price is a discount, and then carry the undiscounted price. `400 {"error":"Hotel not found for hotelCode."}` for an unknown code; `400` with violations for invalid parameters.
|
||||
|
||||
#### `GET /api/contingents/calendar?hotelCode=…&dateFrom=…&dateTo=…` — scope `api`
|
||||
|
||||
Per-day availability enriched with prices. Both dates are `Y-m-d` and **inclusive** (`dateTo` is the last night, not the checkout day); the range may not exceed 366 days and `dateTo` must not precede `dateFrom`. Upstream contingent data is cached 1 hour per (hotelCode, dateFrom, dateTo).
|
||||
|
||||
Each entry:
|
||||
|
||||
```json
|
||||
{
|
||||
"date": "2026-06-01",
|
||||
"status": "Free|Blocked|...",
|
||||
"type": "discount|null",
|
||||
"pricePerNight": 89.5,
|
||||
"defaultPricePerNight": 99.0,
|
||||
"priceAdditionalPerson": 25.0,
|
||||
"defaultPriceAdditionalPerson": 30.0,
|
||||
"currency": "EUR",
|
||||
"includedPax": 2,
|
||||
"minNights": 2
|
||||
}
|
||||
```
|
||||
|
||||
Important rule: **a day with no maintained price is forced to `Blocked`** regardless of what the upstream contingent says. Price fields may be `null` in that case.
|
||||
|
||||
Errors: `400 {"error":"Hotel not found for hotelCode."}`, `502 {"error":"Failed to fetch contingent data."}` when the upstream contingent service fails, `400` with violations for invalid parameters.
|
||||
|
||||
---
|
||||
|
||||
### Accommodation bookings (group bookings)
|
||||
|
||||
#### `GET /api/accommodation-bookings/{uuid}` — scope `api`
|
||||
|
||||
Single booking by UUID. `404 {"message":"Not found"}` if unknown.
|
||||
|
||||
```json
|
||||
{
|
||||
"uuid": "...",
|
||||
"status": "...",
|
||||
"type": "...",
|
||||
"dateFrom": "2026-06-01",
|
||||
"dateTo": "2026-06-08",
|
||||
"nights": 7,
|
||||
"paxCount": 40,
|
||||
"minorsCount": 12,
|
||||
"childrenCount": 3,
|
||||
"groupName": "...",
|
||||
"acceptedAt": "2026-05-01T10:00:00+00:00",
|
||||
"personalData": {},
|
||||
"accommodation": {},
|
||||
"boardService": {},
|
||||
"additionalServices": [],
|
||||
"accommodationDiscount": null,
|
||||
"boardServiceDiscount": null,
|
||||
"additionalServicesDiscount": null,
|
||||
"totalPrice": 123456,
|
||||
"pricingCurrency": "EUR",
|
||||
"pricingVersion": 3,
|
||||
"priceBreakdown": {}
|
||||
}
|
||||
```
|
||||
|
||||
`totalPrice` and the `*Discount` fields are integer **minor units** (cents) — unlike the contingent endpoints. `dateFrom`/`dateTo` are `Y-m-d`; `acceptedAt` is a full ISO-8601 datetime and is `null` until accepted. `priceBreakdown` is a computed nested structure.
|
||||
|
||||
#### `POST /api/accommodation-bookings/{uuid}/accept` — scope `api`
|
||||
|
||||
Marks the booking as accepted (server-side side effects: status change, `acceptedAt`, notifications). No request body. Returns the same booking representation as the GET. `404` if unknown. Not idempotent in terms of side effects — call once.
|
||||
|
||||
---
|
||||
|
||||
### Contact form
|
||||
|
||||
#### `POST /api/contactform` — scope `api`
|
||||
|
||||
Creates or matches an address record in the upstream CRM.
|
||||
|
||||
Request body (all fields optional strings; the upstream system decides what is required):
|
||||
|
||||
```json
|
||||
{
|
||||
"lastName": "...", "firstName": "...", "gender": "...",
|
||||
"street": "...", "zipCode": "...", "city": "...",
|
||||
"email": "...", "phone": "..."
|
||||
}
|
||||
```
|
||||
|
||||
- `201` — `{"success": true, "addressId": 123, "personId": 456, "isNewRecord": true}`
|
||||
- `409` — `{"success": false, "message": "<upstream notification>"}` (upstream rejected/conflicted)
|
||||
- `400` — `{"success": false, "message": "<error>"}` (upstream call failed)
|
||||
- `422` — malformed payload
|
||||
|
||||
---
|
||||
|
||||
### Newsletter
|
||||
|
||||
#### `GET /api/newsletters` — scope `api`
|
||||
|
||||
The configured newsletter lists as an array of `{id, name, ...}` mapping objects. Use the returned `id` values as `listIds` below.
|
||||
|
||||
#### `POST /api/newsletter-subscriptions` — scope `api`
|
||||
|
||||
Request:
|
||||
|
||||
```json
|
||||
{
|
||||
"email": "[email protected]",
|
||||
"firstName": "Ada",
|
||||
"lastName": "Lovelace",
|
||||
"listIds": [1, 2]
|
||||
}
|
||||
```
|
||||
|
||||
- `email`: required, strict email validation.
|
||||
- `firstName` / `lastName`: optional, max 255 chars.
|
||||
- `listIds`: required, at least one positive integer; every id must be one of the configured lists.
|
||||
|
||||
Responses:
|
||||
|
||||
- `200` — subscription completed, `state: "subscribed"`.
|
||||
- `202` — a double-opt-in confirmation mail was requested, `state: "confirmation_requested"`.
|
||||
- Body in both cases:
|
||||
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"email": "[email protected]",
|
||||
"lists": [{"id": 1, "name": "...", "state": "success|pending|already_registered"}],
|
||||
"state": "subscribed|pending_confirmation|confirmation_requested",
|
||||
"confirmationRequested": true
|
||||
}
|
||||
```
|
||||
|
||||
- `400` — unknown or disallowed list ids: `{"success": false, "message": "...", "listIds": [...], "unknownListIds": [...]}`, or validation violations.
|
||||
- `503` — `{"success": false, "email": "...", "listIds": [...], "message": "Newsletter subscription request could not be processed."}` when the newsletter provider is unavailable. Safe to retry.
|
||||
|
||||
---
|
||||
|
||||
### End-user endpoints (require a user-bound token)
|
||||
|
||||
#### `GET /api/userinfo` — scope `email` (+ optional `id`, `profile`, `roles`)
|
||||
|
||||
OIDC-style claims for the authenticated user. The response contains only the claims covered by the granted scopes:
|
||||
|
||||
- always: `email`
|
||||
- scope `id`: `person_id`, `address_id`
|
||||
- scope `roles`: `roles` (array; the implicit baseline role is stripped, only explicitly assigned roles are exported)
|
||||
- scope `profile`: `profile` object:
|
||||
|
||||
```json
|
||||
{
|
||||
"first_name": "...", "last_name": "...", "title": "...", "salutation": "...",
|
||||
"gender": "...", "date_of_birth": "1990-01-01", "nationality": "...",
|
||||
"address": {"street": "...", "postcode": "...", "city": "...", "district": "...", "country": "..."},
|
||||
"communication": {"email": "...", "mobile": "...", "phone": "...", "newsletter": true},
|
||||
"hotel_codes": ["..."], "height": null, "weight": null, "shoe_size": null, "remarks": "..."
|
||||
}
|
||||
```
|
||||
|
||||
Note: `profile.communication.email` comes from the CRM address record and is **not necessarily** the login email — a single address can hold several contacts. Use the top-level `email` claim for identity.
|
||||
|
||||
`400 {"message": "...", "code": "..."}` when the upstream lookup fails or rejects the credentials.
|
||||
|
||||
#### `GET /api/crm-attributes` — scope `profile`
|
||||
|
||||
CRM selection attributes/segments for the authenticated user. `400 {"message": "...", "code": "..."}` on upstream failure.
|
||||
|
||||
---
|
||||
|
||||
## Client implementation notes
|
||||
|
||||
1. **Token lifetime is 10 minutes** — implement refresh/re-fetch with a safety margin and retry once on 401.
|
||||
2. **Check for an `error` key even on 200 responses** for `/api/products` and the travel availability endpoint.
|
||||
3. **Money units are inconsistent across endpoints**: contingent endpoints return decimal major units; accommodation-booking fields return integer cents. Normalize at the client boundary.
|
||||
4. **Dates**: `Y-m-d` everywhere except `acceptedAt` (ISO-8601 datetime) and pickup planning (`Y-m-d H:i`). Contingent `dateFrom`/`dateTo` are inclusive night boundaries.
|
||||
5. **Server-side caching** (1 h on products and the contingent calendar) means upstream changes may not be visible immediately.
|
||||
6. **Route ambiguity**: `/api/travels/{a}/{b}` resolves to the id-based route when both segments are numeric, otherwise to the code-based route. Non-numeric codes are safe; purely numeric *codes* will hit the id route.
|
||||
Reference in New Issue
Block a user