| 1 | // Package httpproblem implements RFC 7807 "Problem Details for HTTP APIs" |
| 2 | // with a bridge to culpa errors. |
| 3 | // |
| 4 | // The package provides a typed [Problem] struct, a [FromError] mapper that |
| 5 | // extracts [culpa.CodeDetail] and [culpa.PublicDetail] from the error chain, |
| 6 | // and a small set of sentinel constructors for common HTTP statuses. |
| 7 | // |
| 8 | // The package is intentionally a subpackage of culpa rather than a top-level |
| 9 | // auxilia package so that culpa core remains free of the net/http import. |
| 10 | // Importers who need HTTP responses opt in by importing this package. |
| 11 | package httpproblem |
| 12 | |
| 13 | import ( |
| 14 | "encoding/json" |
| 15 | "errors" |
| 16 | "net/http" |
| 17 | |
| 18 | "go.bigb.es/auxilia/culpa" |
| 19 | ) |
| 20 | |
| 21 | // ContentType is the media type for RFC 7807 problem documents. |
| 22 | const ContentType = "application/problem+json" |
| 23 | |
| 24 | // defaultType is the placeholder URI per RFC 7807 §4.2 when no Type is set. |
| 25 | const defaultType = "about:blank" |
| 26 | |
| 27 | // Problem is RFC 7807 Problem Details for HTTP APIs. |
| 28 | // |
| 29 | // A Problem is itself an error so that handlers can return it through the |
| 30 | // usual error chain and rely on [FromError] to pass it through unchanged. |
| 31 | type Problem struct { |
| 32 | // Type is a URI identifying the problem. Defaults to "about:blank". |
| 33 | Type string `json:"type"` |
| 34 | // Title is a short human-readable summary. |
| 35 | Title string `json:"title"` |
| 36 | // Status is the HTTP status code. |
| 37 | Status int `json:"status"` |
| 38 | // Detail is a user-safe explanation. Empty for opaque internal errors. |
| 39 | Detail string `json:"detail,omitempty"` |
| 40 | // Code is a machine-readable slug. |
| 41 | Code string `json:"code,omitempty"` |
| 42 | } |
| 43 | |
| 44 | // Error implements the error interface. |
| 45 | func (p Problem) Error() string { |
| 46 | if p.Detail != "" { |
| 47 | return p.Title + ": " + p.Detail |
| 48 | } |
| 49 | return p.Title |
| 50 | } |
| 51 | |
| 52 | // Write serializes the Problem as JSON, sets the |
| 53 | // Content-Type to application/problem+json, and writes the status code. |
| 54 | // |
| 55 | // If Type is empty, it is replaced with "about:blank" in the response body |
| 56 | // without mutating the receiver. |
| 57 | // |
| 58 | // Encoding errors are silently dropped: by the time encoding fails the |
| 59 | // response has already been partially written and there is nothing useful |
| 60 | // to do. |
| 61 | func (p Problem) Write(w http.ResponseWriter) { |
| 62 | out := p |
| 63 | if out.Type == "" { |
| 64 | out.Type = defaultType |
| 65 | } |
| 66 | w.Header().Set("Content-Type", ContentType) |
| 67 | w.WriteHeader(out.Status) |
| 68 | _ = json.NewEncoder(w).Encode(out) |
| 69 | } |
| 70 | |
| 71 | // FromError maps any error onto a [Problem]. |
| 72 | // |
| 73 | // Resolution order: |
| 74 | // |
| 75 | // 1. If the error chain contains a [Problem], that value is used as the |
| 76 | // base (so callers can return one directly even when wrapped). |
| 77 | // 2. If the chain contains one of the typed sentinel errors |
| 78 | // ([NotFoundError], [BadRequestError], ...), the matching status and |
| 79 | // code are used. |
| 80 | // 3. Anything else collapses to a Status 500 problem with Title |
| 81 | // "Internal Server Error" and empty Detail/Code so internal error |
| 82 | // messages do not leak into responses. |
| 83 | // |
| 84 | // After picking a base, FromError applies culpa overrides discovered in the |
| 85 | // chain: |
| 86 | // |
| 87 | // - [culpa.CodeDetail] (when Code is a non-empty string) overrides Code. |
| 88 | // - [culpa.PublicDetail] (when Message is non-empty) overrides Detail. |
| 89 | // |
| 90 | // The override pass also runs for the default 500 case but only sets fields |
| 91 | // that are still zero, so a [culpa.PublicDetail] message can opt in to |
| 92 | // surfacing a user-safe string while the inner error text still does not |
| 93 | // leak. |
| 94 | // |
| 95 | // FromError(nil) returns the zero Problem (Status 0). Callers should not |
| 96 | // invoke it on a nil error. |
| 97 | func FromError(err error) Problem { |
| 98 | if err == nil { |
| 99 | return Problem{} |
| 100 | } |
| 101 | |
| 102 | var p Problem |
| 103 | matched := false |
| 104 | |
| 105 | // 1. Pre-existing Problem in the chain. |
| 106 | var existing Problem |
| 107 | if errors.As(err, &existing) { |
| 108 | p = existing |
| 109 | matched = true |
| 110 | } |
| 111 | |
| 112 | // 2. Typed sentinels. |
| 113 | if !matched { |
| 114 | matched = matchSentinel(err, &p) |
| 115 | } |
| 116 | |
| 117 | // 3. Default to opaque 500. |
| 118 | if !matched { |
| 119 | p = Problem{ |
| 120 | Status: http.StatusInternalServerError, |
| 121 | Title: "Internal Server Error", |
| 122 | } |
| 123 | } |
| 124 | |
| 125 | // Overrides from culpa details. The override pass never widens what is |
| 126 | // already set on a sentinel-derived problem (Code/Detail can be |
| 127 | // replaced) but for the opaque 500 case Detail starts empty so only an |
| 128 | // explicit PublicDetail can populate it. |
| 129 | for _, code := range culpa.FindDetails[culpa.CodeDetail](err) { |
| 130 | s, ok := code.Code.(string) |
| 131 | if !ok || s == "" { |
| 132 | continue |
| 133 | } |
| 134 | p.Code = s |
| 135 | break |
| 136 | } |
| 137 | for _, pub := range culpa.FindDetails[culpa.PublicDetail](err) { |
| 138 | if pub.Message == "" { |
| 139 | continue |
| 140 | } |
| 141 | p.Detail = pub.Message |
| 142 | break |
| 143 | } |
| 144 | |
| 145 | return p |
| 146 | } |
| 147 | |
| 148 | // matchSentinel checks the chain for one of the typed sentinel errors and, |
| 149 | // if found, populates p with the corresponding status/title/code. |
| 150 | func matchSentinel(err error, p *Problem) bool { |
| 151 | var ( |
| 152 | nf NotFoundError |
| 153 | br BadRequestError |
| 154 | cf ConflictError |
| 155 | un UnauthorizedError |
| 156 | fb ForbiddenError |
| 157 | up UnprocessableError |
| 158 | tmr TooManyRequestsError |
| 159 | in InternalError |
| 160 | ) |
| 161 | |
| 162 | switch { |
| 163 | case errors.As(err, &nf): |
| 164 | *p = Problem{Status: http.StatusNotFound, Title: "Not Found", Code: "NOT_FOUND", Detail: nf.detail()} |
| 165 | case errors.As(err, &br): |
| 166 | *p = Problem{Status: http.StatusBadRequest, Title: "Bad Request", Code: "BAD_REQUEST", Detail: br.Detail} |
| 167 | case errors.As(err, &cf): |
| 168 | *p = Problem{Status: http.StatusConflict, Title: "Conflict", Code: "CONFLICT", Detail: cf.Detail} |
| 169 | case errors.As(err, &un): |
| 170 | *p = Problem{Status: http.StatusUnauthorized, Title: "Unauthorized", Code: "UNAUTHORIZED", Detail: un.Detail} |
| 171 | case errors.As(err, &fb): |
| 172 | *p = Problem{Status: http.StatusForbidden, Title: "Forbidden", Code: "FORBIDDEN", Detail: fb.Detail} |
| 173 | case errors.As(err, &up): |
| 174 | *p = Problem{Status: http.StatusUnprocessableEntity, Title: "Unprocessable Entity", Code: "UNPROCESSABLE", Detail: up.Detail} |
| 175 | case errors.As(err, &tmr): |
| 176 | *p = Problem{Status: http.StatusTooManyRequests, Title: "Too Many Requests", Code: "TOO_MANY_REQUESTS", Detail: tmr.Detail} |
| 177 | case errors.As(err, &in): |
| 178 | // InternalError carries a detail string but FromError still |
| 179 | // blanks it out: 500 responses must not leak internals unless |
| 180 | // an explicit PublicDetail is attached. We keep the code so the |
| 181 | // caller can switch on it. |
| 182 | *p = Problem{Status: http.StatusInternalServerError, Title: "Internal Server Error", Code: "INTERNAL"} |
| 183 | default: |
| 184 | return false |
| 185 | } |
| 186 | return true |
| 187 | } |
| 188 | |