DEV Community

Cover image for Returning RFC 9457 Problem Details from Go Validation Errors
Onur Cinar
Onur Cinar

Posted on

Returning RFC 9457 Problem Details from Go Validation Errors

How to turn struct-tag validation failures into a standard "application/problem+json" response — the RFC 9457 format — with one method call, no hand-rolled error envelope.

Most Go APIs invent their own validation error shape. One team returns {"errors": [...]}, another {"field_errors": {...}}, a third just a flat {"error": "message"} and hopes the client parses it. Every one of those is a private contract the client has to learn from your docs, because there's no shared shape for "here's what's wrong with your request."

RFC 9457Problem Details for HTTP APIs — is the IETF standard that fixes this: a application/problem+json body with type, title, status, and room for problem-specific extensions. Checker now builds one of these directly from a failed struct validation, via CheckErrors.ProblemDetails().

The shape

RFC 9457 defines four base members — type, title, status, detail, instance — and lets a specific problem type add its own. For validation errors, RFC 9457 §3.1 sketches exactly this extension: an invalid-params array listing which fields failed and why. That's what Checker produces.

From a failed struct to a problem+json body

Take a struct with a missing required field:

type Person struct {
    Name string `checkers:"required"`
}

person := &Person{}

errs, ok := checker.CheckStruct(person)
if !ok {
    data, _ := json.Marshal(errs.ProblemDetails())
    fmt.Println(string(data))
}
Enter fullscreen mode Exit fullscreen mode
{
  "type": "about:blank",
  "title": "Your request parameters failed validation.",
  "status": 400,
  "invalid-params": [
    { "name": "Name", "reason": "Required value is missing.", "code": "REQUIRED" }
  ]
}
Enter fullscreen mode Exit fullscreen mode

One method call — errs.ProblemDetails() — turns the same CheckErrors you'd otherwise call .JSON() on into a *ProblemDetails value, ready to marshal. type defaults to "about:blank" (RFC 9457's own default for "no more specific problem type registered"), status defaults to 400, and each invalid-params entry carries the field name, a localized human-readable reason, and the machine-readable code — the same code you'd branch on client-side with the plain .JSON() output.

Wiring it into a handler

The one thing RFC 9457 requires that a plain JSON body doesn't is the content type:

func signupHandler(w http.ResponseWriter, r *http.Request) {
    var req SignupRequest
    if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
        http.Error(w, "invalid JSON", http.StatusBadRequest)
        return
    }

    errs, valid := checker.CheckStruct(&req)
    if !valid {
        pd := errs.ProblemDetails()

        w.Header().Set("Content-Type", "application/problem+json")
        w.WriteHeader(pd.Status)
        json.NewEncoder(w).Encode(pd)
        return
    }

    w.WriteHeader(http.StatusCreated)
}
Enter fullscreen mode Exit fullscreen mode

pd.Status drives both the header and the body, so they can't drift out of sync.

Overriding the defaults

Type, Title, and Status are plain exported fields on the returned *ProblemDetails — RFC 9457 deliberately leaves them to the API producer, so Checker fills in reasonable defaults and gets out of your way. Set them before marshaling if you want a registered problem type or a different status code:

pd := errs.ProblemDetails()
pd.Type = "https://api.example.com/problems/validation-error"
pd.Title = "Signup request failed validation"
pd.Status = http.StatusUnprocessableEntity // 422 instead of 400
Enter fullscreen mode Exit fullscreen mode

Nothing else about the call changes — invalid-params is still built from the same CheckErrors map.

Localized problem details

Since ProblemDetails() is just ProblemDetailsWithLocale(DefaultLocale) under the hood, a localized variant is one argument away — same 23 locales that back JSONWithLocale:

pd := errs.ProblemDetailsWithLocale(locales.DeDE)
Enter fullscreen mode Exit fullscreen mode

The reason strings come back in German; type, status, and code are untouched, since those aren't meant for display.

Why bother with a standard shape

A hand-rolled error envelope works fine until you have more than one API, or a client library trying to handle errors generically, or an API gateway that wants to do something useful with 4xx bodies without special-casing your service. application/problem+json is already understood by tooling in several ecosystems — Spring, ASP.NET Core, and various API gateways emit or consume it natively — so producing it from Go costs you nothing extra (it's the same validation you were already running) and buys you a body shape other tools don't have to be taught.

Try it

go get github.com/cinar/checker/v2
Enter fullscreen mode Exit fullscreen mode
import checker "github.com/cinar/checker/v2"
Enter fullscreen mode Exit fullscreen mode

ProblemDetails() and ProblemDetailsWithLocale() sit right next to JSON() and JSONWithLocale() on CheckErrors — pick whichever body shape your API needs, from the same validation call. Full details in the README.

Top comments (0)