Skip to main content

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 — no X-Tenant-Id header is sent and none is expected. Operator/admin surfaces (/api/v1/search/boost-rules*, /api/v1/search/index*) require an operator JWT and X-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

HeaderDirectionNotes
Authorization: Bearer <jwt>inboundrequired on operator/admin routes; omitted on consumer routes (or anonymous JWT issued by bff-consumer-service carrying userBucket only)
X-Tenant-Id: tnt_<ULID>inboundrequired on operator/admin; forbidden on consumer routes
X-Request-Id: <ULID>inboundclient-supplied, echoed back
Idempotency-Key: <ULID>inboundrequired on POST /search/clicks, POST /search/boost-rules, POST /search/boost-rules/{id}:activate, POST /search/index:rebuild
Accept-Language: <bcp-47>inbounddrives locale for response strings and userMessageKey
X-Currency: <ISO-4217>inbounddisplay currency (default: tenant currency in tenant route, AFN on consumer)
X-Region: <ISO-3166-1 alpha-2>inboundhints region pinning (default: client geo or AF)
X-User-Bucket: <opaque>inboundanonymous client bucket (set by bff-consumer-service for rate-limit + popularity)
traceparent (W3C)inboundpropagated to logs & events
Cache-Controloutboundpublic, max-age=60 on hot search reads; private, max-age=300 on hotel detail
ETag: <hash>outboundcontent hash on hotel detail

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&currency=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

kindcity | 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

SurfaceBucketLimit
POST /search/queriesper X-User-Bucket60 / minute
POST /search/queriesper source IP (BFF-fronted)600 / minute
GET /search/hotels/:idper X-User-Bucket120 / minute
GET /search/suggestper X-User-Bucket240 / minute
POST /search/clicksper X-User-Bucket200 / minute
POST /search/boost-rulesper X-Tenant-Id30 / minute
POST /search/index:rebuildper X-Tenant-Id1 / 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.