FEAT: Swicthed QUERY Method To GET
Build, Push and Deploy Discovery / build-push-deploy (push) Successful in 5m2s

This commit is contained in:
FahdShalhoub
2026-08-30 10:56:51 +03:00
parent 35d4d6756e
commit db50e015bf
11 changed files with 760 additions and 94 deletions
+82 -1
View File
@@ -15,6 +15,71 @@ const docTemplate = `{
"host": "{{.Host}}",
"basePath": "{{.BasePath}}",
"paths": {
"/api/videos": {
"get": {
"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.",
"produces": [
"application/json"
],
"tags": [
"videos"
],
"summary": "Search the catalogue",
"parameters": [
{
"type": "string",
"example": "desert fal",
"description": "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.",
"name": "title",
"in": "query"
},
{
"type": "array",
"items": {
"type": "string"
},
"collectionFormat": "multi",
"example": "documentary",
"description": "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.",
"name": "categories",
"in": "query"
},
{
"type": "integer",
"default": 20,
"description": "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.",
"name": "limit",
"in": "query"
},
{
"type": "string",
"description": "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.",
"name": "cursor",
"in": "query"
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/api.SearchResults"
}
},
"400": {
"description": "Bad Request",
"schema": {
"$ref": "#/definitions/api.ProblemDetails"
}
},
"500": {
"description": "Internal Server Error",
"schema": {
"$ref": "#/definitions/api.ProblemDetails"
}
}
}
}
},
"/api/videos/{id}": {
"get": {
"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.",
@@ -108,6 +173,22 @@ const docTemplate = `{
}
}
},
"api.SearchResults": {
"type": "object",
"properties": {
"nextCursor": {
"description": "NextCursor reaches the page after this one. Empty on the last page,\nwhich is how a reader knows there is no more to ask for.",
"type": "string"
},
"videos": {
"description": "Videos are the matches, most relevant first. Never null: a search that\nmatches nothing is an empty list.",
"type": "array",
"items": {
"$ref": "#/definitions/api.Video"
}
}
}
},
"api.Video": {
"type": "object",
"properties": {
@@ -145,7 +226,7 @@ var SwaggerInfo = &swag.Spec{
BasePath: "/",
Schemes: []string{},
Title: "Thamanyah Discovery API",
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.",
Description: "JSON API for browsing the Thamanyah catalogue. Read-side counterpart to the CMS, which is what ingests videos.\n\nThe catalogue search is `GET /api/videos`, listed below: 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.",
InfoInstanceName: "swagger",
SwaggerTemplate: docTemplate,
LeftDelim: "{{",
+82 -1
View File
@@ -1,13 +1,78 @@
{
"swagger": "2.0",
"info": {
"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.",
"description": "JSON API for browsing the Thamanyah catalogue. Read-side counterpart to the CMS, which is what ingests videos.\n\nThe catalogue search is `GET /api/videos`, listed below: 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.",
"title": "Thamanyah Discovery API",
"contact": {},
"version": "1.0"
},
"basePath": "/",
"paths": {
"/api/videos": {
"get": {
"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.",
"produces": [
"application/json"
],
"tags": [
"videos"
],
"summary": "Search the catalogue",
"parameters": [
{
"type": "string",
"example": "desert fal",
"description": "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.",
"name": "title",
"in": "query"
},
{
"type": "array",
"items": {
"type": "string"
},
"collectionFormat": "multi",
"example": "documentary",
"description": "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.",
"name": "categories",
"in": "query"
},
{
"type": "integer",
"default": 20,
"description": "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.",
"name": "limit",
"in": "query"
},
{
"type": "string",
"description": "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.",
"name": "cursor",
"in": "query"
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/api.SearchResults"
}
},
"400": {
"description": "Bad Request",
"schema": {
"$ref": "#/definitions/api.ProblemDetails"
}
},
"500": {
"description": "Internal Server Error",
"schema": {
"$ref": "#/definitions/api.ProblemDetails"
}
}
}
}
},
"/api/videos/{id}": {
"get": {
"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.",
@@ -101,6 +166,22 @@
}
}
},
"api.SearchResults": {
"type": "object",
"properties": {
"nextCursor": {
"description": "NextCursor reaches the page after this one. Empty on the last page,\nwhich is how a reader knows there is no more to ask for.",
"type": "string"
},
"videos": {
"description": "Videos are the matches, most relevant first. Never null: a search that\nmatches nothing is an empty list.",
"type": "array",
"items": {
"$ref": "#/definitions/api.Video"
}
}
}
},
"api.Video": {
"type": "object",
"properties": {
+71 -1
View File
@@ -22,6 +22,21 @@ definitions:
example: about:blank
type: string
type: object
api.SearchResults:
properties:
nextCursor:
description: |-
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.
type: string
videos:
description: |-
Videos are the matches, most relevant first. Never null: a search that
matches nothing is an empty list.
items:
$ref: '#/definitions/api.Video'
type: array
type: object
api.Video:
properties:
categories:
@@ -46,10 +61,65 @@ info:
description: |-
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.
The catalogue search is `GET /api/videos`, listed below: 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.
title: Thamanyah Discovery API
version: "1.0"
paths:
/api/videos:
get:
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.
parameters:
- description: '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
in: query
name: title
type: string
- collectionFormat: multi
description: 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.
example: documentary
in: query
items:
type: string
name: categories
type: array
- default: 20
description: 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.
in: query
name: limit
type: integer
- description: 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.
in: query
name: cursor
type: string
produces:
- application/json
responses:
"200":
description: OK
schema:
$ref: '#/definitions/api.SearchResults'
"400":
description: Bad Request
schema:
$ref: '#/definitions/api.ProblemDetails'
"500":
description: Internal Server Error
schema:
$ref: '#/definitions/api.ProblemDetails'
summary: Search the catalogue
tags:
- videos
/api/videos/{id}:
get:
description: 'Returns the catalogue''s copy of a video: what a reader needs
+4 -20
View File
@@ -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 {
+92 -34
View File
@@ -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
+6 -4
View File
@@ -26,7 +26,7 @@ import (
// @version 1.0
// @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.
// @description The catalogue search is `GET /api/videos`, listed below: 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.
// @BasePath /
func main() {
@@ -117,9 +117,11 @@ func runServer() {
mux.HandleFunc("GET /health", handlers.Health)
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)
// The catalogue search. GET rather than POST because a search is safe and
// idempotent, and its parameters — title, categories, limit, cursor — fit
// a query string. ServeMux keeps this apart from the {id} pattern above:
// the more specific one wins.
mux.HandleFunc("GET /api/videos", handlers.SearchVideos)
// 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.