Skip to main content

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

MethodPathPurposeAuthIdempotent
GET/searchUnified search across typesuseryes
POST/searchComplex search body (filters, facets)useryes
POST/search/recommendHome recommendations (EP-11 — cold-start + outbox event)useryes
POST/search/recommend/next-stepNext-step suggestions after course completion (EP-11 / US-55)useryes
GET/search/suggestAutocompleteuseryes
GET/recommendations/{userId}Personal recommendationsuser (self) or adminyes
POST/recommendations/{userId}/feedbackRecord click/dismiss/convert (EP-11)useryes (Idempotency-Key)
POST/search/reindexRebuild a tenant indextenant-admin / platform-adminno (202 async)
POST/search/rebuild-embeddingsRe-embed a tenant indexplatform-adminno (202 async)
GET/search/reindex/{jobId}Poll reindex job statussame actoryes
GET/search/debug/explain/{docId}Score explanationplatform-adminyes
GET/healthzLivenesspublicyes
GET/readyzReadiness (upstream check)publicyes

2. GET /api/v1/search

Query parameters

ParamTypeRequiredNotes
qstring (≤256)either q or filter body (POST)Free-text query; EP-11 / US-53 capped at 256 chars
typerepeated enumnocourse | lesson | block | listing | user | assignment | certificate
filter[]repeated stringnofield:op:value (e.g. taxonomy:eq:math.algebra)
facet[]repeated stringnofields to aggregate
semanticenumnooff | hybrid (default) | semantic-only
hybridAlphafloat 0..1nooverride blending weight (hybrid mode)
sortstringnorelevance (default) | updatedAt:desc | quality:desc
page[size]int 1..100nodefault 20
page[cursor]stringnoopaque
localeBCP47nodefaults 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

HTTPCodeMeaning
400SEARCH_QUERY_TOO_LONGq exceeds max length (256 in EP-11 reference impl.)
400validation.tenant_idX-Tenant-Id missing or not a UUID
422search.filter.invalidPOST body filters unknown field/op/value (EP-11)
403SEARCH_CROSS_TENANT_FORBIDDENX-Tenant-Id ≠ JWT tid
429SEARCH_RATE_LIMITEDper-actor quota
503SEARCH_UNAVAILABLEfull 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

ParamNotes
qprefix (1..64)
typeoptional filter
localeoptional

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

ParamNotes
contexthome | post-completion | marketplace | next-step
itemTypeoptional filter
limit1..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-Of header — 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 classEndpointLimit
anonymousanydenied (401)
learner/search60/min, 600/h
learner/suggest200/min
learner/recommendations/*30/min
tenant-admin/reindex2/day/tenant
platform-admin/rebuild-embeddings1/hour

Quotas tracked per-tenant and per-actor; responses include X-RateLimit-* headers.

12. Caching Semantics

  • Responses to /search and /suggest include Cache-Control: private, max-age=30.
  • ETag header per response body hash.
  • Clients MAY send If-None-Match; server responds 304.
  • Recommendations carry Last-Modified (= generatedAt) and Expires (= 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.