package handlers import ( "context" "encoding/json" "errors" "fmt" "log" "net/http" "net/url" "slices" "strconv" "strings" "thamanyah/discovery/internal/api" "thamanyah/discovery/internal/db/repositories" "thamanyah/discovery/internal/services" "time" ) // GetVideo serves the catalogue's copy of one video. // // @Summary Get a catalogued video // @Description Returns the catalogue's copy of a video: what a reader needs to show and play it. A video appears here only after the CMS has announced it as ready, so a video that is still transcoding — or one the CMS never made ready — is a 404. // @Tags videos // @Produce json // @Param id path string true "Video id, as issued by the CMS" // @Success 200 {object} api.Video // @Failure 404 {object} api.ProblemDetails // @Failure 500 {object} api.ProblemDetails // @Router /api/videos/{id} [get] func GetVideo(w http.ResponseWriter, r *http.Request) { id := r.PathValue("id") video, err := repositories.VideoRepo.GetVideoByID(r.Context(), id) if errors.Is(err, repositories.ErrVideoNotFound) { writeProblem(w, http.StatusNotFound, "Video Not In The Catalogue", "No video with that id is in the catalogue. A video appears here once the CMS announces it as ready, so one that is still being transcoded is not here yet.") return } if err != nil { log.Printf("Something Went Wrong Loading A Catalogued Video: id=%q: %s", id, err) writeProblem(w, http.StatusInternalServerError, "Something Went Wrong Loading The Catalogue", "The video could not be read from the database. This is a server-side fault and the request was not processed; retrying in a few moments may succeed.") return } // Translated field by field rather than returned as-is: models.Video is the // row, api.Video is the published schema, and neither should drag the other // along when it changes. writeJSON(w, http.StatusOK, api.Video{ ID: video.ID, Title: video.Title, PlaybackURL: video.PlaybackURL, // Never null on the wire: a video with no categories has an empty // list, which is a different thing from "unknown". 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 // searchCacheTTL is how long a page of results stays servable from the // cache before it has to be built again. // // Nothing invalidates an entry when a video is announced: the consumer // writes rows continuously and would have to know which of the cached // pages a new title belongs on, which for a ranked search is every page it // outranks. Expiry is the cheaper answer, and it bounds the staleness — a // video is findable within a minute of being catalogued, which is the same // order as the announcement's own delivery lag. searchCacheTTL = time.Minute ) // searchCacheKey is what one page of search results is stored under: the title // searched for, the categories it was narrowed to, and the page within that // result set. // // The page is named by the cursor, because with keyset paging that is what a // page *is* — there is no page number to key on, and the cursor identifies the // same rows whenever it is presented. The empty cursor is the first page. // // The limit is in the key too, even though a reader would not call it part of // "which page". It has to be: the same title, categories and cursor with a // different limit is a different set of rows, and sharing an entry between // them would hand a client asking for 50 a page of 20. // // The parts are JSON-encoded rather than pasted together with separators so // that no category name can be mistaken for the boundary between two of them — // {"c":["a,b"]} and {"c":["a","b"]} stay distinguishable, which "a,b" and // "a,b" would not. The key stays readable in redis-cli either way. func searchCacheKey(title string, categories []string, limit int, cursor string) string { // The title is lowercased because the search itself is: tsQueryFor folds // case before it builds the tsquery, so two spellings that differ only in // case are the same search and should be the same entry. The categories // are not — they are compared to the stored array verbatim, so "News" and // "news" really do ask different questions. // // Sorting the categories is safe for the same reason the SQL uses &&: // they mean "any of these", so their order never changed the answer. Two // readers naming the same categories in a different order now share one // entry instead of building the same page twice. // // Never nil, so that a client sending no categories at all and one whose // categories parsed away to none — the same search, since both mean "every // category" — land on one entry rather than on `null` and `[]`. sorted := append([]string{}, categories...) slices.Sort(sorted) sorted = slices.Compact(sorted) parts := struct { Title string `json:"t"` Categories []string `json:"c"` Limit int `json:"l"` Cursor string `json:"p"` }{ Title: strings.ToLower(strings.TrimSpace(title)), Categories: sorted, Limit: limit, Cursor: cursor, } // Cannot fail: every field is a string, a string slice or an int. encoded, _ := json.Marshal(parts) return "discovery:search:v1:" + string(encoded) } // cachedSearch returns the page held under key, or false when there is none to // serve. // // Every failure reads as "no cached page": a cache that is unreachable, slow // or holding something unreadable must cost a reader nothing more than the // database query they would have paid for anyway. Only faults worth acting on // are logged — a miss is not one. func cachedSearch(ctx context.Context, key string) (api.SearchResults, bool) { encoded, err := services.CacheClient.Get(ctx, key) if errors.Is(err, services.ErrCacheMiss) { return api.SearchResults{}, false } if err != nil { log.Printf("Could Not Read The Search Cache, Falling Back To The Database: key=%q: %s", key, err) return api.SearchResults{}, false } var results api.SearchResults if err := json.Unmarshal(encoded, &results); err != nil { // Only reachable if something else wrote this key, or the shape // changed without the v1 in the prefix changing with it. log.Printf("Could Not Read A Cached Search Page, Falling Back To The Database: key=%q: %s", key, err) return api.SearchResults{}, false } return results, true } func cacheSearch(ctx context.Context, key string, results api.SearchResults) { encoded, err := json.Marshal(results) if err != nil { log.Printf("Could Not Encode A Search Page For The Cache: key=%q: %s", key, err) return } if err := services.CacheClient.Set(ctx, key, encoded, searchCacheTTL); err != nil { log.Printf("Could Not Write The Search Cache: key=%q: %s", key, err) } } // searchQuery is a search as the query string spells it out. Every parameter // is optional: a request carrying none of them browses the whole catalogue, // newest first. type searchQuery struct { title string categories []string limit int cursor string } // parseSearchQuery reads the search out of the URL, applying the default and // the cap to the page size so that the rest of the handler works with a limit // it can use as given. // // The only parameter that can be malformed is the limit: every other one is a // string the search takes as written, so there is nothing to spell wrong. func parseSearchQuery(values url.Values) (searchQuery, error) { parsed := searchQuery{ title: values.Get("title"), // Repeated — ?categories=news&categories=documentary — rather than one // comma-separated value, for the reason searchCacheKey encodes them as // JSON rather than joining them: a category name is a name, and one // containing a comma must not read as two. // // Empty values are dropped so that a client sending ?categories= means // "every category", as omitting it does, rather than narrowing the // search to a category named "". categories: nonEmpty(values["categories"]), cursor: values.Get("cursor"), limit: searchPageSize, } if raw := values.Get("limit"); raw != "" { limit, err := strconv.Atoi(raw) if err != nil { return searchQuery{}, fmt.Errorf("limit %q is not a whole number", raw) } // Zero and negative are read as "no preference", the same as omitting // the parameter. A page of nothing is not what anyone meant by it, and // the request is still answerable, so answering it beats refusing it. if limit > 0 { parsed.limit = limit } } if parsed.limit > maxSearchPageSize { parsed.limit = maxSearchPageSize } return parsed, nil } func nonEmpty(values []string) []string { kept := make([]string, 0, len(values)) for _, v := range values { if v != "" { kept = append(kept, v) } } return kept } // SearchVideos serves the catalogue's lexical search. // // It is served on GET: a search is safe and idempotent, which POST is not, and // its parameters are small enough to say in a query string — which keeps the // endpoint reachable from anything that speaks HTTP, describable in the // OpenAPI spec, and cacheable by the intermediaries between the reader and // this service. // // @Summary Search the catalogue // @Description Finds catalogued videos by the words of their title and the categories they are filed under, most relevant first. Every parameter is optional — a request with none of them browses the whole catalogue, newest first. Paging is by cursor rather than by offset, so a page stays the same page while videos are being announced into the catalogue around it. // @Tags videos // @Produce json // @Param title query string false "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." example(desert fal) // @Param categories query []string false "Narrows the search to videos filed under any one of these names — not all of them. Repeat the parameter per category. Omitted means every category." collectionFormat(multi) example(documentary) // @Param limit query int false "How many videos to return. Absent, zero or negative 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." default(20) // @Param cursor query string false "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." // @Success 200 {object} api.SearchResults // @Failure 400 {object} api.ProblemDetails // @Failure 500 {object} api.ProblemDetails // @Router /api/videos [get] func SearchVideos(w http.ResponseWriter, r *http.Request) { request, err := parseSearchQuery(r.URL.Query()) if err != nil { writeProblem(w, http.StatusBadRequest, "Malformed Search", "The search parameters could not be read: "+err.Error()+". Every parameter is optional; a request with none of them browses the whole catalogue.") return } cacheKey := searchCacheKey(request.title, request.categories, request.limit, request.cursor) if cached, hit := cachedSearch(r.Context(), cacheKey); hit { writeJSON(w, http.StatusOK, cached) return } found, next, err := repositories.VideoRepo.SearchVideos(r.Context(), request.title, request.categories, request.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...), }) } results := api.SearchResults{Videos: videos, NextCursor: next} cacheSearch(r.Context(), cacheKey, results) writeJSON(w, http.StatusOK, results) }