FEAT: Discovery Video Service Subscription
This commit is contained in:
@@ -15,6 +15,47 @@ const docTemplate = `{
|
||||
"host": "{{.Host}}",
|
||||
"basePath": "{{.BasePath}}",
|
||||
"paths": {
|
||||
"/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.",
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
"tags": [
|
||||
"videos"
|
||||
],
|
||||
"summary": "Get a catalogued video",
|
||||
"parameters": [
|
||||
{
|
||||
"type": "string",
|
||||
"description": "Video id, as issued by the CMS",
|
||||
"name": "id",
|
||||
"in": "path",
|
||||
"required": true
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/api.Video"
|
||||
}
|
||||
},
|
||||
"404": {
|
||||
"description": "Not Found",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/api.ProblemDetails"
|
||||
}
|
||||
},
|
||||
"500": {
|
||||
"description": "Internal Server Error",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/api.ProblemDetails"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/health": {
|
||||
"get": {
|
||||
"description": "Reports that the service is up and serving. Used as the ALB target group health check; it does not verify the database connection.",
|
||||
@@ -45,6 +86,54 @@ const docTemplate = `{
|
||||
"example": "ok"
|
||||
}
|
||||
}
|
||||
},
|
||||
"api.ProblemDetails": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"detail": {
|
||||
"type": "string",
|
||||
"example": "The catalogue 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."
|
||||
},
|
||||
"status": {
|
||||
"type": "integer",
|
||||
"example": 500
|
||||
},
|
||||
"title": {
|
||||
"type": "string",
|
||||
"example": "Something Went Wrong Loading The Catalogue"
|
||||
},
|
||||
"type": {
|
||||
"type": "string",
|
||||
"example": "about:blank"
|
||||
}
|
||||
}
|
||||
},
|
||||
"api.Video": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"categories": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"example": [
|
||||
"documentary",
|
||||
"news"
|
||||
]
|
||||
},
|
||||
"id": {
|
||||
"type": "string",
|
||||
"example": "0f8fad5b-d9cb-469f-a165-70867728950e"
|
||||
},
|
||||
"playbackUrl": {
|
||||
"type": "string",
|
||||
"example": "https://d111111abcdef8.cloudfront.net/videos/abc123/index.m3u8"
|
||||
},
|
||||
"title": {
|
||||
"type": "string",
|
||||
"example": "The Evening Bulletin"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}`
|
||||
|
||||
@@ -8,6 +8,47 @@
|
||||
},
|
||||
"basePath": "/",
|
||||
"paths": {
|
||||
"/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.",
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
"tags": [
|
||||
"videos"
|
||||
],
|
||||
"summary": "Get a catalogued video",
|
||||
"parameters": [
|
||||
{
|
||||
"type": "string",
|
||||
"description": "Video id, as issued by the CMS",
|
||||
"name": "id",
|
||||
"in": "path",
|
||||
"required": true
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/api.Video"
|
||||
}
|
||||
},
|
||||
"404": {
|
||||
"description": "Not Found",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/api.ProblemDetails"
|
||||
}
|
||||
},
|
||||
"500": {
|
||||
"description": "Internal Server Error",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/api.ProblemDetails"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/health": {
|
||||
"get": {
|
||||
"description": "Reports that the service is up and serving. Used as the ALB target group health check; it does not verify the database connection.",
|
||||
@@ -38,6 +79,54 @@
|
||||
"example": "ok"
|
||||
}
|
||||
}
|
||||
},
|
||||
"api.ProblemDetails": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"detail": {
|
||||
"type": "string",
|
||||
"example": "The catalogue 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."
|
||||
},
|
||||
"status": {
|
||||
"type": "integer",
|
||||
"example": 500
|
||||
},
|
||||
"title": {
|
||||
"type": "string",
|
||||
"example": "Something Went Wrong Loading The Catalogue"
|
||||
},
|
||||
"type": {
|
||||
"type": "string",
|
||||
"example": "about:blank"
|
||||
}
|
||||
}
|
||||
},
|
||||
"api.Video": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"categories": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"example": [
|
||||
"documentary",
|
||||
"news"
|
||||
]
|
||||
},
|
||||
"id": {
|
||||
"type": "string",
|
||||
"example": "0f8fad5b-d9cb-469f-a165-70867728950e"
|
||||
},
|
||||
"playbackUrl": {
|
||||
"type": "string",
|
||||
"example": "https://d111111abcdef8.cloudfront.net/videos/abc123/index.m3u8"
|
||||
},
|
||||
"title": {
|
||||
"type": "string",
|
||||
"example": "The Evening Bulletin"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,41 @@ definitions:
|
||||
example: ok
|
||||
type: string
|
||||
type: object
|
||||
api.ProblemDetails:
|
||||
properties:
|
||||
detail:
|
||||
example: The catalogue 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.
|
||||
type: string
|
||||
status:
|
||||
example: 500
|
||||
type: integer
|
||||
title:
|
||||
example: Something Went Wrong Loading The Catalogue
|
||||
type: string
|
||||
type:
|
||||
example: about:blank
|
||||
type: string
|
||||
type: object
|
||||
api.Video:
|
||||
properties:
|
||||
categories:
|
||||
example:
|
||||
- documentary
|
||||
- news
|
||||
items:
|
||||
type: string
|
||||
type: array
|
||||
id:
|
||||
example: 0f8fad5b-d9cb-469f-a165-70867728950e
|
||||
type: string
|
||||
playbackUrl:
|
||||
example: https://d111111abcdef8.cloudfront.net/videos/abc123/index.m3u8
|
||||
type: string
|
||||
title:
|
||||
example: The Evening Bulletin
|
||||
type: string
|
||||
type: object
|
||||
info:
|
||||
contact: {}
|
||||
description: JSON API for browsing the Thamanyah catalogue. Read-side counterpart
|
||||
@@ -13,6 +48,36 @@ info:
|
||||
title: Thamanyah Discovery API
|
||||
version: "1.0"
|
||||
paths:
|
||||
/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.'
|
||||
parameters:
|
||||
- description: Video id, as issued by the CMS
|
||||
in: path
|
||||
name: id
|
||||
required: true
|
||||
type: string
|
||||
produces:
|
||||
- application/json
|
||||
responses:
|
||||
"200":
|
||||
description: OK
|
||||
schema:
|
||||
$ref: '#/definitions/api.Video'
|
||||
"404":
|
||||
description: Not Found
|
||||
schema:
|
||||
$ref: '#/definitions/api.ProblemDetails'
|
||||
"500":
|
||||
description: Internal Server Error
|
||||
schema:
|
||||
$ref: '#/definitions/api.ProblemDetails'
|
||||
summary: Get a catalogued video
|
||||
tags:
|
||||
- videos
|
||||
/health:
|
||||
get:
|
||||
description: Reports that the service is up and serving. Used as the ALB target
|
||||
|
||||
@@ -11,6 +11,21 @@ require (
|
||||
|
||||
require (
|
||||
github.com/KyleBanks/depth v1.2.1 // indirect
|
||||
github.com/aws/aws-sdk-go-v2 v1.45.1 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/config v1.33.1 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/credentials v1.20.1 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.19.1 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/internal/configsources v1.5.1 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.8.1 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/internal/v4a v1.5.1 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.19 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.14.1 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/signin v1.7.1 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/sqs v1.48.1 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/sso v1.35.1 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.40.1 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/sts v1.47.1 // indirect
|
||||
github.com/aws/smithy-go v1.28.1 // indirect
|
||||
github.com/go-openapi/jsonpointer v0.19.5 // indirect
|
||||
github.com/go-openapi/jsonreference v0.20.0 // indirect
|
||||
github.com/go-openapi/spec v0.20.6 // indirect
|
||||
|
||||
@@ -4,6 +4,36 @@ github.com/KyleBanks/depth v1.2.1 h1:5h8fQADFrWtarTdtDudMmGsC7GPbOAu6RVB3ffsVFHc
|
||||
github.com/KyleBanks/depth v1.2.1/go.mod h1:jzSb9d0L43HxTQfT+oSA1EEp2q+ne2uh6XgeJcm8brE=
|
||||
github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY=
|
||||
github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU=
|
||||
github.com/aws/aws-sdk-go-v2 v1.45.1 h1:iIoG3NaLhV6UZpPXyPXlDj2I9oS8tV/nMcMnITCC6Ks=
|
||||
github.com/aws/aws-sdk-go-v2 v1.45.1/go.mod h1:bttEH6JqnUL8LepvDVfdrds/fZ5bCIxzpe3abyUrhDU=
|
||||
github.com/aws/aws-sdk-go-v2/config v1.33.1 h1:bq9jze1hQ5YTCLoVxNnbp0T7rglrlOE7N9YsHqjGkEw=
|
||||
github.com/aws/aws-sdk-go-v2/config v1.33.1/go.mod h1:2A3HQwG4zaL5Tm80rc6RZj8LmWWv4WYT5v8raSz/L7A=
|
||||
github.com/aws/aws-sdk-go-v2/credentials v1.20.1 h1:Z8GRNEx0u9sDkZOq4PUnN8mjGwbUQGRzMSXpvt3d8xQ=
|
||||
github.com/aws/aws-sdk-go-v2/credentials v1.20.1/go.mod h1:uBIK00kFo95dnemqfFMTWx0X8YRqsh6ecIoCjjOkZqM=
|
||||
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.19.1 h1:YIEBqcqRnpi4Pfv0YHImtgi6czGCwKHANC7SwmUAVD0=
|
||||
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.19.1/go.mod h1:imEf0oufgAo8KAkCHhrOdqGEC0YWx1PPBQH82shSxGw=
|
||||
github.com/aws/aws-sdk-go-v2/internal/configsources v1.5.1 h1:pc138gM1CW+XPc60rEwUlwwuwWFQK16CI1T7v1F9Oec=
|
||||
github.com/aws/aws-sdk-go-v2/internal/configsources v1.5.1/go.mod h1:1+koxpPIbfBdfzP6vojm5/zTpTQ/micYwlxIiNB3TxI=
|
||||
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.8.1 h1:K0JsbZQj+1h208Ro1zHeA4l7bMp0NvRffHQ91q8Ol1s=
|
||||
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.8.1/go.mod h1:W3/vL6EtCIatICGy9ab29QhMuae+cOKPWcMxv02CO+Q=
|
||||
github.com/aws/aws-sdk-go-v2/internal/v4a v1.5.1 h1:yhw5KD1phVyP9vijxOUzDfEtJx+bt+L63k+VfuiYFAA=
|
||||
github.com/aws/aws-sdk-go-v2/internal/v4a v1.5.1/go.mod h1:ZW2e0d7DYlRxlS9hEiMXE47gTdX5KRN4byUiNbUpG+Q=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.19 h1:bAdDl/HkGCcGPoe25ToSHEw23VIxt6CT5fLcg111BKg=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.19/go.mod h1:KaUzbLxv4CeSxh6ZCl9B4m7CuFenS8kUEaDs+f/DQr4=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.14.1 h1:RmmWQPREQdk9U+PfqeHW3MqZaBaNK7TpV9W3RY+b+7g=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.14.1/go.mod h1:0A3W4F+68ZnNk5XcNL/e9HFMwnP8RlEicFfy6eOEDyw=
|
||||
github.com/aws/aws-sdk-go-v2/service/signin v1.7.1 h1:mdMtSVKdQ3+mzBh+l0ogrFYZVQUCg6pJZOirA2ARsYE=
|
||||
github.com/aws/aws-sdk-go-v2/service/signin v1.7.1/go.mod h1:9IqUlsJDbUPcg6cgx3WEzXdjrbWzLDQrak0aaSqlTcI=
|
||||
github.com/aws/aws-sdk-go-v2/service/sqs v1.48.1 h1:jXP3BdVenFa8RfLVH+D2gswrWZHJcgtygKCf22APFqo=
|
||||
github.com/aws/aws-sdk-go-v2/service/sqs v1.48.1/go.mod h1:d4DToDhLnEofHKvFu4yCF0Be65pZW267COfKOztsZOQ=
|
||||
github.com/aws/aws-sdk-go-v2/service/sso v1.35.1 h1:B6WFn91tobD6gG4724ONHaqrpKsoETGnv98LHe/yIGM=
|
||||
github.com/aws/aws-sdk-go-v2/service/sso v1.35.1/go.mod h1:tWuiVBUtPBr8/rgRiYS8Uf85sHcAN+G7XS3D3CEoUh8=
|
||||
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.40.1 h1:6yeYCWFvgbI2TI3K6jr9LtBNhXgJ7g4xqD+DEiaDDmM=
|
||||
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.40.1/go.mod h1:naFe83jSMuYkH+QjQPX8n1MLhBkeCFM5Lsnh5m5wz3c=
|
||||
github.com/aws/aws-sdk-go-v2/service/sts v1.47.1 h1:Sv2xPnRHlThSUtVujYuUBPI/Il8si6UPHXL8DMiB/F0=
|
||||
github.com/aws/aws-sdk-go-v2/service/sts v1.47.1/go.mod h1:mKo/CzaCz8qytGW70NG4vIIGAx1HXTlb5lHNkC5k3lk=
|
||||
github.com/aws/smithy-go v1.28.1 h1:R/nXH00c8qcfCzQVELtRw+eLQWtzv+VAIEFJ1/xxXlQ=
|
||||
github.com/aws/smithy-go v1.28.1/go.mod h1:YE2RhdIuDbA5E5bTdciG9KrW3+TiEONeUWCqxX9i1Fc=
|
||||
github.com/containerd/errdefs v1.0.0 h1:tg5yIfIlQIrxYtu9ajqY42W3lpS19XqdxRQeEwYG8PI=
|
||||
github.com/containerd/errdefs v1.0.0/go.mod h1:+YBYIdtsnF4Iw6nWZhJcqGSg/dwvV7tyJ/kCkyJ2k+M=
|
||||
github.com/containerd/errdefs/pkg v0.3.0 h1:9IKJ06FvyNlexW690DXuQNx2KA2cUJXx151Xdx3ZPPE=
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
package api
|
||||
|
||||
// Video is the catalogue's copy of a video, as GET /api/videos/{id} serves it.
|
||||
// It is deliberately not cms's video record of the same name: the catalogue
|
||||
// publishes what a reader needs to show and play a video, and none of the
|
||||
// ingestion detail — no storage key, no transcoding job id, no status, since
|
||||
// a video that is not ready never reaches the catalogue at all.
|
||||
type Video struct {
|
||||
ID string `json:"id" example:"0f8fad5b-d9cb-469f-a165-70867728950e"`
|
||||
Title string `json:"title" example:"The Evening Bulletin"`
|
||||
PlaybackURL string `json:"playbackUrl" example:"https://d111111abcdef8.cloudfront.net/videos/abc123/index.m3u8"`
|
||||
Categories []string `json:"categories" example:"documentary,news"`
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
// Package consumers holds the queue-driven half of the service: work that
|
||||
// arrives on a queue rather than as an HTTP request. It is to SQS what
|
||||
// internal/handlers is to HTTP — it decodes a message, calls repositories, and
|
||||
// decides what the message's fate is — so the same seams apply, and no SQL
|
||||
// lives here.
|
||||
package consumers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"log"
|
||||
"strings"
|
||||
"thamanyah/discovery/internal/db/repositories"
|
||||
"thamanyah/discovery/internal/models"
|
||||
"thamanyah/discovery/internal/services"
|
||||
"time"
|
||||
)
|
||||
|
||||
// receiveBackoff is how long the loop waits after a failed receive, so a queue
|
||||
// that is unreachable produces a slow trickle of log lines rather than a spin.
|
||||
const receiveBackoff = 5 * time.Second
|
||||
|
||||
// announcement is what cms publishes to the catalogue topic when a video
|
||||
// becomes ready. It is cms's published contract, mirrored here rather than
|
||||
// imported: the two services share no code, and this struct is exactly the
|
||||
// coupling between them — so a field renamed on one side has to be renamed
|
||||
// here too, deliberately.
|
||||
//
|
||||
// The subscription delivers raw, so this is the whole message body; there is
|
||||
// no SNS envelope to unwrap.
|
||||
type announcement struct {
|
||||
VideoID string `json:"videoId"`
|
||||
Title string `json:"title"`
|
||||
PlaybackURL string `json:"playbackUrl"`
|
||||
Categories []string `json:"categories"`
|
||||
}
|
||||
|
||||
// CatalogueEvents consumes the announcements cms publishes.
|
||||
type CatalogueEvents struct{}
|
||||
|
||||
// Run consumes announcements until ctx is cancelled. It is meant to be run in
|
||||
// its own goroutine for the lifetime of the process.
|
||||
func (c CatalogueEvents) Run(ctx context.Context) {
|
||||
log.Println("catalogue announcements consumer started")
|
||||
|
||||
for {
|
||||
if ctx.Err() != nil {
|
||||
log.Println("catalogue announcements consumer stopped")
|
||||
return
|
||||
}
|
||||
|
||||
messages, err := services.SQSClient.ReceiveMessages(ctx)
|
||||
if err != nil {
|
||||
if ctx.Err() != nil {
|
||||
log.Println("catalogue announcements consumer stopped")
|
||||
return
|
||||
}
|
||||
log.Printf("Something Went Wrong Receiving Announcements: %s", err)
|
||||
time.Sleep(receiveBackoff)
|
||||
continue
|
||||
}
|
||||
|
||||
for _, message := range messages {
|
||||
if !c.handleAnnouncement(ctx, message.Body) {
|
||||
// Left on the queue on purpose: it becomes visible again when
|
||||
// the visibility timeout expires, and reaches the dead-letter
|
||||
// queue if it keeps failing.
|
||||
continue
|
||||
}
|
||||
|
||||
if err := services.SQSClient.DeleteMessage(ctx, message.ReceiptHandle); err != nil {
|
||||
log.Printf("Something Went Wrong Acknowledging An Announcement: %s", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// handleAnnouncement processes one message and reports whether it is finished
|
||||
// with — that is, whether it should be deleted from the queue. It returns
|
||||
// false only when a retry could plausibly succeed. Anything a retry cannot fix
|
||||
// (a body that will never parse, an announcement naming no video) is finished
|
||||
// with, however little it accomplished: leaving it on the queue would only
|
||||
// stall the announcements behind it.
|
||||
func (c CatalogueEvents) handleAnnouncement(ctx context.Context, body string) bool {
|
||||
var announced announcement
|
||||
if err := json.Unmarshal([]byte(body), &announced); err != nil {
|
||||
log.Printf("Discarding An Announcement That Is Not JSON: %s", err)
|
||||
return true
|
||||
}
|
||||
|
||||
if strings.TrimSpace(announced.VideoID) == "" {
|
||||
log.Printf("Discarding An Announcement That Names No Video")
|
||||
return true
|
||||
}
|
||||
|
||||
if err := repositories.VideoRepo.SaveVideo(ctx, models.Video{
|
||||
ID: announced.VideoID,
|
||||
Title: announced.Title,
|
||||
PlaybackURL: announced.PlaybackURL,
|
||||
Categories: announced.Categories,
|
||||
}); err != nil {
|
||||
// Worth another attempt: the save is an upsert, so a redelivery
|
||||
// re-runs it harmlessly.
|
||||
log.Printf("Something Went Wrong Saving An Announced Video: video=%q: %s", announced.VideoID, err)
|
||||
return false
|
||||
}
|
||||
|
||||
log.Printf("Catalogued An Announced Video: video=%q title=%q", announced.VideoID, announced.Title)
|
||||
return true
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
DROP TABLE videos;
|
||||
@@ -0,0 +1,19 @@
|
||||
-- The catalogue's own copy of a video, built from the announcements cms
|
||||
-- publishes on the catalogue topic. It is a read model, not a second source of
|
||||
-- truth: every column here arrives in an announcement, and nothing in this
|
||||
-- service ever writes a video of its own.
|
||||
--
|
||||
-- id is the id cms issued, carried in the announcement and reused verbatim, so
|
||||
-- the two services can talk about the same video without a mapping table. It
|
||||
-- is therefore NOT generated here: no DEFAULT, unlike cms's videos.id.
|
||||
CREATE TABLE videos (
|
||||
id UUID PRIMARY KEY,
|
||||
title TEXT NOT NULL,
|
||||
playback_url TEXT NOT NULL DEFAULT '',
|
||||
-- Denormalized on purpose. cms owns the category vocabulary and sends
|
||||
-- names, so there is nothing here to join to and no id to keep in step;
|
||||
-- an array is what the read side actually serves.
|
||||
categories TEXT[] NOT NULL DEFAULT '{}',
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
@@ -0,0 +1,9 @@
|
||||
package repositories
|
||||
|
||||
import "errors"
|
||||
|
||||
// Sentinel errors the repositories return so handlers can tell "there is no
|
||||
// such row" from "the query failed", without importing database/sql to do it.
|
||||
|
||||
// ErrVideoNotFound reports that the catalogue holds no video with that id.
|
||||
var ErrVideoNotFound = errors.New("video not found")
|
||||
@@ -0,0 +1,59 @@
|
||||
package repositories
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"thamanyah/discovery/internal/models"
|
||||
|
||||
"github.com/lib/pq"
|
||||
)
|
||||
|
||||
type VideoRepository struct {
|
||||
SQLDB *sql.DB
|
||||
}
|
||||
|
||||
var VideoRepo VideoRepository
|
||||
|
||||
// SaveVideo records an announced video, replacing what the catalogue already
|
||||
// held for that id.
|
||||
//
|
||||
// The upsert is what makes the consumer safe to retry: the catalogue topic
|
||||
// delivers at-least-once, so the same announcement can arrive twice, and a
|
||||
// plain INSERT would fail the second time on the primary key. Re-announcing a
|
||||
// video is also how it is corrected — a later announcement wins, which is why
|
||||
// every column is overwritten rather than merged.
|
||||
func (svc VideoRepository) SaveVideo(ctx context.Context, v models.Video) error {
|
||||
_, err := svc.SQLDB.ExecContext(ctx, `
|
||||
INSERT INTO videos (id, title, playback_url, categories)
|
||||
VALUES ($1, $2, $3, $4)
|
||||
ON CONFLICT (id) DO UPDATE
|
||||
SET title = EXCLUDED.title,
|
||||
playback_url = EXCLUDED.playback_url,
|
||||
categories = EXCLUDED.categories,
|
||||
updated_at = now()
|
||||
`, v.ID, v.Title, v.PlaybackURL, pq.Array(v.Categories))
|
||||
return err
|
||||
}
|
||||
|
||||
// GetVideoByID reads the catalogue's copy of one video. It returns
|
||||
// ErrVideoNotFound when the catalogue has not heard about it — which, for a
|
||||
// video cms has only just made ready, is a matter of timing rather than a
|
||||
// mistake.
|
||||
func (svc VideoRepository) GetVideoByID(ctx context.Context, id string) (models.Video, error) {
|
||||
var v models.Video
|
||||
|
||||
err := svc.SQLDB.QueryRowContext(ctx, `
|
||||
SELECT id, title, playback_url, categories, created_at, updated_at
|
||||
FROM videos
|
||||
WHERE id = $1
|
||||
`, id).Scan(&v.ID, &v.Title, &v.PlaybackURL, pq.Array(&v.Categories), &v.CreatedAt, &v.UpdatedAt)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return models.Video{}, ErrVideoNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return models.Video{}, err
|
||||
}
|
||||
|
||||
return v, nil
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"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...),
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
package models
|
||||
|
||||
import "time"
|
||||
|
||||
// Video is the catalogue's copy of a video cms has announced as ready. Every
|
||||
// member is filled from the announcement — discovery originates none of it.
|
||||
type Video struct {
|
||||
// ID is the id cms issued, reused verbatim so both services name the same
|
||||
// video the same way.
|
||||
ID string
|
||||
Title string
|
||||
PlaybackURL string
|
||||
// Categories are names, not ids: cms owns the vocabulary and announces it
|
||||
// by name, so nothing here has to track its numbering.
|
||||
Categories []string
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
// Package services holds this service's AWS clients. Discovery reaches exactly
|
||||
// one AWS API — the SQS queue subscribed to the catalogue topic — so unlike
|
||||
// cms there is no S3 or MediaConvert here, and nothing database-related.
|
||||
package services
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"github.com/aws/aws-sdk-go-v2/service/sqs"
|
||||
)
|
||||
|
||||
var SQSClient SQS
|
||||
|
||||
// QueueMessage is one message taken off the queue. ReceiptHandle is what
|
||||
// identifies it for deletion — it belongs to this delivery, not to the
|
||||
// message, so it cannot be held across receives.
|
||||
type QueueMessage struct {
|
||||
Body string
|
||||
ReceiptHandle string
|
||||
}
|
||||
|
||||
type SQS interface {
|
||||
ReceiveMessages(ctx context.Context) ([]QueueMessage, error)
|
||||
DeleteMessage(ctx context.Context, receiptHandle string) error
|
||||
}
|
||||
|
||||
type SQSConcrete struct {
|
||||
SQSClient *sqs.Client
|
||||
QueueURL string
|
||||
}
|
||||
|
||||
// receiveWaitSeconds turns every receive into a long poll: the call parks on
|
||||
// the server until a message arrives or this elapses, so an event is picked up
|
||||
// within milliseconds of being published while an idle consumer costs one
|
||||
// request every twenty seconds rather than spinning. 20 is the AWS maximum.
|
||||
const receiveWaitSeconds = 20
|
||||
|
||||
func (svc SQSConcrete) ReceiveMessages(ctx context.Context) ([]QueueMessage, error) {
|
||||
output, err := svc.SQSClient.ReceiveMessage(ctx, &sqs.ReceiveMessageInput{
|
||||
QueueUrl: &svc.QueueURL,
|
||||
MaxNumberOfMessages: 10,
|
||||
WaitTimeSeconds: receiveWaitSeconds,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
messages := make([]QueueMessage, 0, len(output.Messages))
|
||||
for _, message := range output.Messages {
|
||||
if message.Body == nil || message.ReceiptHandle == nil {
|
||||
continue
|
||||
}
|
||||
messages = append(messages, QueueMessage{
|
||||
Body: *message.Body,
|
||||
ReceiptHandle: *message.ReceiptHandle,
|
||||
})
|
||||
}
|
||||
|
||||
return messages, nil
|
||||
}
|
||||
|
||||
// DeleteMessage acknowledges a message. Until this is called the message is
|
||||
// merely invisible, and it returns to the queue when its visibility timeout
|
||||
// expires — which is how a handler that failed gets another attempt.
|
||||
func (svc SQSConcrete) DeleteMessage(ctx context.Context, receiptHandle string) error {
|
||||
_, err := svc.SQSClient.DeleteMessage(ctx, &sqs.DeleteMessageInput{
|
||||
QueueUrl: &svc.QueueURL,
|
||||
ReceiptHandle: &receiptHandle,
|
||||
})
|
||||
return err
|
||||
}
|
||||
|
||||
// AssertSuccessfulConnection verifies the queue is reachable and readable with
|
||||
// the configured credentials. It panics on failure, since a service whose
|
||||
// consumer cannot start would serve a catalogue that silently stops growing.
|
||||
func (svc SQSConcrete) AssertSuccessfulConnection(ctx context.Context) {
|
||||
if _, err := svc.SQSClient.GetQueueAttributes(ctx, &sqs.GetQueueAttributesInput{
|
||||
QueueUrl: &svc.QueueURL,
|
||||
}); err != nil {
|
||||
panic(fmt.Errorf("sqs: cannot connect to queue %q: %w", svc.QueueURL, err))
|
||||
}
|
||||
}
|
||||
@@ -6,9 +6,14 @@ import (
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"thamanyah/discovery/internal/consumers"
|
||||
"thamanyah/discovery/internal/db"
|
||||
"thamanyah/discovery/internal/db/repositories"
|
||||
"thamanyah/discovery/internal/handlers"
|
||||
"thamanyah/discovery/internal/services"
|
||||
|
||||
awsconfig "github.com/aws/aws-sdk-go-v2/config"
|
||||
"github.com/aws/aws-sdk-go-v2/service/sqs"
|
||||
_ "github.com/lib/pq"
|
||||
httpSwagger "github.com/swaggo/http-swagger/v2"
|
||||
|
||||
@@ -38,13 +43,44 @@ func runMigrate() {
|
||||
}
|
||||
|
||||
func runServer() {
|
||||
// The queue subscribed to cms's catalogue topic. This is the only AWS
|
||||
// service discovery talks to.
|
||||
catalogueQueueURL := os.Getenv("CATALOGUE_EVENTS_QUEUE_URL")
|
||||
if catalogueQueueURL == "" {
|
||||
panic(fmt.Errorf("missing required env var: CATALOGUE_EVENTS_QUEUE_URL"))
|
||||
}
|
||||
|
||||
awsConfig, err := awsconfig.LoadDefaultConfig(context.Background())
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
concreteSQSClient := &services.SQSConcrete{
|
||||
SQSClient: sqs.NewFromConfig(awsConfig),
|
||||
QueueURL: catalogueQueueURL,
|
||||
}
|
||||
concreteSQSClient.AssertSuccessfulConnection(context.Background())
|
||||
|
||||
services.SQSClient = concreteSQSClient
|
||||
|
||||
concreteDBClient := db.CreateDBConnection(requireDBConnectionString())
|
||||
defer db.CloseConnection(concreteDBClient)
|
||||
db.AssertSuccessfulConnection(context.Background(), concreteDBClient)
|
||||
|
||||
repositories.VideoRepo = repositories.VideoRepository{
|
||||
SQLDB: concreteDBClient,
|
||||
}
|
||||
|
||||
// Consumes announcements for the lifetime of the process. Cancelled when
|
||||
// runServer returns, which today only happens if the server itself fails.
|
||||
consumerCtx, stopConsumer := context.WithCancel(context.Background())
|
||||
defer stopConsumer()
|
||||
go consumers.CatalogueEvents{}.Run(consumerCtx)
|
||||
|
||||
mux := http.NewServeMux()
|
||||
|
||||
mux.HandleFunc("GET /health", handlers.Health)
|
||||
mux.HandleFunc("GET /api/videos/{id}", handlers.GetVideo)
|
||||
|
||||
// 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