307 lines
13 KiB
Go
307 lines
13 KiB
Go
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)
|
|
}
|