// Package apierror models RFC 7807 Problem Details responses. // // Errors returned from handlers flow through FromError, which inspects // typed sentinel values and culpa details to build a Problem with the // right HTTP status. Anything unrecognized becomes a 500 with the // original message stripped from the response body. package apierror import ( "encoding/json" "errors" "net/http" "go.bigb.es/auxilia/culpa" ) // Problem implements RFC 7807 Problem Details for HTTP APIs. type Problem struct { Type string `json:"type"` Title string `json:"title"` Status int `json:"status"` Detail string `json:"detail,omitempty"` Code string `json:"code,omitempty"` } // Error makes Problem usable as an error itself for chained handling. func (p Problem) Error() string { return p.Title } // Write encodes the Problem as application/problem+json and writes it. func (p Problem) Write(w http.ResponseWriter) { w.Header().Set("Content-Type", "application/problem+json") w.WriteHeader(p.Status) _ = json.NewEncoder(w).Encode(p) } // FromError maps any error onto a Problem. Internal errors (anything not // matched by a typed predicate below) collapse to a generic 500 so we // don't leak implementation details to the client. // // If the error chain carries culpa CodeDetail / PublicDetail, those // override the defaults so call sites can attach machine codes and // user-safe messages without defining new typed errors. func FromError(err error) Problem { var p Problem if errors.As(err, &p) { applyCulpaOverrides(err, &p) return p } var nf NotFoundError if errors.As(err, &nf) { p = Problem{Type: "about:blank", Title: "Not Found", Status: 404, Detail: nf.Error(), Code: "NOT_FOUND"} applyCulpaOverrides(err, &p) return p } var br BadRequestError if errors.As(err, &br) { p = Problem{Type: "about:blank", Title: "Bad Request", Status: 400, Detail: br.Error(), Code: "BAD_REQUEST"} applyCulpaOverrides(err, &p) return p } return Problem{Type: "about:blank", Title: "Internal Server Error", Status: 500} } // applyCulpaOverrides mutates p with any CodeDetail/PublicDetail attached // to the error chain. Public messages are intended for end users, so when // present they replace whatever the typed error produced. func applyCulpaOverrides(err error, p *Problem) { var code culpa.CodeDetail if culpa.FindDetail(err, &code) { if s, ok := code.Code.(string); ok && s != "" { p.Code = s } } var pub culpa.PublicDetail if culpa.FindDetail(err, &pub) && pub.Message != "" { p.Detail = pub.Message } } // NotFoundError signals a missing resource and maps to HTTP 404. type NotFoundError struct{ Resource string } func (e NotFoundError) Error() string { return e.Resource + " not found" } // NotFound is a convenience constructor for NotFoundError. func NotFound(resource string) error { return NotFoundError{Resource: resource} } // BadRequestError signals invalid input and maps to HTTP 400. type BadRequestError struct{ Detail string } func (e BadRequestError) Error() string { return e.Detail } // BadRequest is a convenience constructor for BadRequestError. func BadRequest(detail string) error { return BadRequestError{Detail: detail} }