diff --git a/cms/internal/handlers/handlers.go b/cms/internal/handlers/handlers.go index 0544ac4..131c1d5 100644 --- a/cms/internal/handlers/handlers.go +++ b/cms/internal/handlers/handlers.go @@ -12,9 +12,10 @@ func Health(w http.ResponseWriter, r *http.Request) { // problemDetails is an error body in the RFC 9457 "Problem Details for HTTP // APIs" format. Type stays "about:blank" — the value RFC 9457 defines for -// problems with no dedicated documentation URI — which is why Title carries -// the plain HTTP status phrase and Detail carries the occurrence-specific -// explanation. +// problems with no dedicated documentation URI. Title is a short summary that +// stays identical for every occurrence of the same problem, so clients can +// 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"` @@ -26,10 +27,10 @@ func writeJSON(w http.ResponseWriter, status int, body any) { writeJSONContent(w, "application/json", status, body) } -func writeProblem(w http.ResponseWriter, status int, detail string) { +func writeProblem(w http.ResponseWriter, status int, title, detail string) { writeJSONContent(w, "application/problem+json", status, problemDetails{ Type: "about:blank", - Title: http.StatusText(status), + Title: title, Status: status, Detail: detail, }) diff --git a/cms/internal/handlers/videos.go b/cms/internal/handlers/videos.go index 22e8f22..b74e174 100644 --- a/cms/internal/handlers/videos.go +++ b/cms/internal/handlers/videos.go @@ -27,7 +27,8 @@ func ListCategories(w http.ResponseWriter, r *http.Request) { categories, err := services.DB.ListCategories(r.Context()) if err != nil { log.Printf("Something Went Wrong Loading Categories: %s", err) - writeProblem(w, http.StatusInternalServerError, "Something Went Wrong Loading Categories") + writeProblem(w, http.StatusInternalServerError, "Something Went Wrong Loading Categories", + "The list of video categories 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 } @@ -52,25 +53,29 @@ func PresignVideoUpload(w http.ResponseWriter, r *http.Request) { var req presignRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { - writeProblem(w, http.StatusBadRequest, "Invalid request.") + writeProblem(w, http.StatusBadRequest, "Invalid request.", + fmt.Sprintf("The request body could not be parsed as JSON (%s). Send an object with the string fields 'fileName' and 'contentType', and keep the body under 1 MiB.", err)) return } if !strings.HasPrefix(req.ContentType, "video/") { - writeProblem(w, http.StatusUnprocessableEntity, fmt.Sprintf("Unsupported file type (%s). Please upload a video.", req.ContentType)) + writeProblem(w, http.StatusUnprocessableEntity, "Unsupported file type. Please upload a video.", + fmt.Sprintf("The 'contentType' field was %q, but only video/* media types can be uploaded. Set it to the file's own MIME type, for example 'video/mp4'.", req.ContentType)) return } filename, err := randomFilename(req.FileName) if err != nil { - writeProblem(w, http.StatusInternalServerError, "Could not prepare upload.") + writeProblem(w, http.StatusInternalServerError, "Could not prepare upload.", + "A unique storage key could not be generated because the server's random source failed. This is a server-side fault; retry the request.") return } key := videoUploadPrefix + "/" + filename presignedURL, err := services.S3Client.GetPresignedURL(r.Context(), key, "video/mp4", time.Hour) if err != nil { - writeProblem(w, http.StatusInternalServerError, "Could not prepare upload.") + writeProblem(w, http.StatusInternalServerError, "Could not prepare upload.", + "A presigned upload URL could not be issued for the storage bucket. This is a server-side fault; no upload slot was reserved, so retry the request.") return } @@ -106,19 +111,22 @@ func CompleteVideoUpload(w http.ResponseWriter, r *http.Request) { var req completeRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { - writeProblem(w, http.StatusBadRequest, "Invalid request.") + writeProblem(w, http.StatusBadRequest, "Invalid request.", + fmt.Sprintf("The request body could not be parsed as JSON (%s). Send an object containing at least 'title' and 'key', and keep the body under 1 MiB.", err)) return } title := strings.TrimSpace(req.Title) if title == "" { - writeProblem(w, http.StatusUnprocessableEntity, "Title is required.") + writeProblem(w, http.StatusUnprocessableEntity, "Title is required.", + "The 'title' field was absent or contained only whitespace. Every video needs a non-empty title.") return } key := strings.TrimSpace(req.Key) if key == "" || !strings.HasPrefix(key, videoUploadPrefix+"/") { - writeProblem(w, http.StatusUnprocessableEntity, "A video file is required.") + writeProblem(w, http.StatusUnprocessableEntity, "A video file is required.", + fmt.Sprintf("The 'key' field was %q, which is not a storage key issued by this API. Use the 'key' returned by POST /api/videos/presign, after the file has been PUT to the accompanying upload URL.", req.Key)) return } @@ -132,7 +140,8 @@ func CompleteVideoUpload(w http.ResponseWriter, r *http.Request) { jobID, err := services.MediaConvertClient.QueueEncodingJob(r.Context(), key) if err != nil { log.Printf("Something Went Wrong On Creation Of Transcoding Job: %s", err) - writeProblem(w, http.StatusInternalServerError, "Something Went Wrong On Creation Of Transcoding Job") + writeProblem(w, http.StatusInternalServerError, "Something Went Wrong On Creation Of Transcoding Job", + "The transcoding job could not be queued, so no video record was created. The uploaded file is still in storage; retry this request with the same 'key' rather than uploading the file again.") return } @@ -149,7 +158,8 @@ func CompleteVideoUpload(w http.ResponseWriter, r *http.Request) { }) if err != nil { log.Printf("Something Went Wrong Saving The Video Record: %s", err) - writeProblem(w, http.StatusInternalServerError, "Something Went Wrong Saving The Video Record") + writeProblem(w, http.StatusInternalServerError, "Something Went Wrong Saving The Video Record", + "The transcoding job was queued but its video record could not be written to the database, so the video may finish transcoding without ever appearing in the catalogue. Retry this request or escalate to an operator.") return }