API Contracts
:::info Source
Sourced from services/search-service/API_CONTRACTS.md in the documentation repo.
:::
Inherits envelope, versioning, auth, idempotency, and error conventions from docs/05-api-design.md. This doc specifies only surfaces unique to search-service.
Base path: /api/v1 · Auth: Bearer JWT · Tenant header: X-Tenant-Id · All errors: RFC 9457 problem+json.
1. Endpoint Index
| Method | Path | Purpose | Auth | Idempotent |
|---|---|---|---|---|
| GET | /search | Unified search across types | user | yes |
| POST | /search | Complex search body (filters, facets) | user | yes |
| POST | /search/recommend | Home recommendations (EP-11 — cold-start + outbox event) | user | yes |
| POST | /search/recommend/next-step | Next-step suggestions after course completion (EP-11 / US-55) | user | yes |
| GET | /search/suggest | Autocomplete | user | yes |
| GET | /recommendations/{userId} | Personal recommendations | user (self) or admin | yes |
| POST | /recommendations/{userId}/feedback | Record click/dismiss/convert (EP-11) | user | yes (Idempotency-Key) |
| POST | /search/reindex | Rebuild a tenant index | tenant-admin / platform-admin | no (202 async) |
| POST | /search/rebuild-embeddings | Re-embed a tenant index | platform-admin | no (202 async) |
| GET | /search/reindex/{jobId} | Poll reindex job status | same actor | yes |
| GET | /search/debug/explain/{docId} | Score explanation | platform-admin | yes |
| GET | /healthz | Liveness | public | yes |
| GET | /readyz | Readiness (upstream check) | public | yes |
2. GET /api/v1/search
Query parameters
| Param | Type | Required | Notes |
|---|---|---|---|
q | string (≤256) | either q or filter body (POST) | Free-text query; EP-11 / US-53 capped at 256 chars |
type | repeated enum | no | course | lesson | block | listing | user | assignment | certificate |
filter[] | repeated string | no | field:op:value (e.g. taxonomy:eq:math.algebra) |
facet[] | repeated string | no | fields to aggregate |
semantic | enum | no | off | hybrid (default) | semantic-only |
hybridAlpha | float 0..1 | no | override blending weight (hybrid mode) |
sort | string | no | relevance (default) | updatedAt:desc | quality:desc |
page[size] | int 1..100 | no | default 20 |
page[cursor] | string | no | opaque |
locale | BCP47 | no | defaults to Accept-Language |
Response 200
{
"data": [
{
"id": "catalog:course:crs_01HA...",
"type": "course",
"title": { "en": "Intro to Algebra" },
"summary": { "en": "Foundational algebra..." },
"taxonomy": ["math.algebra.101"],
"score": 12.37,
"highlights": { "title.en": ["<em>Algebra</em>"] },
"reason": "lexical+semantic",
"explanation": "Matched title/summary and related catalog tags.",
"visibility": "org",
"updatedAt": "2026-03-30T12:00:00Z",
"aiProvenance": { "purpose": "search.hybrid", "model": "rec-ep11@...", "embedding": "tag_proxy" }
}
],
"meta": {
"page": { "size": 20, "cursor": null, "nextCursor": "eyJvIjoyMH0", "totalApproximate": 348 },
"facets": {
"taxonomy": [{ "key": "math.algebra", "count": 120 }],
"level": [{ "key": "beginner", "count": 180 }]
},
"sort": [{ "field": "_score", "dir": "desc" }],
"degraded": false,
"requestId": "req_01HA...",
"traceId": "00-abc-def-01",
"apiVersion": "v1.0"
}
}
Errors
| HTTP | Code | Meaning |
|---|---|---|
| 400 | SEARCH_QUERY_TOO_LONG | q exceeds max length (256 in EP-11 reference impl.) |
| 400 | validation.tenant_id | X-Tenant-Id missing or not a UUID |
| 422 | search.filter.invalid | POST body filters unknown field/op/value (EP-11) |
| 403 | SEARCH_CROSS_TENANT_FORBIDDEN | X-Tenant-Id ≠ JWT tid |
| 429 | SEARCH_RATE_LIMITED | per-actor quota |
| 503 | SEARCH_UNAVAILABLE | full outage (lex+vec) |
3. POST /api/v1/search
Body form of GET — needed when filter lists exceed URL size limit.
{
"q": "python for data science",
"types": ["course", "lesson"],
"filters": [
{ "field": "taxonomy", "op": "eq", "value": "math.algebra.101" },
{ "field": "tags", "op": "in", "value": ["cs.python", "data.ds"] },
{ "field": "level", "op": "eq", "value": "beginner" },
{ "field": "visibility", "op": "eq", "value": "org" }
],
"facets": ["taxonomy", "level"],
"semantic": "hybrid",
"hybridAlpha": 0.5,
"page": { "size": 20, "cursor": null }
}
Filter validation (EP-11 reference implementation): Only field in taxonomy, tags, level, visibility with op in eq, in, ne (subset) is accepted; otherwise 422 with search.filter.invalid.
Empty q returns top catalog ordering (same spirit as recommendations — US-53).
3.1 POST /api/v1/search/recommend (EP-11 / US-54)
Cold-start personalized rows for the learner home. Writes search.recommendation.generated.v1 to the outbox in the same transaction as the HTTP 200 body when the index returns data.
Headers: X-Tenant-Id (required), X-User-Id (optional).
Body (optional):
{ "personalizationOptOut": false }
Response 200 (shape compatible with unified search hits for BFF convenience):
{
"data": [
{
"id": "catalog:course:crs_01HA...",
"type": "course",
"title": { "en": "…" },
"summary": { "en": "…" },
"taxonomy": [],
"score": 9.9,
"reason": "personalized_cold_start | tenant_default | generic_catalog",
"explanation": "Based on your tenant catalog and recency (cold-start personalization).",
"modelVersion": "rec-ep11@2026-04-21",
"visibility": "org",
"updatedAt": "2026-04-21T12:00:00Z",
"aiProvenance": { "purpose": "recommend.home", "model": "…", "ranker": "cold_start" }
}
],
"meta": {
"personalizationOptOut": false,
"degraded": false,
"generationId": "gen_…",
"requestId": "req_…",
"traceId": "00-…-01",
"apiVersion": "v0.1.0"
}
}
Dismissal: Items dismissed via POST /recommendations/{userId}/feedback with action=dismiss are excluded for 90 days from subsequent recommend queries for that user (SQL NOT EXISTS over search.recommendation_feedback).
3.2 POST /api/v1/search/recommend/next-step (EP-11 / US-55)
Returns up to limit (default 3, max 10) ranked next courses after completing courseId, using tag overlap with the completed course as a ranking signal (reference implementation).
Body:
{ "courseId": "crs_01HA…", "limit": 3 }
Response 200: { "data": [ … ], "meta": { "requestId", "traceId", "apiVersion" } } with hit fields including reason: "next_step" and explanation.
4. GET /api/v1/search/suggest
Query
| Param | Notes |
|---|---|
q | prefix (1..64) |
type | optional filter |
locale | optional |
Response
{
"data": [
{ "text": "algebra", "type": "course", "weight": 0.94, "docId": "catalog:course:crs_01..." },
{ "text": "algebraic topology", "type": "course", "weight": 0.71 }
],
"meta": { "requestId": "req_01...", "apiVersion": "v1.0" }
}
Latency SLO: p95 ≤ 120ms. Responses are cached 30s.
5. GET /api/v1/recommendations/{userId}
Parameters
| Param | Notes |
|---|---|
context | home | post-completion | marketplace | next-step |
itemType | optional filter |
limit | 1..50 (default 20) |
Response
{
"data": [
{
"itemId": "catalog:course:crs_01HA...",
"itemType": "course",
"score": 0.87,
"reason": "completed_next_step",
"explanation": "Because you completed 'Algebra I', this builds on your trigonometry skills.",
"modelVersion": "rec-l2r@2026-03-14"
}
],
"meta": {
"generatedAt": "2026-04-15T08:12:00Z",
"expiresAt": "2026-04-15T09:12:00Z",
"freshness": "cached"
}
}
Authorization rules:
- Actor must be the user themselves or a tenant admin/manager acting on behalf (with
X-On-Behalf-Ofheader — audit logged).
6. POST /api/v1/recommendations/{userId}/feedback
Headers: X-Tenant-Id, X-User-Id must match {userId} (EP-11); optional Idempotency-Key.
Body:
{
"generationId": "01HA...",
"itemId": "catalog:course:crs_01HA...",
"action": "click | dismiss | convert | not_interested",
"position": 3,
"context": "home"
}
Response: 204 No Content. Persists feedback and emits search.recommendation.feedback.recorded.v1 on the outbox. Fuels offline L2R retraining pipeline (via analytics-service).
7. POST /api/v1/search/reindex
Initiates an asynchronous full rebuild of the caller's tenant index.
Body:
{
"scope": "tenant",
"includeEmbeddings": true,
"reason": "taxonomy-restructure"
}
Response 202 Accepted:
{
"data": {
"jobId": "job_01HA...",
"status": "queued",
"submittedAt": "2026-04-15T08:12:00Z"
},
"meta": { "pollUrl": "/api/v1/search/reindex/job_01HA..." }
}
Errors: 409 if another reindex running for that tenant.
8. GET /api/v1/search/reindex/{jobId}
{
"data": {
"jobId": "job_01HA...",
"status": "running",
"progress": { "total": 48500, "processed": 12300, "rate": 320 },
"startedAt": "...",
"etaSec": 175
}
}
9. GET /api/v1/search/debug/explain/{docId}
Platform-admin only. Returns BM25 breakdown, vector similarity, L2R feature contributions, final score. Used for ranking debugging.
10. OpenAPI Stub
openapi: 3.1.0
info:
title: Ghasi-edTech Search Service
version: 1.0.0
paths:
/api/v1/search:
get:
summary: Unified search
parameters:
- in: query; name: q; schema: { type: string, maxLength: 256 }
- in: query; name: type; schema: { type: array, items: { enum: [course,lesson,block,listing,user,assignment,certificate] } }
- in: query; name: semantic; schema: { enum: [off, hybrid, semantic-only], default: hybrid }
responses:
'200': { description: ok }
# ... (full spec in /openapi/search.yaml at repo root)
11. Rate Limits
| Actor class | Endpoint | Limit |
|---|---|---|
| anonymous | any | denied (401) |
| learner | /search | 60/min, 600/h |
| learner | /suggest | 200/min |
| learner | /recommendations/* | 30/min |
| tenant-admin | /reindex | 2/day/tenant |
| platform-admin | /rebuild-embeddings | 1/hour |
Quotas tracked per-tenant and per-actor; responses include X-RateLimit-* headers.
12. Caching Semantics
- Responses to
/searchand/suggestincludeCache-Control: private, max-age=30. ETagheader per response body hash.- Clients MAY send
If-None-Match; server responds 304. - Recommendations carry
Last-Modified(= generatedAt) andExpires(= expiresAt).
13. Deprecation Policy
No API may be removed without 90 days Sunset notice. Field removals require minor version bump and Deprecation: true on the prior version for one release cycle.