search-aggregation-service — API Contracts
Companion: APPLICATION_LOGIC · DOMAIN_MODEL · 05 API Design · Error Codes · SECURITY_MODEL
All routes follow docs/05-api-design.md: JSON over HTTPS, ULID IDs (<prefix>_<ULID>), RFC 9457 problem-details on errors. Base URL https://api.melmastoon.ghasi.io. Internal routes use the prefix /internal/v1 and require an mTLS-authenticated service account.
Tenancy posture (this service only). The consumer-facing surface (
/api/v1/search/queries,/api/v1/search/hotels/:id,/api/v1/search/suggest,/api/v1/search/facets,/api/v1/search/clicks) is anonymous-by-default — noX-Tenant-Idheader is sent and none is expected. Operator/admin surfaces (/api/v1/search/boost-rules*,/api/v1/search/index*) require an operator JWT andX-Tenant-Id(boost rules are scoped to the operator's tenant; index admin requires platform-admin role).
OpenAPI source of truth: services/search-aggregation-service/contracts/openapi.yaml (generated from controllers + DTOs).
1. Common headers
| Header | Direction | Notes |
|---|---|---|
Authorization: Bearer <jwt> | inbound | required on operator/admin routes; omitted on consumer routes (or anonymous JWT issued by bff-consumer-service carrying userBucket only) |
X-Tenant-Id: tnt_<ULID> | inbound | required on operator/admin; forbidden on consumer routes |
X-Request-Id: <ULID> | inbound | client-supplied, echoed back |
Idempotency-Key: <ULID> | inbound | required on POST /search/clicks, POST /search/boost-rules, POST /search/boost-rules/{id}:activate, POST /search/index:rebuild |
Accept-Language: <bcp-47> | inbound | drives locale for response strings and userMessageKey |
X-Currency: <ISO-4217> | inbound | display currency (default: tenant currency in tenant route, AFN on consumer) |
X-Region: <ISO-3166-1 alpha-2> | inbound | hints region pinning (default: client geo or AF) |
X-User-Bucket: <opaque> | inbound | anonymous client bucket (set by bff-consumer-service for rate-limit + popularity) |
traceparent (W3C) | inbound | propagated to logs & events |
Cache-Control | outbound | public, max-age=60 on hot search reads; private, max-age=300 on hotel detail |
ETag: <hash> | outbound | content hash on hotel detail |
2. Search
2.1 Execute search query
POST /api/v1/search/queries
{
"text": "guesthouse near garden",
"destination": {
"city": "kabul",
"near": { "center": { "lat": 34.5328, "lng": 69.1727 }, "radiusKm": 10 }
},
"dates": { "from": "2026-05-01", "to": "2026-05-04" },
"occupancy": { "adults": 2, "children": 1, "childrenAges": [6], "rooms": 1 },
"filter": {
"priceBand": { "minMicro": "500000000", "maxMicro": "5000000000", "currency": "AFN" },
"amenities": ["wifi", "halal_kitchen", "prayer_room"],
"starRatingMin": 3,
"languages": ["en", "ps"],
"freeCancellation": true
},
"sort": { "kind": "price", "direction": "asc" },
"page": { "size": 20 }
}
200 OK:
{
"queryId": "q_01H...",
"tookMs": 87,
"degradationLevel": "none",
"fxAgeSeconds": 142,
"fxStale": false,
"results": [
{
"propertyId": "ppt_01H...",
"rank": 1,
"name": { "default": "ps", "values": { "ps": "د غاسي مېلمستون", "en": "Ghasi Guesthouse" } },
"city": "kabul",
"country": "AF",
"geo": { "lat": 34.5310, "lng": 69.1750 },
"starRating": 3,
"ratingAvg": 4.4,
"ratingCount": 132,
"amenities": ["wifi", "halal_kitchen", "prayer_room", "generator_backup"],
"languagesSpoken": ["ps", "fa", "en"],
"heroPhotoUrl": "https://media.melmastoon.ghasi.io/m/01H.../hero.webp?sig=…",
"priceFrom": { "amountMicro": "1250000000", "currency": "AFN" },
"priceFromConverted": { "amountMicro": "14250000", "currency": "USD" },
"freeCancellation": true,
"distanceKm": 0.42,
"deepLinkUrl": "https://book.melmastoon.ghasi.io/t/tnt_…/p/ppt_…?from=2026-05-01&to=2026-05-04&adults=2&children=1&rooms=1"
}
],
"page": { "size": 20, "total": 47, "nextCursor": "eyJzZWFyY2hfYWZ0ZXIiOlsiMC44IiwicHB0XzAxSC4uLiJdfQ" },
"facets": {
"amenities": [
{ "code": "wifi", "count": 47 },
{ "code": "halal_kitchen", "count": 38 },
{ "code": "prayer_room", "count": 31 }
],
"starRating": [ { "value": 3, "count": 22 }, { "value": 4, "count": 14 } ],
"priceBands": [ { "minMicro": "0", "maxMicro": "1000000000", "count": 9 } ]
}
}
Pagination subsequent page: POST /api/v1/search/queries with { "cursor": "eyJ…" } (the body otherwise mirrors the original; mismatch invalidates the cursor).
Errors: MELMASTOON.SEARCH.QUERY_INVALID, MELMASTOON.SEARCH.GEO_OUT_OF_BOUNDS, MELMASTOON.SEARCH.PAGE_OUT_OF_RANGE, MELMASTOON.SEARCH.INDEX_UNAVAILABLE, MELMASTOON.GENERAL.RATE_LIMITED.
2.2 Get hotel detail
GET /api/v1/search/hotels/{propertyId}?from=2026-05-01&to=2026-05-04¤cy=USD
200 OK:
{
"propertyId": "ppt_01H...",
"tenantSlug": "ghasi-house-kabul",
"name": { "default": "ps", "values": { "ps": "...", "en": "..." } },
"description": { "default": "ps", "values": { "ps": "...", "en": "..." } },
"address": { "city": "Kabul", "country": "AF" },
"geo": { "lat": 34.5310, "lng": 69.1750 },
"starRating": 3,
"ratingAvg": 4.4,
"ratingCount": 132,
"amenities": ["wifi", "halal_kitchen", "prayer_room"],
"languagesSpoken": ["ps", "fa", "en"],
"photos": [
{ "mediaId": "med_01H...", "url": "https://media.melmastoon.ghasi.io/.../1.webp?sig=…", "alt": { "default": "en", "values": { "en": "Front facade" } } }
],
"priceFrom": { "amountMicro": "1250000000", "currency": "AFN" },
"priceFromConverted": { "amountMicro": "14250000", "currency": "USD" },
"perNightAvailability": [
{ "date": "2026-05-01", "roomsAvailable": 12, "roomsTotal": 30 },
{ "date": "2026-05-02", "roomsAvailable": 11, "roomsTotal": 30 },
{ "date": "2026-05-03", "roomsAvailable": 14, "roomsTotal": 30 }
],
"deepLinkUrl": "https://book.melmastoon.ghasi.io/t/tnt_…/p/ppt_…?from=2026-05-01&to=2026-05-04",
"fxAgeSeconds": 142,
"fxStale": false,
"lastUpsertedAt": "2026-04-22T10:00:00Z",
"etag": "sha256:abc123…"
}
Errors: MELMASTOON.GENERAL.RESOURCE_NOT_FOUND (also returned for suppressed properties — never expose existence), MELMASTOON.SEARCH.INDEX_UNAVAILABLE.
2.3 Suggest (autocomplete)
GET /api/v1/search/suggest?q=kab&locale=ps&kind=any&limit=10
kind ∈ city | hotel | any.
{
"suggestions": [
{ "kind": "city", "text": "Kabul", "country": "AF", "matchScore": 0.98, "geo": { "lat": 34.5328, "lng": 69.1727 } },
{ "kind": "hotel", "text": "Ghasi House Kabul", "propertyId": "ppt_01H...", "city": "Kabul", "matchScore": 0.85 }
]
}
2.4 Facets (decoupled)
POST /api/v1/search/facets
Same body shape as /search/queries minus sort and page; returns only the facets block. Used when the UI wants to refresh facets without re-rendering results.
2.5 Record click
POST /api/v1/search/clicks (Idempotency-Key required)
{
"queryId": "q_01H...",
"propertyId": "ppt_01H...",
"rank": 3,
"userBucket": "ub_anon_3f9a..."
}
202 Accepted (fire-and-forget; ranked into popularity asynchronously).
Errors: MELMASTOON.GENERAL.VALIDATION_FAILED, MELMASTOON.GENERAL.RATE_LIMITED.
3. Boost rules (operator API, Phase 3+)
3.1 Create boost rule
POST /api/v1/search/boost-rules — requires tenant.admin or marketing role; X-Tenant-Id header required; Idempotency-Key required.
{
"propertyId": "ppt_01H...",
"scope": {
"region": "AF",
"amenities": ["conference_hall"],
"locales": ["en"],
"dateRange": { "from": "2026-06-01", "to": "2026-09-30" }
},
"multiplier": 1.5,
"expiresAt": "2026-10-01T00:00:00Z"
}
201 Created returns BoostRuleDto { id, status: 'draft', ... }. Errors: MELMASTOON.SEARCH.BOOST_RULE_SCOPE_VIOLATION, MELMASTOON.SEARCH.BOOST_MULTIPLIER_OUT_OF_RANGE, MELMASTOON.IDENTITY.PERMISSION_DENIED.
3.2 Activate boost rule
POST /api/v1/search/boost-rules/{id}:activate — body empty; emits search.boost_rule.activated.v1. The change propagates to the OpenSearch document's boost_multiplier within ≤ 30 s.
3.3 List / get / delete
GET /api/v1/search/boost-rules?status=active&page=1&pageSize=20
GET /api/v1/search/boost-rules/{id}
DELETE /api/v1/search/boost-rules/{id}
4. Index admin (platform-admin role only)
4.1 Trigger full reindex
POST /api/v1/search/index:rebuild — Idempotency-Key required.
{
"regions": ["AF", "TJ", "IR"],
"since": "1970-01-01T00:00:00Z",
"templateVersion": "v3"
}
202 Accepted:
{ "buildId": "ibd_01H...", "status": "planning", "regions": ["AF", "TJ", "IR"] }
Errors: MELMASTOON.SEARCH.INDEX_REBUILD_IN_PROGRESS (one active build per region).
4.2 Index health
GET /api/v1/search/index/health
{
"alias": "melmastoon-search-current",
"indices": [
{
"name": "melmastoon-search-af-2026-04-22T08-00",
"docCount": 12480,
"deletedCount": 134,
"primaryShards": 3,
"replicas": 1,
"freshnessSeconds": 4,
"status": "green"
}
],
"lastRebuild": {
"buildId": "ibd_01H...",
"completedAt": "2026-04-22T08:14:11Z",
"durationSeconds": 612
},
"circuit": { "state": "closed", "lastChangeAt": "2026-04-22T07:00:00Z" }
}
5. Internal sync / projection feed
5.1 Projection change stream (analytics)
GET /internal/v1/projection/changes?since=<cursor>&limit=500 — mTLS-authenticated; consumed by analytics-service for fan-out into BigQuery if Pub/Sub-to-BigQuery is paused.
{
"cursor": "01H8Z…",
"ts": "2026-04-22T10:00:00Z",
"changes": [
{
"type": "hotel_index_entry.upserted",
"propertyId": "ppt_01H...",
"tenantId": "tnt_01H...",
"version": "01H8Z…",
"snapshot": { /* HotelIndexEntry public projection */ }
}
],
"more": false
}
5.2 Cache invalidation broadcast (FX)
POST /internal/v1/cache:invalidate — body { "scope": "fx" | "property", "ids"?: ["ppt_…"] } — invoked by pricing-service after publishing fx_snapshot.updated.v1 to force a fast cache flip.
6. Health
GET /healthz — { "status": "ok" }.
GET /readyz — verifies Postgres, OpenSearch (cluster status yellow|green), Redis, Pub/Sub publisher; returns 503 with details on any failure.
7. Rate limits
| Surface | Bucket | Limit |
|---|---|---|
POST /search/queries | per X-User-Bucket | 60 / minute |
POST /search/queries | per source IP (BFF-fronted) | 600 / minute |
GET /search/hotels/:id | per X-User-Bucket | 120 / minute |
GET /search/suggest | per X-User-Bucket | 240 / minute |
POST /search/clicks | per X-User-Bucket | 200 / minute |
POST /search/boost-rules | per X-Tenant-Id | 30 / minute |
POST /search/index:rebuild | per X-Tenant-Id | 1 / hour |
Exceeding → MELMASTOON.GENERAL.RATE_LIMITED (429) with Retry-After.
8. Error envelope
Per ERROR_CODES.md. Example:
{
"error": {
"type": "https://errors.melmastoon.ghasi.io/search/geo-out-of-bounds",
"code": "MELMASTOON.SEARCH.GEO_OUT_OF_BOUNDS",
"title": "Geo bounding box too large",
"status": 422,
"detail": "Bounding box area 73000 km² exceeds the 50000 km² limit.",
"instance": "/api/v1/search/queries",
"traceId": "00f067aa0ba902b7",
"requestId": "req_01H...",
"retriable": false,
"userMessageKey": "errors.search.geo_out_of_bounds",
"docUrl": "https://docs.melmastoon.ghasi.io/errors/search/geo-out-of-bounds"
}
}
9. OpenAPI generation
Controllers emit OpenAPI 3.1 via NestJS decorators + @nestjs/swagger. CI runs openapi-diff against the prior published spec; any breaking change fails the build unless BREAKING_CHANGE_ALLOWED=1 and a new major version (/api/v2) is introduced. The schema is published to the platform schema registry on merge to main.