FEAT: Swicthed QUERY Method To GET
Build, Push and Deploy Discovery / build-push-deploy (push) Successful in 5m2s
Build, Push and Deploy Discovery / build-push-deploy (push) Successful in 5m2s
This commit is contained in:
@@ -12,26 +12,10 @@ type Video struct {
|
||||
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"`
|
||||
}
|
||||
// The search itself takes no body type: GET /api/videos reads its parameters
|
||||
// — title, categories, limit and cursor — from the query string, so there is
|
||||
// no request document to publish a schema for. They are documented as
|
||||
// parameters on handlers.SearchVideos, and appear in the generated spec there.
|
||||
|
||||
// SearchResults is one page of search results.
|
||||
type SearchResults struct {
|
||||
|
||||
@@ -4,9 +4,12 @@ import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"slices"
|
||||
"strconv"
|
||||
"strings"
|
||||
"thamanyah/discovery/internal/api"
|
||||
"thamanyah/discovery/internal/db/repositories"
|
||||
@@ -111,9 +114,9 @@ func searchCacheKey(title string, categories []string, limit int, cursor string)
|
||||
// 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 "categories": [] and one omitting
|
||||
// the member entirely — the same search, since both mean "every category"
|
||||
// — land on one entry rather than on `null` and `[]`.
|
||||
// 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)
|
||||
@@ -175,55 +178,110 @@ func cacheSearch(ctx context.Context, key string, results api.SearchResults) {
|
||||
}
|
||||
}
|
||||
|
||||
// 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 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.
|
||||
// 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) {
|
||||
var request api.SearchRequest
|
||||
|
||||
if err := json.NewDecoder(r.Body).Decode(&request); err != nil {
|
||||
request, err := parseSearchQuery(r.URL.Query())
|
||||
if 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.")
|
||||
"The search parameters could not be read: "+err.Error()+". Every parameter is optional; a request with none of them browses the whole catalogue.")
|
||||
return
|
||||
}
|
||||
|
||||
limit := request.Limit
|
||||
if limit <= 0 {
|
||||
limit = searchPageSize
|
||||
}
|
||||
if limit > maxSearchPageSize {
|
||||
limit = maxSearchPageSize
|
||||
}
|
||||
|
||||
// Read through the cache before touching Postgres. A ranked search is the
|
||||
// expensive request this service serves — ts_rank is computed per matching
|
||||
// row, so deep pages scan rather than seek — and readers ask for the same
|
||||
// few things over and over, which is exactly the shape a cache pays for.
|
||||
//
|
||||
// Only successful pages are ever stored, so a malformed cursor still
|
||||
// reaches the repository and is still rejected below.
|
||||
cacheKey := searchCacheKey(request.Title, request.Categories, limit, request.Cursor)
|
||||
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, limit, request.Cursor)
|
||||
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)
|
||||
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
|
||||
|
||||
Reference in New Issue
Block a user