From f26ea98b52ce6916539c9113816c25752279eae3 Mon Sep 17 00:00:00 2001 From: FahdShalhoub Date: Sun, 16 Aug 2026 23:42:00 +0300 Subject: [PATCH] FEAT: Added Swagger Docs --- cms/docs/docs.go | 352 ++++++++++++++++++++++++++++++ cms/docs/swagger.json | 327 +++++++++++++++++++++++++++ cms/docs/swagger.yaml | 240 ++++++++++++++++++++ cms/go.mod | 14 ++ cms/go.sum | 55 +++++ cms/internal/handlers/handlers.go | 22 +- cms/internal/handlers/videos.go | 77 +++++-- cms/main.go | 12 + 8 files changed, 1073 insertions(+), 26 deletions(-) create mode 100644 cms/docs/docs.go create mode 100644 cms/docs/swagger.json create mode 100644 cms/docs/swagger.yaml diff --git a/cms/docs/docs.go b/cms/docs/docs.go new file mode 100644 index 0000000..ec9eaa8 --- /dev/null +++ b/cms/docs/docs.go @@ -0,0 +1,352 @@ +// Package docs Code generated by swaggo/swag. DO NOT EDIT +package docs + +import "github.com/swaggo/swag" + +const docTemplate = `{ + "schemes": {{ marshal .Schemes }}, + "swagger": "2.0", + "info": { + "description": "{{escape .Description}}", + "title": "{{.Title}}", + "contact": {}, + "version": "{{.Version}}" + }, + "host": "{{.Host}}", + "basePath": "{{.BasePath}}", + "paths": { + "/api/categories": { + "get": { + "description": "Returns the fixed lookup set of categories a video can belong to. Values from this list are the only ones accepted in the ` + "`" + `categories` + "`" + ` field of POST /api/videos.", + "produces": [ + "application/json" + ], + "tags": [ + "categories" + ], + "summary": "List video categories", + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/handlers.categoriesResponse" + } + }, + "500": { + "description": "Categories could not be read from the database", + "schema": { + "$ref": "#/definitions/handlers.problemDetails" + } + } + } + } + }, + "/api/videos": { + "post": { + "description": "Step 2 of the upload flow. Takes the storage key returned by POST /api/videos/presign — after the file has been PUT to the presigned URL — submits a MediaConvert transcoding job, and persists the video with its category links. The returned record has status \"processing\"; note that nothing currently updates that status once transcoding finishes.", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "videos" + ], + "summary": "Register an uploaded video", + "parameters": [ + { + "description": "Video metadata plus the storage key from the presign step. title and key are required; categories must be values from GET /api/categories.", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/handlers.completeRequest" + } + } + ], + "responses": { + "201": { + "description": "Created", + "schema": { + "$ref": "#/definitions/handlers.videoResponse" + } + }, + "400": { + "description": "Request body was not valid JSON", + "schema": { + "$ref": "#/definitions/handlers.problemDetails" + } + }, + "422": { + "description": "title was empty, or key was missing or not a key issued by this API", + "schema": { + "$ref": "#/definitions/handlers.problemDetails" + } + }, + "500": { + "description": "Transcoding job could not be queued, or the video record could not be saved", + "schema": { + "$ref": "#/definitions/handlers.problemDetails" + } + } + } + } + }, + "/api/videos/presign": { + "post": { + "description": "Step 1 of the upload flow. Returns a short-lived presigned S3 URL that the client PUTs the video file to directly, plus the storage key identifying it. Once the PUT succeeds, pass that same key to POST /api/videos to register the video and start transcoding. The file itself never passes through this API.", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "videos" + ], + "summary": "Create a presigned upload URL", + "parameters": [ + { + "description": "Name and media type of the file to be uploaded. contentType must be a video/* type.", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/handlers.presignRequest" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/handlers.presignResponse" + } + }, + "400": { + "description": "Request body was not valid JSON", + "schema": { + "$ref": "#/definitions/handlers.problemDetails" + } + }, + "422": { + "description": "contentType was not a video/* media type", + "schema": { + "$ref": "#/definitions/handlers.problemDetails" + } + }, + "500": { + "description": "Upload URL could not be issued", + "schema": { + "$ref": "#/definitions/handlers.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 or S3 connections.", + "produces": [ + "application/json" + ], + "tags": [ + "system" + ], + "summary": "Health check", + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/handlers.healthResponse" + } + } + } + } + } + }, + "definitions": { + "handlers.categoriesResponse": { + "type": "object", + "properties": { + "categories": { + "type": "array", + "items": { + "type": "string" + }, + "example": [ + "documentary", + "news" + ] + } + } + }, + "handlers.completeRequest": { + "type": "object", + "properties": { + "categories": { + "type": "array", + "items": { + "type": "string" + }, + "example": [ + "documentary", + "news" + ] + }, + "description": { + "type": "string", + "example": "A behind-the-scenes look at the evening bulletin." + }, + "fileName": { + "type": "string", + "example": "interview-cut.mov" + }, + "key": { + "type": "string", + "example": "videos/a1b2c3d4e5f6.mov" + }, + "tags": { + "type": "string", + "example": "media, press, riyadh" + }, + "title": { + "type": "string", + "example": "Inside the Newsroom" + } + } + }, + "handlers.healthResponse": { + "type": "object", + "properties": { + "status": { + "type": "string", + "example": "ok" + } + } + }, + "handlers.presignRequest": { + "type": "object", + "properties": { + "contentType": { + "type": "string", + "example": "video/quicktime" + }, + "fileName": { + "type": "string", + "example": "interview-cut.mov" + } + } + }, + "handlers.presignResponse": { + "type": "object", + "properties": { + "key": { + "type": "string", + "example": "videos/a1b2c3d4e5f6.mov" + }, + "uploadUrl": { + "type": "string", + "example": "https://raw-uploads-bucket.s3.amazonaws.com/videos/a1b2....mov?X-Amz-Signature=..." + } + } + }, + "handlers.problemDetails": { + "type": "object", + "properties": { + "detail": { + "type": "string", + "example": "The 'title' field was absent or contained only whitespace. Every video needs a non-empty title." + }, + "status": { + "type": "integer", + "example": 422 + }, + "title": { + "type": "string", + "example": "Title is required." + }, + "type": { + "type": "string", + "example": "about:blank" + } + } + }, + "handlers.videoResponse": { + "type": "object", + "properties": { + "categories": { + "type": "array", + "items": { + "type": "string" + }, + "example": [ + "documentary", + "news" + ] + }, + "createdAt": { + "type": "string" + }, + "description": { + "type": "string", + "example": "A behind-the-scenes look at the evening bulletin." + }, + "fileName": { + "type": "string", + "example": "interview-cut.mov" + }, + "id": { + "type": "string", + "example": "0199f3a1-7c2e-7b21-9f0d-1a2b3c4d5e6f" + }, + "mediaConvertJobId": { + "type": "string", + "example": "1755300000000-abcdef" + }, + "sizeBytes": { + "type": "integer", + "example": 60 + }, + "status": { + "type": "string", + "example": "processing" + }, + "storageKey": { + "type": "string", + "example": "videos/a1b2c3d4e5f6.mov" + }, + "tags": { + "type": "string", + "example": "media, press, riyadh" + }, + "title": { + "type": "string", + "example": "Inside the Newsroom" + }, + "updatedAt": { + "type": "string" + } + } + } + } +}` + +// SwaggerInfo holds exported Swagger Info so clients can modify it +var SwaggerInfo = &swag.Spec{ + Version: "1.0", + Host: "", + BasePath: "/", + Schemes: []string{}, + Title: "Thamanyah CMS API", + Description: "JSON API for ingesting videos into the Thamanyah catalogue. Uploads are two-step: presign, PUT the file straight to S3, then register the video to queue transcoding.", + InfoInstanceName: "swagger", + SwaggerTemplate: docTemplate, + LeftDelim: "{{", + RightDelim: "}}", +} + +func init() { + swag.Register(SwaggerInfo.InstanceName(), SwaggerInfo) +} diff --git a/cms/docs/swagger.json b/cms/docs/swagger.json new file mode 100644 index 0000000..d7e5c5f --- /dev/null +++ b/cms/docs/swagger.json @@ -0,0 +1,327 @@ +{ + "swagger": "2.0", + "info": { + "description": "JSON API for ingesting videos into the Thamanyah catalogue. Uploads are two-step: presign, PUT the file straight to S3, then register the video to queue transcoding.", + "title": "Thamanyah CMS API", + "contact": {}, + "version": "1.0" + }, + "basePath": "/", + "paths": { + "/api/categories": { + "get": { + "description": "Returns the fixed lookup set of categories a video can belong to. Values from this list are the only ones accepted in the `categories` field of POST /api/videos.", + "produces": [ + "application/json" + ], + "tags": [ + "categories" + ], + "summary": "List video categories", + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/handlers.categoriesResponse" + } + }, + "500": { + "description": "Categories could not be read from the database", + "schema": { + "$ref": "#/definitions/handlers.problemDetails" + } + } + } + } + }, + "/api/videos": { + "post": { + "description": "Step 2 of the upload flow. Takes the storage key returned by POST /api/videos/presign — after the file has been PUT to the presigned URL — submits a MediaConvert transcoding job, and persists the video with its category links. The returned record has status \"processing\"; note that nothing currently updates that status once transcoding finishes.", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "videos" + ], + "summary": "Register an uploaded video", + "parameters": [ + { + "description": "Video metadata plus the storage key from the presign step. title and key are required; categories must be values from GET /api/categories.", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/handlers.completeRequest" + } + } + ], + "responses": { + "201": { + "description": "Created", + "schema": { + "$ref": "#/definitions/handlers.videoResponse" + } + }, + "400": { + "description": "Request body was not valid JSON", + "schema": { + "$ref": "#/definitions/handlers.problemDetails" + } + }, + "422": { + "description": "title was empty, or key was missing or not a key issued by this API", + "schema": { + "$ref": "#/definitions/handlers.problemDetails" + } + }, + "500": { + "description": "Transcoding job could not be queued, or the video record could not be saved", + "schema": { + "$ref": "#/definitions/handlers.problemDetails" + } + } + } + } + }, + "/api/videos/presign": { + "post": { + "description": "Step 1 of the upload flow. Returns a short-lived presigned S3 URL that the client PUTs the video file to directly, plus the storage key identifying it. Once the PUT succeeds, pass that same key to POST /api/videos to register the video and start transcoding. The file itself never passes through this API.", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "videos" + ], + "summary": "Create a presigned upload URL", + "parameters": [ + { + "description": "Name and media type of the file to be uploaded. contentType must be a video/* type.", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/handlers.presignRequest" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/handlers.presignResponse" + } + }, + "400": { + "description": "Request body was not valid JSON", + "schema": { + "$ref": "#/definitions/handlers.problemDetails" + } + }, + "422": { + "description": "contentType was not a video/* media type", + "schema": { + "$ref": "#/definitions/handlers.problemDetails" + } + }, + "500": { + "description": "Upload URL could not be issued", + "schema": { + "$ref": "#/definitions/handlers.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 or S3 connections.", + "produces": [ + "application/json" + ], + "tags": [ + "system" + ], + "summary": "Health check", + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/handlers.healthResponse" + } + } + } + } + } + }, + "definitions": { + "handlers.categoriesResponse": { + "type": "object", + "properties": { + "categories": { + "type": "array", + "items": { + "type": "string" + }, + "example": [ + "documentary", + "news" + ] + } + } + }, + "handlers.completeRequest": { + "type": "object", + "properties": { + "categories": { + "type": "array", + "items": { + "type": "string" + }, + "example": [ + "documentary", + "news" + ] + }, + "description": { + "type": "string", + "example": "A behind-the-scenes look at the evening bulletin." + }, + "fileName": { + "type": "string", + "example": "interview-cut.mov" + }, + "key": { + "type": "string", + "example": "videos/a1b2c3d4e5f6.mov" + }, + "tags": { + "type": "string", + "example": "media, press, riyadh" + }, + "title": { + "type": "string", + "example": "Inside the Newsroom" + } + } + }, + "handlers.healthResponse": { + "type": "object", + "properties": { + "status": { + "type": "string", + "example": "ok" + } + } + }, + "handlers.presignRequest": { + "type": "object", + "properties": { + "contentType": { + "type": "string", + "example": "video/quicktime" + }, + "fileName": { + "type": "string", + "example": "interview-cut.mov" + } + } + }, + "handlers.presignResponse": { + "type": "object", + "properties": { + "key": { + "type": "string", + "example": "videos/a1b2c3d4e5f6.mov" + }, + "uploadUrl": { + "type": "string", + "example": "https://raw-uploads-bucket.s3.amazonaws.com/videos/a1b2....mov?X-Amz-Signature=..." + } + } + }, + "handlers.problemDetails": { + "type": "object", + "properties": { + "detail": { + "type": "string", + "example": "The 'title' field was absent or contained only whitespace. Every video needs a non-empty title." + }, + "status": { + "type": "integer", + "example": 422 + }, + "title": { + "type": "string", + "example": "Title is required." + }, + "type": { + "type": "string", + "example": "about:blank" + } + } + }, + "handlers.videoResponse": { + "type": "object", + "properties": { + "categories": { + "type": "array", + "items": { + "type": "string" + }, + "example": [ + "documentary", + "news" + ] + }, + "createdAt": { + "type": "string" + }, + "description": { + "type": "string", + "example": "A behind-the-scenes look at the evening bulletin." + }, + "fileName": { + "type": "string", + "example": "interview-cut.mov" + }, + "id": { + "type": "string", + "example": "0199f3a1-7c2e-7b21-9f0d-1a2b3c4d5e6f" + }, + "mediaConvertJobId": { + "type": "string", + "example": "1755300000000-abcdef" + }, + "sizeBytes": { + "type": "integer", + "example": 60 + }, + "status": { + "type": "string", + "example": "processing" + }, + "storageKey": { + "type": "string", + "example": "videos/a1b2c3d4e5f6.mov" + }, + "tags": { + "type": "string", + "example": "media, press, riyadh" + }, + "title": { + "type": "string", + "example": "Inside the Newsroom" + }, + "updatedAt": { + "type": "string" + } + } + } + } +} \ No newline at end of file diff --git a/cms/docs/swagger.yaml b/cms/docs/swagger.yaml new file mode 100644 index 0000000..9ca4d78 --- /dev/null +++ b/cms/docs/swagger.yaml @@ -0,0 +1,240 @@ +basePath: / +definitions: + handlers.categoriesResponse: + properties: + categories: + example: + - documentary + - news + items: + type: string + type: array + type: object + handlers.completeRequest: + properties: + categories: + example: + - documentary + - news + items: + type: string + type: array + description: + example: A behind-the-scenes look at the evening bulletin. + type: string + fileName: + example: interview-cut.mov + type: string + key: + example: videos/a1b2c3d4e5f6.mov + type: string + tags: + example: media, press, riyadh + type: string + title: + example: Inside the Newsroom + type: string + type: object + handlers.healthResponse: + properties: + status: + example: ok + type: string + type: object + handlers.presignRequest: + properties: + contentType: + example: video/quicktime + type: string + fileName: + example: interview-cut.mov + type: string + type: object + handlers.presignResponse: + properties: + key: + example: videos/a1b2c3d4e5f6.mov + type: string + uploadUrl: + example: https://raw-uploads-bucket.s3.amazonaws.com/videos/a1b2....mov?X-Amz-Signature=... + type: string + type: object + handlers.problemDetails: + properties: + detail: + example: The 'title' field was absent or contained only whitespace. Every + video needs a non-empty title. + type: string + status: + example: 422 + type: integer + title: + example: Title is required. + type: string + type: + example: about:blank + type: string + type: object + handlers.videoResponse: + properties: + categories: + example: + - documentary + - news + items: + type: string + type: array + createdAt: + type: string + description: + example: A behind-the-scenes look at the evening bulletin. + type: string + fileName: + example: interview-cut.mov + type: string + id: + example: 0199f3a1-7c2e-7b21-9f0d-1a2b3c4d5e6f + type: string + mediaConvertJobId: + example: 1755300000000-abcdef + type: string + sizeBytes: + example: 60 + type: integer + status: + example: processing + type: string + storageKey: + example: videos/a1b2c3d4e5f6.mov + type: string + tags: + example: media, press, riyadh + type: string + title: + example: Inside the Newsroom + type: string + updatedAt: + type: string + type: object +info: + contact: {} + description: 'JSON API for ingesting videos into the Thamanyah catalogue. Uploads + are two-step: presign, PUT the file straight to S3, then register the video to + queue transcoding.' + title: Thamanyah CMS API + version: "1.0" +paths: + /api/categories: + get: + description: Returns the fixed lookup set of categories a video can belong to. + Values from this list are the only ones accepted in the `categories` field + of POST /api/videos. + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/handlers.categoriesResponse' + "500": + description: Categories could not be read from the database + schema: + $ref: '#/definitions/handlers.problemDetails' + summary: List video categories + tags: + - categories + /api/videos: + post: + consumes: + - application/json + description: Step 2 of the upload flow. Takes the storage key returned by POST + /api/videos/presign — after the file has been PUT to the presigned URL — submits + a MediaConvert transcoding job, and persists the video with its category links. + The returned record has status "processing"; note that nothing currently updates + that status once transcoding finishes. + parameters: + - description: Video metadata plus the storage key from the presign step. title + and key are required; categories must be values from GET /api/categories. + in: body + name: request + required: true + schema: + $ref: '#/definitions/handlers.completeRequest' + produces: + - application/json + responses: + "201": + description: Created + schema: + $ref: '#/definitions/handlers.videoResponse' + "400": + description: Request body was not valid JSON + schema: + $ref: '#/definitions/handlers.problemDetails' + "422": + description: title was empty, or key was missing or not a key issued by + this API + schema: + $ref: '#/definitions/handlers.problemDetails' + "500": + description: Transcoding job could not be queued, or the video record could + not be saved + schema: + $ref: '#/definitions/handlers.problemDetails' + summary: Register an uploaded video + tags: + - videos + /api/videos/presign: + post: + consumes: + - application/json + description: Step 1 of the upload flow. Returns a short-lived presigned S3 URL + that the client PUTs the video file to directly, plus the storage key identifying + it. Once the PUT succeeds, pass that same key to POST /api/videos to register + the video and start transcoding. The file itself never passes through this + API. + parameters: + - description: Name and media type of the file to be uploaded. contentType must + be a video/* type. + in: body + name: request + required: true + schema: + $ref: '#/definitions/handlers.presignRequest' + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/handlers.presignResponse' + "400": + description: Request body was not valid JSON + schema: + $ref: '#/definitions/handlers.problemDetails' + "422": + description: contentType was not a video/* media type + schema: + $ref: '#/definitions/handlers.problemDetails' + "500": + description: Upload URL could not be issued + schema: + $ref: '#/definitions/handlers.problemDetails' + summary: Create a presigned upload URL + tags: + - videos + /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 or S3 connections. + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/handlers.healthResponse' + summary: Health check + tags: + - system +swagger: "2.0" diff --git a/cms/go.mod b/cms/go.mod index 9bf06ba..b311338 100644 --- a/cms/go.mod +++ b/cms/go.mod @@ -9,9 +9,12 @@ require ( github.com/aws/aws-sdk-go-v2/service/s3 v1.107.1 github.com/golang-migrate/migrate/v4 v4.19.1 github.com/lib/pq v1.12.3 + github.com/swaggo/http-swagger/v2 v2.0.2 + github.com/swaggo/swag v1.16.6 ) require ( + github.com/KyleBanks/depth v1.2.1 // indirect github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.17 // indirect github.com/aws/aws-sdk-go-v2/credentials v1.19.35 // indirect github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.36 // indirect @@ -27,5 +30,16 @@ require ( github.com/aws/aws-sdk-go-v2/service/ssooidc v1.38.5 // indirect github.com/aws/aws-sdk-go-v2/service/sts v1.45.5 // indirect github.com/aws/smithy-go v1.27.7 // 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 + github.com/go-openapi/swag v0.19.15 // indirect + github.com/josharian/intern v1.0.0 // indirect + github.com/mailru/easyjson v0.7.6 // indirect + github.com/swaggo/files/v2 v2.0.0 // indirect + golang.org/x/mod v0.29.0 // indirect + golang.org/x/sync v0.18.0 // indirect golang.org/x/sys v0.41.0 // indirect + golang.org/x/tools v0.38.0 // indirect + gopkg.in/yaml.v2 v2.4.0 // indirect ) diff --git a/cms/go.sum b/cms/go.sum index d6d2c05..cccad89 100644 --- a/cms/go.sum +++ b/cms/go.sum @@ -1,5 +1,7 @@ github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161 h1:L/gRVlceqvL25UVaW/CKtUDjefjrs0SPonmDGUVOYP0= github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E= +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.43.5 h1:yKT5GYnFWhuDo+DqKvE5ZPwVn3RjC4MAeBtZGlh6AVM= @@ -44,6 +46,9 @@ github.com/containerd/errdefs v1.0.0 h1:tg5yIfIlQIrxYtu9ajqY42W3lpS19XqdxRQeEwYG github.com/containerd/errdefs v1.0.0/go.mod h1:+YBYIdtsnF4Iw6nWZhJcqGSg/dwvV7tyJ/kCkyJ2k+M= github.com/containerd/errdefs/pkg v0.3.0 h1:9IKJ06FvyNlexW690DXuQNx2KA2cUJXx151Xdx3ZPPE= github.com/containerd/errdefs/pkg v0.3.0/go.mod h1:NJw6s9HwNuRhnjJhM7pylWwMyAkmCQvQ4GpJHEqRLVk= +github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/dhui/dktest v0.4.6 h1:+DPKyScKSEp3VLtbMDHcUq6V5Lm5zfZZVb0Sk7Ahom4= @@ -62,28 +67,63 @@ github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= +github.com/go-openapi/jsonpointer v0.19.3/go.mod h1:Pl9vOtqEWErmShwVjC8pYs9cog34VGT37dQOVbmoatg= +github.com/go-openapi/jsonpointer v0.19.5 h1:gZr+CIYByUqjcgeLXnQu2gHYQC9o73G2XUeOFYEICuY= +github.com/go-openapi/jsonpointer v0.19.5/go.mod h1:Pl9vOtqEWErmShwVjC8pYs9cog34VGT37dQOVbmoatg= +github.com/go-openapi/jsonreference v0.20.0 h1:MYlu0sBgChmCfJxxUKZ8g1cPWFOB37YSZqewK7OKeyA= +github.com/go-openapi/jsonreference v0.20.0/go.mod h1:Ag74Ico3lPc+zR+qjn4XBUmXymS4zJbYVCZmcgkasdo= +github.com/go-openapi/spec v0.20.6 h1:ich1RQ3WDbfoeTqTAb+5EIxNmpKVJZWBNah9RAT0jIQ= +github.com/go-openapi/spec v0.20.6/go.mod h1:2OpW+JddWPrpXSCIX8eOx7lZ5iyuWj3RYR6VaaBKcWA= +github.com/go-openapi/swag v0.19.5/go.mod h1:POnQmlKehdgb5mhVOsnJFsivZCEZ/vjK9gh66Z9tfKk= +github.com/go-openapi/swag v0.19.15 h1:D2NRCBzS9/pEY3gP9Nl8aDqGUcPFrwG2p+CNFrLyrCM= +github.com/go-openapi/swag v0.19.15/go.mod h1:QYRuS/SOXUCsnplDa677K7+DxSOj6IPNl/eQntq43wQ= github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= github.com/golang-migrate/migrate/v4 v4.19.1 h1:OCyb44lFuQfYXYLx1SCxPZQGU7mcaZ7gH9yH4jSFbBA= github.com/golang-migrate/migrate/v4 v4.19.1/go.mod h1:CTcgfjxhaUtsLipnLoQRWCrjYXycRz/g5+RWDuYgPrE= +github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= +github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY= +github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y= +github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= +github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= +github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/lib/pq v1.12.3 h1:tTWxr2YLKwIvK90ZXEw8GP7UFHtcbTtty8zsI+YjrfQ= github.com/lib/pq v1.12.3/go.mod h1:/p+8NSbOcwzAEI7wiMXFlgydTwcgTr3OSKMsD2BitpA= +github.com/mailru/easyjson v0.0.0-20190614124828-94de47d64c63/go.mod h1:C1wdFJiN94OJF2b5HbByQZoLdCWB1Yqtg26g4irojpc= +github.com/mailru/easyjson v0.0.0-20190626092158-b2ccc519800e/go.mod h1:C1wdFJiN94OJF2b5HbByQZoLdCWB1Yqtg26g4irojpc= +github.com/mailru/easyjson v0.7.6 h1:8yTIVnZgCoiM1TgqoeTl+LfU5Jg6/xL3QhGQnimLYnA= +github.com/mailru/easyjson v0.7.6/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc= github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0= github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo= github.com/moby/term v0.5.0 h1:xt8Q1nalod/v7BqbG21f8mQPqH+xAaC9C3N3wfWbVP0= github.com/moby/term v0.5.0/go.mod h1:8FzsFHVUBGZdbDsJw/ot+X+d5HLUbvklYLJ9uGfcI3Y= github.com/morikuni/aec v1.0.0 h1:nP9CBfwrvYnBRgY6qfDQkygYDmYwOilePFkwzv4dU8A= github.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc= +github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e h1:fD57ERR4JtEqsWbfPhv4DMiApHyliiK5xCTNVSPiaAs= +github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno= github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U= github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM= github.com/opencontainers/image-spec v1.1.0 h1:8SG7/vwALn54lVB/0yZ/MMwhFrPYtpEHQb2IpWsCzug= github.com/opencontainers/image-spec v1.1.0/go.mod h1:W4s4sFTMaBeK1BQLXbG4AdM2szdn85PY75RI83NrTrM= github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/swaggo/files/v2 v2.0.0 h1:hmAt8Dkynw7Ssz46F6pn8ok6YmGZqHSVLZ+HQM7i0kw= +github.com/swaggo/files/v2 v2.0.0/go.mod h1:24kk2Y9NYEJ5lHuCra6iVwkMjIekMCaFq/0JQj66kyM= +github.com/swaggo/http-swagger/v2 v2.0.2 h1:FKCdLsl+sFCx60KFsyM0rDarwiUSZ8DqbfSyIKC9OBg= +github.com/swaggo/http-swagger/v2 v2.0.2/go.mod h1:r7/GBkAWIfK6E/OLnE8fXnviHiDeAHmgIyooa4xm3AQ= +github.com/swaggo/swag v1.16.6 h1:qBNcx53ZaX+M5dxVyTrgQ0PJ/ACK+NzhwcbieTt+9yI= +github.com/swaggo/swag v1.16.6/go.mod h1:ngP2etMK5a0P3QBizic5MEwpRmluJZPHjXcMoj4Xesg= go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA= go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A= go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0 h1:F7Jx+6hwnZ41NSFTO5q4LYDtJRXBf2PD0rNBkeB/lus= @@ -94,7 +134,22 @@ go.opentelemetry.io/otel/metric v1.37.0 h1:mvwbQS5m0tbmqML4NqK+e3aDiO02vsf/Wgbsd go.opentelemetry.io/otel/metric v1.37.0/go.mod h1:04wGrZurHYKOc+RKeye86GwKiTb9FKm1WHtO+4EVr2E= go.opentelemetry.io/otel/trace v1.37.0 h1:HLdcFNbRQBE2imdSEgm/kwqmQj1Or1l/7bW6mxVK7z4= go.opentelemetry.io/otel/trace v1.37.0/go.mod h1:TlgrlQ+PtQO5XFerSPUYG0JSgGyryXewPGyayAWSBS0= +golang.org/x/mod v0.29.0 h1:HV8lRxZC4l2cr3Zq1LvtOsi/ThTgWnUk/y64QSs8GwA= +golang.org/x/mod v0.29.0/go.mod h1:NyhrlYXJ2H4eJiRy/WDBO6HMqZQ6q9nk4JzS3NuCK+w= +golang.org/x/sync v0.18.0 h1:kr88TuHDroi+UVf+0hZnirlk8o8T+4MrK6mr60WkH/I= +golang.org/x/sync v0.18.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k= golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/tools v0.38.0 h1:Hx2Xv8hISq8Lm16jvBZ2VQf+RLmbd7wVUsALibYI/IQ= +golang.org/x/tools v0.38.0/go.mod h1:yEsQ/d/YK8cjh0L6rZlY8tgtlKiBNTL14pGDJPJpYQs= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f h1:BLraFXnmrev5lT+xlilqcH8XK9/i0At2xKjWk4p6zsU= +gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= +gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.0-20200615113413-eeeca48fe776/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/cms/internal/handlers/handlers.go b/cms/internal/handlers/handlers.go index 131c1d5..0b83e57 100644 --- a/cms/internal/handlers/handlers.go +++ b/cms/internal/handlers/handlers.go @@ -6,8 +6,20 @@ import ( "net/http" ) +type healthResponse struct { + Status string `json:"status" example:"ok"` +} + +// Health is the liveness probe the ALB target group polls. +// +// @Summary Health check +// @Description Reports that the service is up and serving. Used as the ALB target group health check; it does not verify the database or S3 connections. +// @Tags system +// @Produce json +// @Success 200 {object} healthResponse +// @Router /health [get] func Health(w http.ResponseWriter, r *http.Request) { - writeJSON(w, http.StatusOK, map[string]string{"status": "ok"}) + writeJSON(w, http.StatusOK, healthResponse{Status: "ok"}) } // problemDetails is an error body in the RFC 9457 "Problem Details for HTTP @@ -17,10 +29,10 @@ func Health(w http.ResponseWriter, r *http.Request) { // branch on it; Detail explains this particular occurrence and is the only // member that varies with request data. type problemDetails struct { - Type string `json:"type"` - Title string `json:"title"` - Status int `json:"status"` - Detail string `json:"detail,omitempty"` + Type string `json:"type" example:"about:blank"` + Title string `json:"title" example:"Title is required."` + Status int `json:"status" example:"422"` + Detail string `json:"detail,omitempty" example:"The 'title' field was absent or contained only whitespace. Every video needs a non-empty title."` } func writeJSON(w http.ResponseWriter, status int, body any) { diff --git a/cms/internal/handlers/videos.go b/cms/internal/handlers/videos.go index b74e174..cea50b6 100644 --- a/cms/internal/handlers/videos.go +++ b/cms/internal/handlers/videos.go @@ -20,9 +20,18 @@ const ( ) type categoriesResponse struct { - Categories []string `json:"categories"` + Categories []string `json:"categories" example:"documentary,news"` } +// ListCategories returns the categories a video may be assigned to. +// +// @Summary List video categories +// @Description Returns the fixed lookup set of categories a video can belong to. Values from this list are the only ones accepted in the `categories` field of POST /api/videos. +// @Tags categories +// @Produce json +// @Success 200 {object} categoriesResponse +// @Failure 500 {object} problemDetails "Categories could not be read from the database" +// @Router /api/categories [get] func ListCategories(w http.ResponseWriter, r *http.Request) { categories, err := services.DB.ListCategories(r.Context()) if err != nil { @@ -39,15 +48,28 @@ func ListCategories(w http.ResponseWriter, r *http.Request) { } type presignRequest struct { - FileName string `json:"fileName"` - ContentType string `json:"contentType"` + FileName string `json:"fileName" example:"interview-cut.mov"` + ContentType string `json:"contentType" example:"video/quicktime"` } type presignResponse struct { - UploadURL string `json:"uploadUrl"` - Key string `json:"key"` + UploadURL string `json:"uploadUrl" example:"https://raw-uploads-bucket.s3.amazonaws.com/videos/a1b2....mov?X-Amz-Signature=..."` + Key string `json:"key" example:"videos/a1b2c3d4e5f6.mov"` } +// PresignVideoUpload issues a presigned S3 PUT URL for a video upload. +// +// @Summary Create a presigned upload URL +// @Description Step 1 of the upload flow. Returns a short-lived presigned S3 URL that the client PUTs the video file to directly, plus the storage key identifying it. Once the PUT succeeds, pass that same key to POST /api/videos to register the video and start transcoding. The file itself never passes through this API. +// @Tags videos +// @Accept json +// @Produce json +// @Param request body presignRequest true "Name and media type of the file to be uploaded. contentType must be a video/* type." +// @Success 200 {object} presignResponse +// @Failure 400 {object} problemDetails "Request body was not valid JSON" +// @Failure 422 {object} problemDetails "contentType was not a video/* media type" +// @Failure 500 {object} problemDetails "Upload URL could not be issued" +// @Router /api/videos/presign [post] func PresignVideoUpload(w http.ResponseWriter, r *http.Request) { r.Body = http.MaxBytesReader(w, r.Body, maxJSONBodySize) @@ -83,29 +105,42 @@ func PresignVideoUpload(w http.ResponseWriter, r *http.Request) { } type completeRequest struct { - Title string `json:"title"` - Description string `json:"description"` - Categories []string `json:"categories"` - Tags string `json:"tags"` - FileName string `json:"fileName"` - Key string `json:"key"` + Title string `json:"title" example:"Inside the Newsroom"` + Description string `json:"description" example:"A behind-the-scenes look at the evening bulletin."` + Categories []string `json:"categories" example:"documentary,news"` + Tags string `json:"tags" example:"media, press, riyadh"` + FileName string `json:"fileName" example:"interview-cut.mov"` + Key string `json:"key" example:"videos/a1b2c3d4e5f6.mov"` } type videoResponse struct { - ID string `json:"id"` - Title string `json:"title"` - Description string `json:"description"` - Categories []string `json:"categories"` - Tags string `json:"tags"` - FileName string `json:"fileName"` - StorageKey string `json:"storageKey"` - MediaConvertJobID string `json:"mediaConvertJobId"` - Status string `json:"status"` - SizeBytes int64 `json:"sizeBytes"` + ID string `json:"id" example:"0199f3a1-7c2e-7b21-9f0d-1a2b3c4d5e6f"` + Title string `json:"title" example:"Inside the Newsroom"` + Description string `json:"description" example:"A behind-the-scenes look at the evening bulletin."` + Categories []string `json:"categories" example:"documentary,news"` + Tags string `json:"tags" example:"media, press, riyadh"` + FileName string `json:"fileName" example:"interview-cut.mov"` + StorageKey string `json:"storageKey" example:"videos/a1b2c3d4e5f6.mov"` + MediaConvertJobID string `json:"mediaConvertJobId" example:"1755300000000-abcdef"` + Status string `json:"status" example:"processing"` + SizeBytes int64 `json:"sizeBytes" example:"60"` CreatedAt time.Time `json:"createdAt"` UpdatedAt time.Time `json:"updatedAt"` } +// CompleteVideoUpload registers an uploaded video and queues it for transcoding. +// +// @Summary Register an uploaded video +// @Description Step 2 of the upload flow. Takes the storage key returned by POST /api/videos/presign — after the file has been PUT to the presigned URL — submits a MediaConvert transcoding job, and persists the video with its category links. The returned record has status "processing"; note that nothing currently updates that status once transcoding finishes. +// @Tags videos +// @Accept json +// @Produce json +// @Param request body completeRequest true "Video metadata plus the storage key from the presign step. title and key are required; categories must be values from GET /api/categories." +// @Success 201 {object} videoResponse +// @Failure 400 {object} problemDetails "Request body was not valid JSON" +// @Failure 422 {object} problemDetails "title was empty, or key was missing or not a key issued by this API" +// @Failure 500 {object} problemDetails "Transcoding job could not be queued, or the video record could not be saved" +// @Router /api/videos [post] func CompleteVideoUpload(w http.ResponseWriter, r *http.Request) { r.Body = http.MaxBytesReader(w, r.Body, maxJSONBodySize) diff --git a/cms/main.go b/cms/main.go index 163f4bb..1d7ecc7 100644 --- a/cms/main.go +++ b/cms/main.go @@ -13,8 +13,16 @@ import ( "github.com/aws/aws-sdk-go-v2/service/mediaconvert" "github.com/aws/aws-sdk-go-v2/service/s3" _ "github.com/lib/pq" + httpSwagger "github.com/swaggo/http-swagger/v2" + + _ "thamanyah/cms/v2/docs" ) +// @title Thamanyah CMS API +// @version 1.0 +// @description JSON API for ingesting videos into the Thamanyah catalogue. Uploads are two-step: presign, PUT the file straight to S3, then register the video to queue transcoding. +// @BasePath / + func main() { if len(os.Args) > 1 && os.Args[1] == "migrate" { runMigrate() @@ -83,6 +91,10 @@ func runServer() { mux.HandleFunc("POST /api/videos/presign", handlers.PresignVideoUpload) mux.HandleFunc("POST /api/videos", handlers.CompleteVideoUpload) + // 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. + mux.Handle("GET /swagger/", httpSwagger.WrapHandler) + addr := ":8081" log.Printf("listening on %s", addr) if err := http.ListenAndServe(addr, mux); err != nil {