package handlers import ( "encoding/json" "errors" "log" "net/http" "thamanyah/discovery/internal/api" "thamanyah/discovery/internal/db/repositories" ) // 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 ) // 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}) }