Documentation
¶
Overview ¶
Package problem answers errors as RFC 9457 Problem Details.
A Problem is the document a client receives, with the application/problem+json media type:
{
"type": "about:blank",
"title": "Not Found",
"status": 404,
"detail": "Article was not found",
"instance": "/articles/42",
"code": "article_not_found"
}
Besides the members defined by the RFC it has two extensions: code, a stable identifier a client can branch on, and errors, a list of InvalidParam values that name each rejected request value by its path.
Mapping errors ¶
Services return their own errors; the transport decides what a client sees. A Mapper holds an ordered list of rules and turns an error into a Problem for the request, with the request path as the instance:
mapper, err := problem.MakeMapper(
problem.WhenIs(article.ErrNotFound, problem.Template{
Status: http.StatusNotFound,
Code: "article_not_found",
Detail: "Article was not found",
}),
problem.InputProblemRule(),
problem.ErrorRule(),
)
WhenIs matches with errors.Is, so wrapped errors match too. WhenAs matches with errors.AsType and builds the template from the typed error. When runs any classification. The first matching rule wins.
A Template holds only what is public. An empty type becomes about:blank and an empty title becomes the status text. MakeMapper rejects a rule that is not configured correctly, such as a status outside 400-599, when the application starts rather than when the error happens.
Mapper.Map never exposes an error it does not know. An error no rule matches, or a rule that returns an invalid template, becomes status 500 with code internal_error and nothing else. Log the error; the client sees only the problem. Mapper.MapKnown reports whether a rule matched, so a router adapter can fall back to its own answer, such as its 404 or 405, only for errors the application did not classify.
Input errors ¶
InputProblemRule maps the errors of the request, page and sortby packages:
- a body that is not one JSON document: 400 with code invalid_json, and the path of a value of the wrong type in errors;
- a body in another media type: 415 with an Accept: application/json header;
- a body over an http.MaxBytesReader limit: 413;
- a page number or size out of range: 422 with code invalid_request;
- a sort expression outside the allowed set: 422 with code invalid_request.
A handler that rejects a value itself returns MakeInvalidRequest, or any template with MakeError, and ErrorRule maps it. The cause stays available for logging and never reaches the client. InvalidRequest is the template of the standard invalid_request problem: status 400 means that request values could not be decoded, 422 that decoded values break a constraint.
Codes and messages ¶
The code of an InvalidParam follows go-playground/validator: a constraint is named by its validation tag, such as required, min or max, and the message follows the tag's English translation, such as "size must be 100 or less". A value rejected here reads the same as one rejected by a validator. Two codes have no tag: type for a value that cannot be decoded into its field (TypeParam), and enum for a value outside a closed set.
Writing ¶
Problem.Response answers the problem as a response.Response, so anything that writes a response writes a problem. Write writes it through net/http.
The Header field of a Problem carries headers the problem calls for, such as Accept for 415, WWW-Authenticate for 401 or Retry-After for 429. They are sent with the response and are not part of the document. MakeProblem builds a problem from a template without a mapper, for example for a router's own status errors.
Index ¶
Examples ¶
Constants ¶
const (
// ContentType is the RFC 9457 Problem Details JSON media type.
ContentType = "application/problem+json"
)
Variables ¶
This section is empty.
Functions ¶
Types ¶
type Error ¶
type Error struct {
// contains filtered or unexported fields
}
Error carries a public problem template at the HTTP transport boundary. Domain and service packages should continue to return domain errors.
func MakeError ¶
MakeError creates a transport error with an optional underlying cause. ErrorRule maps it without exposing cause in the response.
func MakeInvalidRequest ¶
func MakeInvalidRequest(status int, cause error, params ...InvalidParam) *Error
MakeInvalidRequest creates the standard invalid_request transport error.
Example ¶
package main
import (
"encoding/json"
"fmt"
"log"
"net/http"
"net/http/httptest"
"github.com/uchaloop/httpx/problem"
)
func main() {
mapper, err := problem.MakeMapper(problem.ErrorRule())
if err != nil {
log.Fatal(err)
}
// A handler that checks a value itself reports it as a validator would.
invalid := problem.MakeInvalidRequest(http.StatusUnprocessableEntity, nil, problem.InvalidParam{
Path: "title",
Code: "required",
Message: "title is a required field",
})
r := httptest.NewRequest(http.MethodPost, "/articles", nil)
body, err := json.Marshal(mapper.Map(r, invalid))
if err != nil {
log.Fatal(err)
}
fmt.Println(string(body))
}
Output: {"type":"about:blank","title":"Unprocessable Entity","status":422,"detail":"Request parameters are invalid","instance":"/articles","code":"invalid_request","errors":[{"path":"title","code":"required","message":"title is a required field"}]}
type InvalidParam ¶
type InvalidParam struct {
Path string `json:"path"`
Code string `json:"code,omitempty"`
Message string `json:"message"`
}
InvalidParam describes one invalid request value.
func TypeParam ¶
func TypeParam(path string, target reflect.Type) InvalidParam
TypeParam describes a request value that cannot be decoded into target, the type of the field it binds to. Its code is type: validation never sees such a value, so no validation tag names the failure. Adapters use it for their binding errors so every framework reports the same text.
type Mapper ¶
type Mapper struct {
// contains filtered or unexported fields
}
Mapper converts errors to request-specific Problem Details documents.
func MakeMapper ¶
MakeMapper creates an ordered error mapper. It returns an error when a rule is not configured correctly.
Example ¶
package main
import (
"errors"
"fmt"
"log"
"net/http"
"net/http/httptest"
"github.com/uchaloop/httpx/problem"
)
var errNotFound = errors.New("article not found")
func main() {
mapper, err := problem.MakeMapper(
problem.WhenIs(errNotFound, problem.Template{
Status: http.StatusNotFound,
Code: "article_not_found",
Detail: "Article was not found",
}),
problem.InputProblemRule(),
problem.ErrorRule(),
)
if err != nil {
log.Fatal(err)
}
getArticle := func(w http.ResponseWriter, r *http.Request) {
// The service wraps its error; errors.Is still finds it.
err := fmt.Errorf("get article 42: %w", errNotFound)
if writeErr := problem.Write(w, mapper.Map(r, err)); writeErr != nil {
log.Print(writeErr)
}
}
recorder := httptest.NewRecorder()
getArticle(recorder, httptest.NewRequest(http.MethodGet, "/articles/42", nil))
fmt.Println(recorder.Code, recorder.Header().Get("Content-Type"))
fmt.Println(recorder.Body.String())
}
Output: 404 application/problem+json {"type":"about:blank","title":"Not Found","status":404,"detail":"Article was not found","instance":"/articles/42","code":"article_not_found"}
func (*Mapper) Map ¶
Map applies the first matching rule. Unknown errors and invalid templates become a safe internal problem that does not expose the source error.
Example ¶
package main
import (
"encoding/json"
"errors"
"fmt"
"log"
"net/http"
"net/http/httptest"
"github.com/uchaloop/httpx/problem"
)
func main() {
mapper, err := problem.MakeMapper()
if err != nil {
log.Fatal(err)
}
// No rule knows this error, so the client learns nothing about it.
r := httptest.NewRequest(http.MethodGet, "/articles/42", nil)
mapped := mapper.Map(r, errors.New("dial tcp 10.0.0.5:5432: connection refused"))
body, err := json.Marshal(mapped)
if err != nil {
log.Fatal(err)
}
fmt.Println(string(body))
}
Output: {"type":"about:blank","title":"Internal Server Error","status":500,"instance":"/articles/42","code":"internal_error"}
type Problem ¶
type Problem struct {
Type string `json:"type"`
Title string `json:"title"`
Status int `json:"status"`
Detail string `json:"detail,omitempty"`
Instance string `json:"instance,omitempty"`
Code string `json:"code,omitempty"`
Errors []InvalidParam `json:"errors,omitempty"`
// Header holds response headers the problem calls for, such as Accept for
// 415 or WWW-Authenticate for 401. It is sent, but is not part of the
// document.
Header http.Header `json:"-"`
}
Problem is an RFC 9457 Problem Details document with stable code and errors extension members.
func MakeProblem ¶
MakeProblem builds the problem tmpl describes for r with the defaults a Mapper applies: about:blank as the type, the status text as the title, and the request path as the instance. It does not validate tmpl.
Example ¶
package main
import (
"fmt"
"log"
"net/http"
"net/http/httptest"
"github.com/uchaloop/httpx/problem"
)
func main() {
// A router's own 405 has a status and a header, but no code or detail.
r := httptest.NewRequest(http.MethodDelete, "/articles", nil)
mapped := problem.MakeProblem(r, problem.Template{
Status: http.StatusMethodNotAllowed,
Header: http.Header{"Allow": {"GET, POST"}},
})
recorder := httptest.NewRecorder()
if err := problem.Write(recorder, mapped); err != nil {
log.Fatal(err)
}
fmt.Println(recorder.Code, recorder.Header().Get("Allow"))
fmt.Println(recorder.Body.String())
}
Output: 405 GET, POST {"type":"about:blank","title":"Method Not Allowed","status":405,"instance":"/articles"}
type Rule ¶
type Rule struct {
// contains filtered or unexported fields
}
Rule maps a matching error to a public problem template.
Rules are created by WhenIs, WhenAs, or When. Mapper evaluates them in the order supplied to MakeMapper and uses the first match.
func ErrorRule ¶
func ErrorRule() Rule
ErrorRule maps errors created by MakeError. It is normally placed with the common transport rules before domain-specific rules.
func InputProblemRule ¶
func InputProblemRule() Rule
InputProblemRule maps errors produced by httpx request, page, and sortby packages to stable RFC 9457 templates. Error codes use go-playground/validator tag names where the constraint has one (min, max) and the enum tag for closed sets; a JSON value of the wrong type has code type. Invalid server-side page configuration remains an internal error.
Example ¶
package main
import (
"encoding/json"
"fmt"
"log"
"net/http"
"net/http/httptest"
"strings"
"github.com/uchaloop/httpx/page"
"github.com/uchaloop/httpx/problem"
"github.com/uchaloop/httpx/request"
)
type createArticle struct {
Title string `json:"title"`
}
func main() {
mapper, err := problem.MakeMapper(problem.InputProblemRule())
if err != nil {
log.Fatal(err)
}
show := func(mapped problem.Problem) {
body, err := json.Marshal(mapped)
if err != nil {
log.Fatal(err)
}
fmt.Println(string(body))
}
// A JSON value of the wrong type cannot be decoded: 400.
r := httptest.NewRequest(http.MethodPost, "/articles", strings.NewReader(`{"title":5}`))
r.Header.Set("Content-Type", "application/json")
_, err = request.DecodeJSON[createArticle](r)
show(mapper.Map(r, err))
// A page size over the maximum breaks a constraint: 422.
size := uint64(500)
r = httptest.NewRequest(http.MethodGet, "/articles?size=500", nil)
_, err = page.Make(page.Params{Size: &size}, page.Config{DefaultSize: 20, MaxSize: 100})
show(mapper.Map(r, err))
}
Output: {"type":"about:blank","title":"Bad Request","status":400,"detail":"Request body must contain one valid JSON document","instance":"/articles","code":"invalid_json","errors":[{"path":"title","code":"type","message":"title must be a string"}]} {"type":"about:blank","title":"Unprocessable Entity","status":422,"detail":"Request parameters are invalid","instance":"/articles","code":"invalid_request","errors":[{"path":"size","code":"max","message":"size must be 100 or less"}]}
func When ¶
When creates a rule for application-specific classification. The callback returns a template and true when it handled err.
func WhenAs ¶
WhenAs creates a rule using errors.AsType. makeTemplate receives the matched typed error and may derive safe public details from it.
Example ¶
package main
import (
"fmt"
"log"
"net/http"
"net/http/httptest"
"github.com/uchaloop/httpx/problem"
)
type quotaError struct {
limit int
}
func (e *quotaError) Error() string {
return fmt.Sprintf("quota of %d requests exceeded", e.limit)
}
func main() {
mapper, err := problem.MakeMapper(problem.WhenAs(func(quota *quotaError) problem.Template {
return problem.Template{
Status: http.StatusTooManyRequests,
Code: "quota_exceeded",
Detail: fmt.Sprintf("At most %d requests per minute are allowed", quota.limit),
Header: http.Header{"Retry-After": {"60"}},
}
}))
if err != nil {
log.Fatal(err)
}
r := httptest.NewRequest(http.MethodPost, "/articles", nil)
mapped := mapper.Map(r, fmt.Errorf("create article: %w", "aError{limit: 100}))
fmt.Println(mapped.Status, mapped.Code)
fmt.Println(mapped.Detail)
fmt.Println("Retry-After:", mapped.Header.Get("Retry-After"))
}
Output: 429 quota_exceeded At most 100 requests per minute are allowed Retry-After: 60
type Template ¶
type Template struct {
Type string
Title string
Status int
Detail string
Code string
Errors []InvalidParam
Header http.Header
}
Template describes the public parts of a problem before a request-specific instance is added by Mapper.
func InvalidRequest ¶
func InvalidRequest(status int, params ...InvalidParam) Template
InvalidRequest returns the standard invalid_request template, also used by framework adapters for their binding and validation errors. Status 400 means request values could not be decoded; 422 means decoded values violate a constraint.