Package rfc9457

import "funnyhaha.technology/rfc/rfc9457"
View the source code

Overview

Package rfc9457 builds and reads problem details, defined by RFC 9457 (Problem Details for HTTP APIs): a JSON body, with the media type application/problem+json, that explains why a request failed.

Servers

A Problem starts from an rfc9110.Status, whose code it uses for the response and whose header fields it sends, so a 401 problem carries its WWW-Authenticate challenge:

rfc9457.New(rfc9110.Unauthorized(challenge)).
	WithDetail("The access token has expired.").
	Write(w)

Declare each problem type your API uses once, as you would a sentinel error, and add it with Problem.WithType:

var outOfCredit = rfc9457.MustType("https://example.com/probs/out-of-credit", "You do not have enough credit.")

rfc9457.New(rfc9110.Forbidden()).WithType(outOfCredit)

A problem without a type is "about:blank", and its title is the status code's reason phrase (Section 4.2.1).

Clients

Decode reads a problem from a response. Problem implements error, and Problem.HasType maps it back to the problem types you know:

p, err := rfc9457.Decode(resp)
if err == nil && p.HasType(outOfCredit) {
	return ErrOutOfCredit
}

The package doesn't cover the XML format of Appendix B.

The RFC text is in rfc9457.txt in this directory, and the doc comments cite its sections.

Index

Constants

const MediaType = "application/problem+json"
source

MediaType is the media type of a problem details JSON object (Section 3).

Variables

var (
	// ErrInvalidType is returned by [NewType] for a URI that's empty or
	// isn't a URI reference.
	ErrInvalidType = errors.New("invalid problem type")

	// ErrInvalidExtensionName is returned by [ParseExtensionName] for a
	// name that doesn't follow Section 4, or is a member this package
	// defines.
	ErrInvalidExtensionName = errors.New("invalid extension name")

	// ErrInvalidStatus is returned by [Problem.Write] for a Problem that
	// wasn't built by [New] or [Decode], so has no status code to send.
	ErrInvalidStatus = errors.New("invalid status")

	// ErrNotProblem is returned by [Decode] for a response whose media type
	// isn't application/problem+json.
	ErrNotProblem = errors.New("not a problem details response")

	// ErrTooLarge is returned by [Decode] for a body over 1 MiB.
	ErrTooLarge = errors.New("problem details too large")
)
source

Types

ExtensionName source

type ExtensionName string

ExtensionName is the name of an extension member. Use ParseExtensionName to check a string.

See RFC 9457, Section 3.2.

ParseExtensionName source

func ParseExtensionName(s string) (ExtensionName, error)

ParseExtensionName returns s as an ExtensionName. Following Section 4, it must start with a letter, contain only letters, digits and "_", and be at least three characters long. It mustn't be a standard member (Section 3.1) or "errors", which Problem.WithErrors sets. Otherwise it returns ErrInvalidExtensionName.

FieldError source

type FieldError struct {
	Detail  string
	Pointer rfc6901.Pointer
}

FieldError describes one problem with a request's content, such as an invalid field. It's an entry in a problem's "errors" member, which follows the example in Section 3. Its pointer is sent in URI fragment form, such as "#/age".

(FieldError) MarshalJSON source

func (e FieldError) MarshalJSON() ([]byte, error)

MarshalJSON returns the field error as an object with "detail" and "pointer" members.

(FieldError) UnmarshalJSON source

func (e *FieldError) UnmarshalJSON(data []byte) error

UnmarshalJSON reads a field error. It accepts a pointer in either URI fragment form ("#/age") or string form ("/age").

Problem source

type Problem struct {
	// contains filtered or unexported fields
}

Problem is a problem details object. Build one with New, or read one with Decode. The zero Problem has no status, so Problem.Write rejects it.

Problem is immutable: its With methods return a modified copy. It implements error, so a client can return the problem it received.

See RFC 9457, Section 3.

Decode source

func Decode(resp *http.Response) (Problem, error)

Decode reads a problem from a response, without closing its body. It returns ErrNotProblem if the media type isn't application/problem+json, and ErrTooLarge if the body is over 1 MiB. If the body has no "status" member, the problem takes the response's status code.

Example

ExampleDecode shows a server writing a problem and a client decoding it, reading an extension, and mapping its type back to a sentinel error.

package main

import (
	"errors"
	"fmt"
	"net/http"
	"net/http/httptest"

	"funnyhaha.technology/rfc/rfc9110"
	"funnyhaha.technology/rfc/rfc9457"
)

var errOutOfCredit = errors.New("out of credit")

func main() {
	outOfCredit := rfc9457.MustType("https://example.com/probs/out-of-credit", "You do not have enough credit.")
	balance, err := rfc9457.ParseExtensionName("balance")
	if err != nil {
		panic(err)
	}

	srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
		_ = rfc9457.New(rfc9110.Forbidden()).
			WithType(outOfCredit).
			WithDetail("Your current balance is 30, but that costs 50.").
			WithExtension(balance, 30).
			Write(w)
	}))
	defer srv.Close()

	resp, err := http.Get(srv.URL) //nolint:noctx // An example, with no context to pass.
	if err != nil {
		panic(err)
	}
	defer resp.Body.Close()

	p, err := rfc9457.Decode(resp)
	if err != nil {
		panic(err)
	}
	var amount int
	_, err = p.Extension("balance", &amount)
	if err != nil {
		panic(err)
	}

	err = p
	if p.HasType(outOfCredit) {
		err = fmt.Errorf("%w: balance %d", errOutOfCredit, amount)
	}
	fmt.Println(errors.Is(err, errOutOfCredit), err)
}

Output:

true out of credit: balance 30

New source

func New(s rfc9110.Status) Problem

New returns an "about:blank" problem for the status. It sends the status's code and header fields, and its title is the code's reason phrase, as Section 4.2.1 recommends.

(Problem) Detail source

func (p Problem) Detail() string

Detail returns the explanation specific to this occurrence.

(Problem) Error source

func (p Problem) Error() string

Error returns the status code, title and detail, such as "403 You do not have enough credit.: Your current balance is 30".

(Problem) Errors source

func (p Problem) Errors() []FieldError

Errors returns a copy of the problem's field errors.

(Problem) Extension source

func (p Problem) Extension(name string, v any) (bool, error)

Extension unmarshals the extension member with the name into v, as json.Unmarshal does. It reports false if p has no such member.

(Problem) HasType source

func (p Problem) HasType(t Type) bool

HasType reports whether p is a problem of type t.

(Problem) Instance source

func (p Problem) Instance() string

Instance returns the URI reference of this occurrence.

(Problem) MarshalJSON source

func (p Problem) MarshalJSON() ([]byte, error)

MarshalJSON returns p as a JSON object. It leaves out empty members, and "type" when it's "about:blank" (Section 3.1.1), and writes extension members after the standard ones, sorted by name.

(Problem) Status source

func (p Problem) Status() int

Status returns the problem's status code.

(Problem) Title source

func (p Problem) Title() string

Title returns the problem's title.

(Problem) Type source

func (p Problem) Type() string

Type returns the problem type's URI, which is "about:blank" if it has none.

(Problem) UnmarshalJSON source

func (p *Problem) UnmarshalJSON(data []byte) error

UnmarshalJSON reads a problem details object. A standard member with the wrong JSON type is ignored, as Section 3.1 requires, and every other member is kept as an extension.

(Problem) WithDetail source

func (p Problem) WithDetail(detail string) Problem

WithDetail returns a copy of p with an explanation specific to this occurrence of the problem. It should help the client correct the problem, rather than give debugging information (Section 3.1.4).

(Problem) WithErrors source

func (p Problem) WithErrors(errs ...FieldError) Problem

WithErrors returns a copy of p with the field errors added to its "errors" member.

Example

ExampleProblem_WithErrors shows the validation-error example of RFC 9457, Section 3, with a JSON Pointer to each invalid field.

package main

import (
	"fmt"

	"funnyhaha.technology/rfc/rfc6901"
	"funnyhaha.technology/rfc/rfc9110"
	"funnyhaha.technology/rfc/rfc9457"
)

func main() {
	validation := rfc9457.MustType("https://example.net/validation-error", "Your request is not valid.")

	p := rfc9457.New(rfc9110.UnprocessableContent()).
		WithType(validation).
		WithErrors(
			rfc9457.FieldError{Detail: "must be a positive integer", Pointer: rfc6901.New("age")},
			rfc9457.FieldError{Detail: "must be 'green', 'red' or 'blue'", Pointer: rfc6901.New("profile", "color")},
		)

	body, err := p.MarshalJSON()
	if err != nil {
		panic(err)
	}
	fmt.Println(string(body))
}

Output:

{"type":"https://example.net/validation-error","title":"Your request is not valid.","status":422,"errors":[{"detail":"must be a positive integer","pointer":"#/age"},{"detail":"must be 'green', 'red' or 'blue'","pointer":"#/profile/color"}]}

(Problem) WithExtension source

func (p Problem) WithExtension(name ExtensionName, value any) Problem

WithExtension returns a copy of p with an extension member, replacing any with the same name. The value is marshalled when p is written, so Problem.Write and Problem.MarshalJSON return an error if it can't be.

(Problem) WithInstance source

func (p Problem) WithInstance(instance string) Problem

WithInstance returns a copy of p with a URI reference that identifies this occurrence of the problem (Section 3.1.5).

(Problem) WithType source

func (p Problem) WithType(t Type) Problem

WithType returns a copy of p with the problem type, and the type's title. The zero Type sets "about:blank" and the status code's reason phrase.

(Problem) Write source

func (p Problem) Write(w http.ResponseWriter) error

Write sends p as the response: its status's header fields, a Content-Type of application/problem+json, the status code and the JSON body. If p can't be marshalled, Write returns an error before writing anything, so the caller can still send another response.

Example

ExampleProblem_Write shows a 401 problem that carries an RFC 6750 Bearer challenge, so the client gets both the challenge and a JSON explanation.

package main

import (
	"fmt"
	"net/http/httptest"

	"funnyhaha.technology/rfc/rfc6750"
	"funnyhaha.technology/rfc/rfc9457"
)

func main() {
	bearer := rfc6750.MustChallenge("api")

	rec := httptest.NewRecorder()
	err := rfc9457.New(rfc6750.InvalidToken(bearer, "The access token expired")).
		WithDetail("Sign in again to get a new token.").
		Write(rec)
	if err != nil {
		panic(err)
	}

	fmt.Println(rec.Code)
	fmt.Println(rec.Header().Get("Content-Type"))
	fmt.Println(rec.Header().Get("WWW-Authenticate"))
	fmt.Println(rec.Body)
}

Output:

401
application/problem+json
Bearer realm="api", error="invalid_token", error_description="The access token expired"
{"title":"Unauthorized","status":401,"detail":"Sign in again to get a new token."}

Type source

type Type struct {
	// contains filtered or unexported fields
}

Type is a problem type: a URI that identifies it, and a title that summarises it. The zero Type is "about:blank", which means the problem has no semantics beyond its status code.

Declare each type once, with MustType for a package-level variable or NewType, as you would a sentinel error.

See RFC 9457, Sections 3.1.1 and 4.

MustType source

func MustType(uri, title string) Type

MustType is like NewType but panics if uri isn't valid. Use it to declare a package-level problem type.

NewType source

func NewType(uri, title string) (Type, error)

NewType returns a problem type. The uri should be absolute (Section 3.1.1). It returns ErrInvalidType if uri is empty or isn't a URI reference.

(Type) Title source

func (t Type) Title() string

Title returns the type's title.

(Type) URI source

func (t Type) URI() string

URI returns the type's URI.