FEAT: Video Search In Discovery
Build, Push and Deploy Discovery / build-push-deploy (push) Successful in 1m57s

This commit is contained in:
FahdShalhoub
2026-08-29 17:56:11 +03:00
parent 6f9e04ee97
commit c3465bedb1
20 changed files with 915 additions and 9 deletions
+57 -5
View File
@@ -18,11 +18,12 @@ draining `discovery-catalogue-events`, the queue subscribed to the catalogue
topic `cms` announces ready videos on, and keeps its own copy in its own topic `cms` announces ready videos on, and keeps its own copy in its own
database. Neither service reads the other's tables and nothing polls `cms`. database. Neither service reads the other's tables and nothing polls `cms`.
It serves `GET /health`, `GET /api/videos/{id}` and the Swagger UI. Migration It serves `GET /health`, `GET /api/videos/{id}`, `QUERY /api/videos` (the
`0001_init` still creates nothing (it predates the domain and exists only catalogue search — see below) and the Swagger UI. Migration `0001_init` still
because `internal/db/migrate.go` embeds `migrations/*.sql`, which will not creates nothing (it predates the domain and exists only because
compile against an empty directory); `0002_create_videos_table` is where the `internal/db/migrate.go` embeds `migrations/*.sql`, which will not compile
schema actually starts. It now has an `internal/services` (SQS only) and an against an empty directory); `0002_create_videos_table` is where the schema
actually starts, and `0003`–`0005` add what the search needs. It now has an `internal/services` (SQS only) and an
`internal/consumers`, and a task role scoped to that one queue — receive, `internal/consumers`, and a task role scoped to that one queue — receive,
delete, get-attributes, and nothing else. It cannot publish back onto the delete, get-attributes, and nothing else. It cannot publish back onto the
topic: it is a subscriber, not a participant. topic: it is a subscriber, not a participant.
@@ -72,6 +73,57 @@ same video the same way). `categories` is a denormalized `TEXT[]` of names:
so the same announcement can arrive twice, and a plain `INSERT` would fail on so the same announcement can arrive twice, and a plain `INSERT` would fail on
the second, wedging every message queued behind it. A later announcement wins. the second, wedging every message queued behind it. A later announcement wins.
Three more columns/indexes exist purely for the search (`0003`–`0005`):
`search_vector` is a **generated** `tsvector` column (`to_tsvector('arabic',
title)`, `STORED`) with a GIN index; `categories` has a GIN index for the `&&`
overlap; and `videos_recent_idx` is a btree on `(created_at DESC, id DESC)`.
The text search configuration is `'arabic'`, not `'english'` or `'simple'`: it
stems Arabic while leaving Latin-script tokens as written, which suits a mixed
catalogue. The cost is that English is **not** stemmed, so "documentaries" does
not find "documentary". It is named explicitly in the column *and* in every
query — a query built under a different configuration stems its terms
differently and silently matches nothing.
### Catalogue search (`QUERY /api/videos`)
Served on **QUERY**, not GET or POST: a search is safe and idempotent (which
POST is not), but its parameters are a structured document that does not belong
in a query string. Go's `ServeMux` routes the method fine. Two consequences to
know before touching it:
- It **cannot appear in the OpenAPI spec**. Swagger 2.0 and OpenAPI 3.x define
`PathItem` with a fixed field per method and there is no slot for QUERY;
`swag` rejects the annotation outright with `invalid method: QUERY`. So the
handler carries **no `@Router` annotation** and the endpoint is described in
prose in `main.go`'s `@description` instead. Adding a `@Router … [query]`
line breaks `swag init` for the whole service.
- QUERY is still an IETF draft (`draft-ietf-httpbis-safe-method-w-body`), so
expect intermediaries that have never heard of it, and no HTTP caching of the
kind a `GET` with a query string would get.
Request `{title, categories, limit, cursor}`, response `{videos, nextCursor}`.
`title` is matched lexically, ANDing the words with the **last one as a prefix**
(`desert & fal:*`) so a part-typed word still matches; `categories` narrows to
videos filed under **any** of the names (`&&`, not `@>`); `limit` defaults to 20
and is **capped at 100** rather than refused. Paging is **keyset**, not offset:
the opaque cursor carries the last row's `(rank, created_at, id)`, so pages stay
stable while the catalogue is being written to. The query asks for `limit+1`
rows and hands back a cursor only when the extra row appears, which is how the
last page reports itself as last.
`tsQueryFor` builds the tsquery by splitting input on everything that is not a
letter or digit. That drops every character tsquery gives a meaning to, which is
what makes it safe to interpolate — `websearch_to_tsquery` parses user syntax
but cannot express a prefix match.
A measured caveat: for a **ranked** search, deep paging is not a seek. `ts_rank`
is computed per row, so the keyset comparison lands as a filter rather than an
index condition and every matching row is still scanned. Keyset paging still
buys stability and avoids OFFSET's growing discard cost, but only the unranked
browse path (no title term) is a true index seek — that is what
`videos_recent_idx` is for, and it takes a 200k-row browse from ~15 ms to
~0.03 ms.
`cms`, `discovery` and `infrastructure` hold no test files of their own. The `cms`, `discovery` and `infrastructure` hold no test files of their own. The
only tests in the repo are the black-box BDD scenarios in `tests/` — see below. only tests in the repo are the black-box BDD scenarios in `tests/` — see below.
+1 -1
View File
@@ -145,7 +145,7 @@ var SwaggerInfo = &swag.Spec{
BasePath: "/", BasePath: "/",
Schemes: []string{}, Schemes: []string{},
Title: "Thamanyah Discovery API", Title: "Thamanyah Discovery API",
Description: "JSON API for browsing the Thamanyah catalogue. Read-side counterpart to the CMS, which is what ingests videos.", Description: "JSON API for browsing the Thamanyah catalogue. Read-side counterpart to the CMS, which is what ingests videos.\n\nNot listed below: `QUERY /api/videos`, the catalogue search. It takes a JSON body `{title, categories, limit, cursor}` and answers `{videos, nextCursor}` — title is matched lexically with the last word as a prefix, categories narrow to videos filed under any one of them, limit defaults to 20 and is capped at 100, and cursor is the opaque `nextCursor` of a previous search. It is absent here because OpenAPI has no slot for the QUERY method, not because it is unsupported; see handlers.SearchVideos.",
InfoInstanceName: "swagger", InfoInstanceName: "swagger",
SwaggerTemplate: docTemplate, SwaggerTemplate: docTemplate,
LeftDelim: "{{", LeftDelim: "{{",
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"swagger": "2.0", "swagger": "2.0",
"info": { "info": {
"description": "JSON API for browsing the Thamanyah catalogue. Read-side counterpart to the CMS, which is what ingests videos.", "description": "JSON API for browsing the Thamanyah catalogue. Read-side counterpart to the CMS, which is what ingests videos.\n\nNot listed below: `QUERY /api/videos`, the catalogue search. It takes a JSON body `{title, categories, limit, cursor}` and answers `{videos, nextCursor}` — title is matched lexically with the last word as a prefix, categories narrow to videos filed under any one of them, limit defaults to 20 and is capped at 100, and cursor is the opaque `nextCursor` of a previous search. It is absent here because OpenAPI has no slot for the QUERY method, not because it is unsupported; see handlers.SearchVideos.",
"title": "Thamanyah Discovery API", "title": "Thamanyah Discovery API",
"contact": {}, "contact": {},
"version": "1.0" "version": "1.0"
+4 -2
View File
@@ -43,8 +43,10 @@ definitions:
type: object type: object
info: info:
contact: {} contact: {}
description: JSON API for browsing the Thamanyah catalogue. Read-side counterpart description: |-
to the CMS, which is what ingests videos. JSON API for browsing the Thamanyah catalogue. Read-side counterpart to the CMS, which is what ingests videos.
Not listed below: `QUERY /api/videos`, the catalogue search. It takes a JSON body `{title, categories, limit, cursor}` and answers `{videos, nextCursor}` — title is matched lexically with the last word as a prefix, categories narrow to videos filed under any one of them, limit defaults to 20 and is capped at 100, and cursor is the opaque `nextCursor` of a previous search. It is absent here because OpenAPI has no slot for the QUERY method, not because it is unsupported; see handlers.SearchVideos.
title: Thamanyah Discovery API title: Thamanyah Discovery API
version: "1.0" version: "1.0"
paths: paths:
+31
View File
@@ -11,3 +11,34 @@ type Video struct {
PlaybackURL string `json:"playbackUrl" example:"https://d111111abcdef8.cloudfront.net/videos/abc123/index.m3u8"` PlaybackURL string `json:"playbackUrl" example:"https://d111111abcdef8.cloudfront.net/videos/abc123/index.m3u8"`
Categories []string `json:"categories" example:"documentary,news"` Categories []string `json:"categories" example:"documentary,news"`
} }
// SearchRequest is the body of QUERY /api/videos: what a reader is looking
// for. Every member is optional — an empty body browses the whole catalogue.
type SearchRequest struct {
// Title is matched lexically against video titles: the words are all
// required, and the last is treated as a prefix so a part-typed word still
// finds something.
Title string `json:"title" example:"desert fal"`
// Categories narrows the search to videos filed under any one of these
// names — not all of them, so it widens the choice of category while
// narrowing the results. Empty means every category.
Categories []string `json:"categories" example:"documentary,news"`
// Limit is how many videos to return. Absent or zero takes the default of
// 20; anything above the maximum of 100 is capped to it rather than
// refused, and the cursor still reaches the rest.
Limit int `json:"limit" example:"20"`
// Cursor asks for the page after the one a previous search ended at. Send
// back the nextCursor from that search, unchanged; it is opaque, and the
// only thing to do with it is return it.
Cursor string `json:"cursor"`
}
// SearchResults is one page of search results.
type SearchResults struct {
// Videos are the matches, most relevant first. Never null: a search that
// matches nothing is an empty list.
Videos []Video `json:"videos"`
// NextCursor reaches the page after this one. Empty on the last page,
// which is how a reader knows there is no more to ask for.
NextCursor string `json:"nextCursor"`
}
@@ -0,0 +1,2 @@
DROP INDEX IF EXISTS videos_search_vector_idx;
ALTER TABLE videos DROP COLUMN IF EXISTS search_vector;
@@ -0,0 +1,25 @@
-- Lexical search over video titles.
--
-- The vector is a generated column rather than something the consumer writes:
-- the catalogue's only writer is SaveVideo, and a title that arrives in an
-- announcement should be searchable because it is stored, not because a second
-- statement remembered to index it. STORED because a GIN index needs the value
-- on disk to index it at all.
--
-- 'arabic' is the text search configuration, not 'english' or 'simple': it
-- stems Arabic (الوثائقي -> وثايق) while leaving Latin-script tokens as written,
-- which is the right trade for a mixed catalogue. The cost is that English
-- words are not stemmed, so "documentaries" does not find "documentary".
--
-- The configuration is named explicitly rather than left to default_text_search_config,
-- which is a per-session GUC — an expression that reads it is only STABLE, and
-- a generated column requires IMMUTABLE. Naming it is also what keeps the
-- column and every query in step: a query built with a different
-- configuration would stem its terms differently and silently match nothing.
ALTER TABLE videos
ADD COLUMN search_vector tsvector
GENERATED ALWAYS AS (to_tsvector('arabic', title)) STORED;
-- GIN, not GiST: the catalogue is read far more than it is written, and GIN
-- answers @@ faster at the cost of a slower update — the right way round here.
CREATE INDEX videos_search_vector_idx ON videos USING GIN (search_vector);
@@ -0,0 +1 @@
DROP INDEX IF EXISTS videos_categories_idx;
@@ -0,0 +1,7 @@
-- Narrowing a search by category.
--
-- categories is a TEXT[] of names, and the search overlaps it against the
-- names a reader asked for (&&). GIN is the index type that operator can use;
-- without it the overlap is a filter applied after the rows are read, which is
-- the whole table once the title term is broad.
CREATE INDEX videos_categories_idx ON videos USING GIN (categories);
@@ -0,0 +1 @@
DROP INDEX IF EXISTS videos_recent_idx;
@@ -0,0 +1,17 @@
-- Browsing the catalogue, and paging through it.
--
-- Every search orders by (rank, created_at, id). When there is no title term
-- the rank is a constant, and Postgres folds a constant sort key away — so the
-- order that remains is exactly this index, and a browse becomes an index-only
-- scan that stops as soon as the page is full instead of sorting the table.
--
-- Measured at 200k rows: a first page of a browse goes from a 15 ms parallel
-- sequential scan with a top-N sort to 0.03 ms. It matters more for the cursor
-- than for the first page — the keyset comparison (created_at, id) < (…)
-- becomes an index condition rather than a filter, so a deep page is a seek
-- to the right place in the index rather than a walk through everything
-- before it.
--
-- DESC on both columns to match the ORDER BY: a btree can be read backwards,
-- but only as a whole, so a mixed-direction ordering could not use it.
CREATE INDEX videos_recent_idx ON videos (created_at DESC, id DESC);
@@ -7,3 +7,8 @@ import "errors"
// ErrVideoNotFound reports that the catalogue holds no video with that id. // ErrVideoNotFound reports that the catalogue holds no video with that id.
var ErrVideoNotFound = errors.New("video not found") var ErrVideoNotFound = errors.New("video not found")
// ErrInvalidCursor reports that a paging cursor could not be read. It is the
// caller's mistake, not a fault in the catalogue, so it must not reach the
// handler as an indistinguishable query failure.
var ErrInvalidCursor = errors.New("invalid cursor")
@@ -3,8 +3,13 @@ package repositories
import ( import (
"context" "context"
"database/sql" "database/sql"
"encoding/base64"
"encoding/json"
"errors" "errors"
"strings"
"thamanyah/discovery/internal/models" "thamanyah/discovery/internal/models"
"time"
"unicode"
"github.com/lib/pq" "github.com/lib/pq"
) )
@@ -57,3 +62,191 @@ func (svc VideoRepository) GetVideoByID(ctx context.Context, id string) (models.
return v, nil return v, nil
} }
// searchConfig is the text search configuration the search_vector column is
// generated with. Every query has to name the same one: a tsquery built under
// a different configuration stems its terms differently and matches nothing.
const searchConfig = "arabic"
// tsQueryFor turns what a reader typed into a tsquery.
//
// The words are ANDed, and the last one is given a :* prefix match — someone
// typing into a search box is usually part-way through their last word, so
// "desert fal" should still find "Desert Falcons". Earlier words are matched
// whole, because they have been finished.
//
// The input is split on everything that is not a letter or a digit, which
// drops every character tsquery gives a meaning to (&, |, !, :, *, parens) and
// leaves nothing that could change the shape of the query. That is what makes
// it safe to interpolate the result into to_tsquery: the alternative,
// websearch_to_tsquery, parses user syntax but cannot express a prefix match.
//
// It returns "" when there is nothing to search for, which callers read as
// "no title filter" rather than "match nothing".
func tsQueryFor(term string) string {
words := strings.FieldsFunc(term, func(r rune) bool {
return !unicode.IsLetter(r) && !unicode.IsDigit(r)
})
if len(words) == 0 {
return ""
}
for i, word := range words {
words[i] = strings.ToLower(word)
}
return strings.Join(words, " & ") + ":*"
}
// searchCursor is where a page of results stopped: the sort key of its last
// row. Paging resumes by asking for the rows that sort after it.
//
// It carries the rank as well as the row's identity because the results are
// ordered by relevance first, and "the row after this one" is only a
// well-defined place if every term of the ordering is pinned.
type searchCursor struct {
Rank float64 `json:"r"`
CreatedAt time.Time `json:"t"`
ID string `json:"i"`
}
// encode renders a cursor as an opaque string. Opaque on purpose: it is a
// place in a result set, not a number a client should do arithmetic on, and
// making it look like one invites callers to guess a page.
func (c searchCursor) encode() (string, error) {
raw, err := json.Marshal(c)
if err != nil {
return "", err
}
return base64.RawURLEncoding.EncodeToString(raw), nil
}
func decodeCursor(encoded string) (searchCursor, error) {
raw, err := base64.RawURLEncoding.DecodeString(encoded)
if err != nil {
return searchCursor{}, ErrInvalidCursor
}
var c searchCursor
if err := json.Unmarshal(raw, &c); err != nil {
return searchCursor{}, ErrInvalidCursor
}
if strings.TrimSpace(c.ID) == "" {
return searchCursor{}, ErrInvalidCursor
}
return c, nil
}
// SearchVideos finds catalogued videos whose title matches the given term and
// which are filed under any of the given categories, most relevant first.
//
// It returns the page and the cursor that reaches the page after it; that
// cursor is empty on the last page, which is how a caller knows to stop.
//
// The title predicate is skipped entirely when the term yields no words, and
// the category predicate when no categories are named, so a search with
// neither browses the whole catalogue newest-first.
//
// Paging is by key rather than by offset. OFFSET makes the database walk and
// discard every row it skips, so deep pages get steadily more expensive, and
// it addresses a page by its position — which moves whenever a video is
// announced, repeating or skipping rows for a reader mid-browse. Comparing
// against the last row's sort key has neither problem: the work is the same at
// any depth, and the page after a given row is that same page however much has
// been added since.
func (svc VideoRepository) SearchVideos(ctx context.Context, term string, categories []string, limit int, cursor string) ([]models.Video, string, error) {
query := tsQueryFor(term)
// Never nil: a nil slice reaches Postgres as NULL, and cardinality(NULL)
// is NULL rather than 0, so the "no category filter" branch would not fire
// and every search would come back empty.
if categories == nil {
categories = []string{}
}
// A zero cursor is passed as NULL, which the query reads as "start at the
// beginning" rather than as a row to seek past.
var after *searchCursor
if strings.TrimSpace(cursor) != "" {
decoded, err := decodeCursor(cursor)
if err != nil {
return nil, "", err
}
after = &decoded
}
var (
afterRank *float64
afterTime *time.Time
afterID *string
)
if after != nil {
afterRank, afterTime, afterID = &after.Rank, &after.CreatedAt, &after.ID
}
// One row more than asked for: if it comes back, there is another page and
// the extra row is discarded. That is what lets the last page say so,
// rather than handing out a cursor to an empty page.
//
// The rank is computed in the subquery and referenced by name in both the
// cursor comparison and the ORDER BY, so the two cannot drift apart — if
// they did, "the row after this one" would mean something different from
// the order the rows are actually in, and pages would overlap.
rows, err := svc.SQLDB.QueryContext(ctx, `
SELECT id, title, playback_url, categories, created_at, rank
FROM (
SELECT id, title, playback_url, categories, created_at,
CASE WHEN $1 = '' THEN 0::float8
ELSE ts_rank(search_vector, to_tsquery('`+searchConfig+`', $1))::float8
END AS rank
FROM videos
WHERE ($1 = '' OR search_vector @@ to_tsquery('`+searchConfig+`', $1))
AND (cardinality($2::text[]) = 0 OR categories && $2)
) ranked
WHERE $4::float8 IS NULL
OR (rank, created_at, id) < ($4::float8, $5::timestamptz, $6::uuid)
ORDER BY rank DESC, created_at DESC, id DESC
LIMIT $3
`, query, pq.Array(categories), limit+1, afterRank, afterTime, afterID)
if err != nil {
return nil, "", err
}
defer rows.Close()
// Never nil: a search that matches nothing is an empty list on the wire,
// not null.
videos := []models.Video{}
ranks := []float64{}
for rows.Next() {
var (
v models.Video
rank float64
)
if err := rows.Scan(&v.ID, &v.Title, &v.PlaybackURL, pq.Array(&v.Categories), &v.CreatedAt, &rank); err != nil {
return nil, "", err
}
videos = append(videos, v)
ranks = append(ranks, rank)
}
if err := rows.Err(); err != nil {
return nil, "", err
}
if len(videos) <= limit {
return videos, "", nil
}
videos, ranks = videos[:limit], ranks[:limit]
last := videos[len(videos)-1]
next, err := searchCursor{
Rank: ranks[len(ranks)-1],
CreatedAt: last.CreatedAt,
ID: last.ID,
}.encode()
if err != nil {
return nil, "", err
}
return videos, next, nil
}
+74
View File
@@ -1,6 +1,7 @@
package handlers package handlers
import ( import (
"encoding/json"
"errors" "errors"
"log" "log"
"net/http" "net/http"
@@ -47,3 +48,76 @@ func GetVideo(w http.ResponseWriter, r *http.Request) {
Categories: append([]string{}, video.Categories...), Categories: append([]string{}, video.Categories...),
}) })
} }
const (
// searchPageSize is how many videos a search returns when the caller does
// not say. Small enough to keep the common request cheap.
searchPageSize = 20
// maxSearchPageSize is the largest page the catalogue will build, however
// big a page the caller asks for. The endpoint is read constantly, and a
// page size is a request for work: without a ceiling, one client asking
// for the whole catalogue in a single response makes every other reader
// wait behind it.
//
// Asking for more is not an error — the page is simply capped, and the
// cursor still reaches the rest — because a client that wants everything
// is doing something legitimate, just not in one request.
maxSearchPageSize = 100
)
// SearchVideos serves the catalogue's lexical search.
//
// It is served on QUERY rather than GET or POST: a search is safe and
// idempotent, which POST is not, but its parameters are a structured document
// that does not belong in a query string. QUERY says both. It is specified in
// draft-ietf-httpbis-safe-method-w-body, not in RFC 9110 — still a draft, so
// expect intermediaries that have never heard of it. Note it also cannot be
// expressed in the generated OpenAPI spec — Swagger 2.0 and OpenAPI 3.x define a fixed set of
// per-method fields with no slot for QUERY, and swag rejects the annotation
// outright — so this endpoint is deliberately absent from /swagger and
// documented here instead.
func SearchVideos(w http.ResponseWriter, r *http.Request) {
var request api.SearchRequest
if err := json.NewDecoder(r.Body).Decode(&request); err != nil {
writeProblem(w, http.StatusBadRequest, "Malformed Search",
"The request body could not be read as JSON. Send a JSON object describing what to search for; an empty object browses the whole catalogue.")
return
}
limit := request.Limit
if limit <= 0 {
limit = searchPageSize
}
if limit > maxSearchPageSize {
limit = maxSearchPageSize
}
found, next, err := repositories.VideoRepo.SearchVideos(r.Context(), request.Title, request.Categories, limit, request.Cursor)
if errors.Is(err, repositories.ErrInvalidCursor) {
writeProblem(w, http.StatusBadRequest, "Malformed Search Cursor",
"The cursor could not be read. Send back the nextCursor from a previous search unchanged, or omit it to start from the first page.")
return
}
if err != nil {
log.Printf("Something Went Wrong Searching The Catalogue: title=%q: %s", request.Title, err)
writeProblem(w, http.StatusInternalServerError, "Something Went Wrong Searching The Catalogue",
"The catalogue could not be searched. This is a server-side fault and the request was not processed; retrying in a few moments may succeed.")
return
}
// Translated field by field, for the same reason GetVideo does it: the row
// and the published schema are different things.
videos := make([]api.Video, 0, len(found))
for _, v := range found {
videos = append(videos, api.Video{
ID: v.ID,
Title: v.Title,
PlaybackURL: v.PlaybackURL,
Categories: append([]string{}, v.Categories...),
})
}
writeJSON(w, http.StatusOK, api.SearchResults{Videos: videos, NextCursor: next})
}
+5
View File
@@ -23,6 +23,8 @@ import (
// @title Thamanyah Discovery API // @title Thamanyah Discovery API
// @version 1.0 // @version 1.0
// @description JSON API for browsing the Thamanyah catalogue. Read-side counterpart to the CMS, which is what ingests videos. // @description JSON API for browsing the Thamanyah catalogue. Read-side counterpart to the CMS, which is what ingests videos.
// @description
// @description Not listed below: `QUERY /api/videos`, the catalogue search. It takes a JSON body `{title, categories, limit, cursor}` and answers `{videos, nextCursor}` — title is matched lexically with the last word as a prefix, categories narrow to videos filed under any one of them, limit defaults to 20 and is capped at 100, and cursor is the opaque `nextCursor` of a previous search. It is absent here because OpenAPI has no slot for the QUERY method, not because it is unsupported; see handlers.SearchVideos.
// @BasePath / // @BasePath /
func main() { func main() {
@@ -81,6 +83,9 @@ func runServer() {
mux.HandleFunc("GET /health", handlers.Health) mux.HandleFunc("GET /health", handlers.Health)
mux.HandleFunc("GET /api/videos/{id}", handlers.GetVideo) mux.HandleFunc("GET /api/videos/{id}", handlers.GetVideo)
// QUERY, not GET: a search is safe and idempotent but carries a structured
// body. See handlers.SearchVideos for why it is absent from the spec.
mux.HandleFunc("QUERY /api/videos", handlers.SearchVideos)
// Swagger UI and the generated spec. The UI assets are embedded in the // Swagger UI and the generated spec. The UI assets are embedded in the
// binary by swaggo/files, so this needs no static directory on disk. // binary by swaggo/files, so this needs no static directory on disk.
+13
View File
@@ -77,6 +77,19 @@ Both steps of the upload flow, end to end:
not there at all, and a video announced a second time is updated rather than not there at all, and a video announced a second time is updated rather than
colliding with its own row colliding with its own row
- searching that catalogue over `QUERY /api/videos`: finding a video by a word
in its title and by a part-typed one, narrowing to a category and being left
out of another, several categories meaning "any of", paging by cursor so the
pages tile the results exactly once, refusing a cursor no search issued, and
capping a page size that asks for the whole catalogue
Search scenarios give every video they catalogue a nonce in its title, and
assert relative to the video they created ("returns that video") rather than on
absolute counts. Nothing cleans up between runs, so a fixed title accumulates a
copy per run and a count would stop meaning anything. The paging scenario shares
one nonce across its videos so that searching for it matches that run's cohort
and nothing else.
The publication scenarios read `tests-catalogue-events`, the suite's **own** The publication scenarios read `tests-catalogue-events`, the suite's **own**
queue on the catalogue topic — not discovery's. A consumer destroys what it queue on the catalogue topic — not discovery's. A consumer destroys what it
reads, so sharing discovery's queue would have the suite and the running reads, so sharing discovery's queue would have the suite and the running
+49
View File
@@ -139,6 +139,55 @@ type catalogueVideoBody struct {
Categories []string `json:"categories"` Categories []string `json:"categories"`
} }
// searchRequest is the body of QUERY /api/videos on discovery: what a reader
// is looking for, plus where in the results to carry on from.
//
// Every field is optional. An empty body is the whole catalogue, newest first.
type searchRequest struct {
Title string `json:"title,omitempty"`
Categories []string `json:"categories,omitempty"`
Limit int `json:"limit,omitempty"`
Cursor string `json:"cursor,omitempty"`
}
// searchResultsBody is what that query answers with: the page of videos, and
// the cursor that reaches the page after it. NextCursor is empty on the last
// page, which is how a client knows to stop.
type searchResultsBody struct {
Videos []catalogueVideoBody `json:"videos"`
NextCursor string `json:"nextCursor"`
}
// ids lists the video ids in the page, for asserting on which videos came back
// without caring about the rest of their fields.
func (b searchResultsBody) ids() []string {
found := make([]string, 0, len(b.Videos))
for _, v := range b.Videos {
found = append(found, v.ID)
}
return found
}
func (b searchResultsBody) holds(id string) bool {
for _, v := range b.Videos {
if v.ID == id {
return true
}
}
return false
}
// queryJSON sends a QUERY request — the method the catalogue search is served
// on, chosen because a search is safe and idempotent like a GET but carries a
// structured body no query string would hold comfortably.
func (c *client) queryJSON(path string, payload any) (response, error) {
encoded, err := json.Marshal(payload)
if err != nil {
return response{}, err
}
return c.send("QUERY", c.base+path, "application/json", encoded)
}
// postJSON marshals payload and posts it to a path on the CMS. // postJSON marshals payload and posts it to a path on the CMS.
func (c *client) postJSON(path string, payload any) (response, error) { func (c *client) postJSON(path string, payload any) (response, error) {
encoded, err := json.Marshal(payload) encoded, err := json.Marshal(payload)
+78
View File
@@ -0,0 +1,78 @@
Feature: Searching the catalogue
Discovery holds a copy of every video cms has announced as ready. Reaching
one by id is no use to a reader who does not have an id — finding something
to watch means searching for it by what it is called and what it is filed
under.
The search is lexical: it matches the words of a title, not its meaning. The
last word a reader types is treated as a prefix, because someone typing into
a search box is usually part-way through a word.
Background:
Given the CMS API is available
And the Discovery API is available
Scenario: Finding a catalogued video by a word in its title
Given the catalogue holds a video titled "Desert Falcons"
When I search the catalogue for that video's title
Then the search returns that video
# The last word is matched as a prefix, so the results keep up with someone
# still typing. Earlier words are matched whole — they have been finished.
Scenario: Finding a video from a part-typed word
Given the catalogue holds a video titled "Desert Falcons"
When I search the catalogue for "Desert Fal"
Then the search returns that video
# Categories narrow a search rather than widening it: a video filed elsewhere
# is not an answer to a reader who asked for one category in particular.
Scenario: A search narrowed to another category leaves the video out
Given the catalogue holds a video titled "Desert Falcons" filed under "documentary"
When I search the catalogue for that video's title in category "news"
Then the search does not return that video
Scenario: A search narrowed to the video's own category still finds it
Given the catalogue holds a video titled "Desert Falcons" filed under "documentary"
When I search the catalogue for that video's title in category "documentary"
Then the search returns that video
# Several categories mean "any of these", not "all of these": naming more of
# them offers the reader more, rather than demanding the video be filed under
# every one at once.
Scenario: Naming several categories matches a video filed under any of them
Given the catalogue holds a video titled "Desert Falcons" filed under "documentary"
When I search the catalogue for that video's title in categories "news, documentary"
Then the search returns that video
# Paging is by cursor rather than by offset: the catalogue is added to while
# people are reading it, and an offset silently repeats or skips a video when
# something is announced between one page and the next. A cursor names where
# the last page stopped, so the page after it is the same page whenever it is
# asked for.
Scenario: Paging through the results a page at a time
Given the catalogue holds 3 videos titled "Mountain Wolves"
When I search the catalogue for that title 2 at a time
Then the search returns 2 videos
And the search offers a cursor to the next page
When I follow the cursor
Then the search returns 1 video
And the search offers no further cursor
And the pages together hold every one of those videos exactly once
# A cursor is opaque, so a client that makes one up has made a mistake rather
# than found a fault. Saying 400 is what tells the two apart.
Scenario: Refusing a cursor no search issued
Given the catalogue holds a video titled "Desert Falcons"
When I search the catalogue for that video's title from the cursor "not-a-cursor"
Then the request is rejected with status 400
And the problem title is "Malformed Search Cursor"
# A page size is a request for work, and an uncapped one is a request for all
# of it — the catalogue is read constantly, and one client asking for every
# row at once should not be able to make everyone else wait. The cap applies
# silently rather than as a rejection: the reader still gets a page, and the
# cursor still reaches the rest.
Scenario: Capping a page size that asks for the whole catalogue
When I search the whole catalogue 5000 at a time
Then the search returns at most 100 videos
And the search offers a cursor to the next page
+315
View File
@@ -3,8 +3,10 @@ package tests
import ( import (
"bytes" "bytes"
"context" "context"
"fmt"
"net/url" "net/url"
"slices" "slices"
"strconv"
"strings" "strings"
"time" "time"
@@ -902,3 +904,316 @@ func theCatalogueEventuallyHoldsThatVideoWithNoPlaybackURL(t gobdd.StepTest, ctx
t.Errorf("the catalogue still points a player at %q for video %q after %s; the later "+ t.Errorf("the catalogue still points a player at %q for video %q after %s; the later "+
"announcement carried no playlist and should have replaced it", last, w.video.ID, catalogueSettleTimeout) "announcement carried no playlist and should have replaced it", last, w.video.ID, catalogueSettleTimeout)
} }
// --- Catalogue search ------------------------------------------------------
// searchLimit is the page size the search scenarios ask for unless they are
// about paging itself.
const searchLimit = 20
// uniqueTitle appends a nonce to the title a scenario names.
//
// Scenarios write real rows and nothing cleans up after them, so a fixed title
// accumulates a copy per run and "the search returns exactly this video" stops
// meaning anything. The nonce keeps every run's video findable on its own
// while the feature file still reads in plain words.
func uniqueTitle(title string) string {
return fmt.Sprintf("%s %s", title, strings.ToUpper(strconv.FormatInt(time.Now().UnixNano(), 36)))
}
// theCatalogueHoldsAVideoTitled puts one video into the catalogue under a
// title the scenario chose: register it, report its transcode finished, and
// wait for the announcement to land on the read side.
func theCatalogueHoldsAVideoTitled(t gobdd.StepTest, ctx gobdd.Context, title string) {
catalogueVideoTitledUnder(t, ctx, title, "other")
}
// catalogueVideoTitledUnder is the whole write-to-read round trip for one
// video, which is what "the catalogue holds …" costs: cms issues the id, the
// job completes, cms announces it, discovery ingests it.
func catalogueVideoTitledUnder(t gobdd.StepTest, ctx gobdd.Context, title, categories string) {
w := worldOf(t, ctx)
full := uniqueTitle(title)
requestUploadSlot(t, ctx, "searchable.mp4", "video/mp4")
uploadTheFile(t, ctx)
registerUploadedVideo(t, ctx, full, categories)
if w.last.status != 201 {
t.Fatalf("could not register a video to search for: %s", w.last.summary())
return
}
w.searchTitle = full
mediaConvertReportsState(t, ctx, "COMPLETE")
theCatalogueEventuallyHoldsThatVideo(t, ctx)
}
// searchTheCatalogueForThatTitle searches for the exact title the scenario's
// video was catalogued under, nonce included — the scenario says "that video's
// title" precisely so it does not have to know about the nonce.
func searchTheCatalogueForThatTitle(t gobdd.StepTest, ctx gobdd.Context) {
w := worldOf(t, ctx)
if strings.TrimSpace(w.searchTitle) == "" {
t.Fatalf("no video has been catalogued, so there is no title to search for")
return
}
searchTheCatalogue(t, w, searchRequest{Title: w.searchTitle, Limit: searchLimit})
}
// searchTheCatalogue performs the query and decodes the page, leaving both the
// raw exchange and the decoded results on the world so a "Then" step can
// assert on either.
func searchTheCatalogue(t gobdd.StepTest, w *world, request searchRequest) {
if w.discovery == nil {
w.discovery = newDiscoveryClient()
}
result, err := w.discovery.queryJSON("/api/videos", request)
if err != nil {
t.Fatalf("could not search the catalogue: %s", err)
return
}
w.last = result
w.results = searchResultsBody{}
// A rejected search has no page to decode; the scenario asserting the
// rejection reads w.last instead.
if result.status == 200 {
if err := result.json(&w.results); err != nil {
t.Fatalf("could not decode the search results: %s", err)
}
}
}
func theSearchReturnsThatVideo(t gobdd.StepTest, ctx gobdd.Context) {
w := worldOf(t, ctx)
if w.last.status != 200 {
t.Fatalf("the search did not succeed: %s", w.last.summary())
return
}
if !w.results.holds(w.video.ID) {
t.Errorf("the search for %q did not return video %q; it returned %v",
w.searchTitle, w.video.ID, w.results.ids())
}
}
// searchTheCatalogueFor searches for a term the scenario spells out, rather
// than for the title of the video it catalogued.
func searchTheCatalogueFor(t gobdd.StepTest, ctx gobdd.Context, term string) {
searchTheCatalogue(t, worldOf(t, ctx), searchRequest{Title: term, Limit: searchLimit})
}
// theCatalogueHoldsAVideoTitledUnder is theCatalogueHoldsAVideoTitled for a
// scenario that cares which categories the video is filed under.
func theCatalogueHoldsAVideoTitledUnder(t gobdd.StepTest, ctx gobdd.Context, title, categories string) {
catalogueVideoTitledUnder(t, ctx, title, categories)
}
func searchTheCatalogueForThatTitleInCategory(t gobdd.StepTest, ctx gobdd.Context, category string) {
w := worldOf(t, ctx)
if strings.TrimSpace(w.searchTitle) == "" {
t.Fatalf("no video has been catalogued, so there is no title to search for")
return
}
searchTheCatalogue(t, w, searchRequest{
Title: w.searchTitle,
Categories: []string{category},
Limit: searchLimit,
})
}
func theSearchDoesNotReturnThatVideo(t gobdd.StepTest, ctx gobdd.Context) {
w := worldOf(t, ctx)
if w.last.status != 200 {
t.Fatalf("the search did not succeed: %s", w.last.summary())
return
}
if w.results.holds(w.video.ID) {
t.Errorf("the search returned video %q, which it should have left out; it returned %v",
w.video.ID, w.results.ids())
}
}
// searchTheCatalogueForThatTitleInCategories is the several-categories form,
// for pinning that they mean "any of" rather than "all of".
func searchTheCatalogueForThatTitleInCategories(t gobdd.StepTest, ctx gobdd.Context, categories string) {
w := worldOf(t, ctx)
if strings.TrimSpace(w.searchTitle) == "" {
t.Fatalf("no video has been catalogued, so there is no title to search for")
return
}
names := []string{}
for _, name := range strings.Split(categories, ",") {
if name = strings.TrimSpace(name); name != "" {
names = append(names, name)
}
}
searchTheCatalogue(t, w, searchRequest{
Title: w.searchTitle,
Categories: names,
Limit: searchLimit,
})
}
// theCatalogueHoldsVideosTitled catalogues several videos under one shared
// title, which is what a paging scenario needs: enough matches to fill more
// than one page.
//
// The nonce is generated once and shared, rather than per video, so that
// searching for it matches this scenario's videos and no others. Runs leave
// their videos behind, so a term that also matched a previous run's would make
// "the search returns 2 videos" mean nothing.
func theCatalogueHoldsVideosTitled(t gobdd.StepTest, ctx gobdd.Context, count int, title string) {
w := worldOf(t, ctx)
w.searchTitle = uniqueTitle(title)
w.cohort = nil
w.pageSeen = nil
for i := 0; i < count; i++ {
requestUploadSlot(t, ctx, "searchable.mp4", "video/mp4")
uploadTheFile(t, ctx)
registerUploadedVideo(t, ctx, w.searchTitle, "other")
if w.last.status != 201 {
t.Fatalf("could not register video %d of %d to page through: %s", i+1, count, w.last.summary())
return
}
mediaConvertReportsState(t, ctx, "COMPLETE")
theCatalogueEventuallyHoldsThatVideo(t, ctx)
w.cohort = append(w.cohort, w.video.ID)
}
}
func searchTheCatalogueForThatTitleAPageAtATime(t gobdd.StepTest, ctx gobdd.Context, size int) {
w := worldOf(t, ctx)
w.pageSeen = nil
searchTheCatalogue(t, w, searchRequest{Title: w.searchTitle, Limit: size})
recordPage(w)
}
// followTheCursor asks for the page after the one just returned, with the same
// term and page size — a cursor says where to carry on from, not what to look
// for.
func followTheCursor(t gobdd.StepTest, ctx gobdd.Context) {
w := worldOf(t, ctx)
if w.results.NextCursor == "" {
t.Fatalf("the last page offered no cursor, so there is no next page to follow")
return
}
searchTheCatalogue(t, w, searchRequest{
Title: w.searchTitle,
Limit: len(w.results.Videos),
Cursor: w.results.NextCursor,
})
recordPage(w)
}
// recordPage remembers what a page held, so a later step can check the pages
// tile the results.
func recordPage(w *world) {
w.pageSeen = append(w.pageSeen, w.results.ids()...)
}
func theSearchReturnsNVideos(t gobdd.StepTest, ctx gobdd.Context, count int) {
w := worldOf(t, ctx)
if w.last.status != 200 {
t.Fatalf("the search did not succeed: %s", w.last.summary())
return
}
if len(w.results.Videos) != count {
t.Errorf("expected %d videos in the page, got %d: %v", count, len(w.results.Videos), w.results.ids())
}
}
func theSearchOffersACursor(t gobdd.StepTest, ctx gobdd.Context) {
w := worldOf(t, ctx)
if strings.TrimSpace(w.results.NextCursor) == "" {
t.Errorf("the page offered no cursor, so there is no way to ask for the next one")
}
}
func theSearchOffersNoFurtherCursor(t gobdd.StepTest, ctx gobdd.Context) {
w := worldOf(t, ctx)
if w.results.NextCursor != "" {
t.Errorf("the last page still offered cursor %q, so a reader cannot tell they have reached the end",
w.results.NextCursor)
}
}
// thePagesTileTheCohort checks the pages between them held each of the
// scenario's videos exactly once — the property a cursor exists to give, and
// the one an offset loses as soon as the catalogue is written to.
func thePagesTileTheCohort(t gobdd.StepTest, ctx gobdd.Context) {
w := worldOf(t, ctx)
seen := map[string]int{}
for _, id := range w.pageSeen {
seen[id]++
}
for _, id := range w.cohort {
switch seen[id] {
case 1:
case 0:
t.Errorf("video %q was catalogued but appeared in none of the pages; the pages held %v",
id, w.pageSeen)
default:
t.Errorf("video %q appeared in %d pages; a video should be on exactly one", id, seen[id])
}
}
if len(w.pageSeen) != len(w.cohort) {
t.Errorf("the pages held %d videos between them, but %d were catalogued: %v",
len(w.pageSeen), len(w.cohort), w.pageSeen)
}
}
func searchTheCatalogueFromCursor(t gobdd.StepTest, ctx gobdd.Context, cursor string) {
w := worldOf(t, ctx)
searchTheCatalogue(t, w, searchRequest{
Title: w.searchTitle,
Limit: searchLimit,
Cursor: cursor,
})
}
// searchTheWholeCatalogue searches with no term and no categories, which is
// how a reader browses rather than searches.
func searchTheWholeCatalogue(t gobdd.StepTest, ctx gobdd.Context, size int) {
searchTheCatalogue(t, worldOf(t, ctx), searchRequest{Limit: size})
}
func theSearchReturnsAtMostNVideos(t gobdd.StepTest, ctx gobdd.Context, most int) {
w := worldOf(t, ctx)
if w.last.status != 200 {
t.Fatalf("the search did not succeed: %s", w.last.summary())
return
}
if len(w.results.Videos) > most {
t.Errorf("the search returned %d videos, more than the %d it should cap at",
len(w.results.Videos), most)
}
}
+36
View File
@@ -48,6 +48,24 @@ type world struct {
// catalogued is the read side's own copy of the video, once a step has // catalogued is the read side's own copy of the video, once a step has
// waited for it to arrive. // waited for it to arrive.
catalogued *catalogueVideoBody catalogued *catalogueVideoBody
// searchTitle is the exact title the scenario's video was catalogued
// under. Scenarios name a plain title like "Desert Falcons"; the step
// registering it appends a nonce, because nothing cleans up between runs
// and a title reused across runs would make a result count meaningless.
searchTitle string
// results is the last page the catalogue search answered with.
results searchResultsBody
// pageSeen accumulates the ids from every page a paging scenario has
// walked, so it can assert the pages tile the results rather than
// repeating or dropping one.
pageSeen []string
// cohort is the ids of the videos a scenario catalogued together under one
// shared title, in the order they were created.
cohort []string
} }
type worldKey struct{} type worldKey struct{}
@@ -186,6 +204,24 @@ func TestVideoUpload(t *testing.T) {
suite.AddStep(`^the catalogue does not hold that video$`, theCatalogueDoesNotHoldThatVideo) suite.AddStep(`^the catalogue does not hold that video$`, theCatalogueDoesNotHoldThatVideo)
suite.AddStep(`^the catalogue eventually holds that video with no playback URL$`, theCatalogueEventuallyHoldsThatVideoWithNoPlaybackURL) suite.AddStep(`^the catalogue eventually holds that video with no playback URL$`, theCatalogueEventuallyHoldsThatVideoWithNoPlaybackURL)
suite.AddStep(`^the catalogue's copy carries its title, playback URL and categories$`, theCataloguesCopyCarriesTheDetails) suite.AddStep(`^the catalogue's copy carries its title, playback URL and categories$`, theCataloguesCopyCarriesTheDetails)
suite.AddStep(`^the catalogue holds a video titled "([^"]*)"$`, theCatalogueHoldsAVideoTitled)
suite.AddStep(`^I search the catalogue for that video's title$`, searchTheCatalogueForThatTitle)
suite.AddStep(`^I search the catalogue for "([^"]*)"$`, searchTheCatalogueFor)
suite.AddStep(`^the catalogue holds a video titled "([^"]*)" filed under "([^"]*)"$`, theCatalogueHoldsAVideoTitledUnder)
suite.AddStep(`^I search the catalogue for that video's title in category "([^"]*)"$`, searchTheCatalogueForThatTitleInCategory)
suite.AddStep(`^I search the catalogue for that video's title in categories "([^"]*)"$`, searchTheCatalogueForThatTitleInCategories)
suite.AddStep(`^the catalogue holds (\d+) videos titled "([^"]*)"$`, theCatalogueHoldsVideosTitled)
suite.AddStep(`^I search the catalogue for that title (\d+) at a time$`, searchTheCatalogueForThatTitleAPageAtATime)
suite.AddStep(`^I search the whole catalogue (\d+) at a time$`, searchTheWholeCatalogue)
suite.AddStep(`^I follow the cursor$`, followTheCursor)
suite.AddStep(`^I search the catalogue for that video's title from the cursor "([^"]*)"$`, searchTheCatalogueFromCursor)
suite.AddStep(`^the search returns (\d+) videos?$`, theSearchReturnsNVideos)
suite.AddStep(`^the search returns at most (\d+) videos$`, theSearchReturnsAtMostNVideos)
suite.AddStep(`^the search offers a cursor to the next page$`, theSearchOffersACursor)
suite.AddStep(`^the search offers no further cursor$`, theSearchOffersNoFurtherCursor)
suite.AddStep(`^the pages together hold every one of those videos exactly once$`, thePagesTileTheCohort)
suite.AddStep(`^the search does not return that video$`, theSearchDoesNotReturnThatVideo)
suite.AddStep(`^the search returns that video$`, theSearchReturnsThatVideo)
suite.AddStep(`^I register the uploaded video titled "(.*)" under categories "(.*)"$`, registerUploadedVideo) suite.AddStep(`^I register the uploaded video titled "(.*)" under categories "(.*)"$`, registerUploadedVideo)
suite.AddStep(`^I register the uploaded video titled "(.*)" under category id (\d+)$`, registerUnderCategoryID) suite.AddStep(`^I register the uploaded video titled "(.*)" under category id (\d+)$`, registerUnderCategoryID)
suite.AddStep(`^I register a video titled "(.*)" with the key "(.*)" under categories "(.*)"$`, registerWithKey) suite.AddStep(`^I register a video titled "(.*)" with the key "(.*)" under categories "(.*)"$`, registerWithKey)