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:
+82
-1
@@ -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: "{{",
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
+6
-4
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user