Go Module 18 – HTTP servers and clients with net/http
JSON APIs: decoding limits, validation and errors
Read JSON request bodies in Go through a size limit and one strict decode, validate them with allowlists and limits, and answer as RFC 9457 problem details.
What you will learn
- Decode a request body safely, with a media-type check, a size limit and one strict decode
- Validate decoded input with allowlists, ranges, anchored formats and length limits in characters
- Choose 400, 413, 415 or 422 for a refused body, and return it as RFC 9457 problem details
- Map decoder errors to messages about the client's JSON that reveal nothing about the code
- Write a validation test that fails on weak rules and passes on the fixed ones
Before you start
What you’ll learn · Before you start
What you will learn
- Decode a request body safely, with a media-type check, a size limit and one strict decode
- Validate decoded input with allowlists, ranges, anchored formats and length limits in characters
- Choose 400, 413, 415 or 422 for a refused body, and return it as RFC 9457 problem details
- Map decoder errors to messages about the client's JSON that reveal nothing about the code
- Write a validation test that fails on weak rules and passes on the fixed ones
Before you start
On this page
POST /orders answered 201 to a body of 70,000 bytes, to an order for 0 copies and to a field called quantitty: the practice run at the end shows it. Each was a different mistake that needed a different answer: 413 for the size, 422 for the value and 400 for the shape.
You will read a JSON body through a size limit and one strict decode, validate what it says with allowlists and limits, and answer every refusal as problem details: The JSON body for errors of HTTP APIs that RFC 9457 defines, served as application/problem+json, with members such as type, title, status, detail and instance. More in “Custom error types and error design for APIs”. Every program here ran on Go 1.27.1.
A body passes three gates before it becomes an order
A handler should not trust a body’s format, its size or its shape. decodeOrder in decode.go checks them in that
order. First the media type: The format of a body, such as application/json or application/problem+json, named in the Content-Type header. Parameters such as charset=utf-8 may follow it, so it is read with mime.ParseMediaType rather than compared as a whole.: mime.ParseMediaType reads the Content-Type header, so
application/json; charset=utf-8 counts as JSON and a form does not. Then the size: http.MaxBytesReader wraps the
body, and a read past 64 KB returns a *http.MaxBytesError. Then the shape: a decoder with DisallowUnknownFields
reads one order, and a second Decode must return io.EOF, so that trailing data: Bytes after the first complete JSON value of an input, such as a second object or a stray bracket. Unmarshal reports them as a syntax error; a Decoder never reads them unless it is called again. More in “JSON with encoding/json: tags, omitzero, streams” is
refused. Eight requests show where each one stops:
// decode.go: POST /orders reads its body through three gates, the media type, a 64 KB limit and one strict decode,
// and eight requests show the status each way of refusing a body gets.
package main
import (
"encoding/json"
"errors"
"fmt"
"io"
"mime"
"net/http"
"net/http/httptest"
"strings"
)
type Order struct {
Quantity int `json:"quantity"`
Pincode string `json:"pincode"`
Payment string `json:"payment"`
Name string `json:"name"`
}
const maxBody = 64 << 10 // 64 KB, far more than any order needs
// decodeOrder reads exactly one Order from the request, or returns the status and the message that refuse it.
func decodeOrder(w http.ResponseWriter, r *http.Request) (Order, int, string) {
mediaType, _, err := mime.ParseMediaType(r.Header.Get("Content-Type"))
if err != nil || mediaType != "application/json" {
return Order{}, http.StatusUnsupportedMediaType, "send the order as application/json"
}
r.Body = http.MaxBytesReader(w, r.Body, maxBody)
dec := json.NewDecoder(r.Body)
dec.DisallowUnknownFields()
var o Order
if err := dec.Decode(&o); err != nil {
if tooBig, ok := errors.AsType[*http.MaxBytesError](err); ok {
return Order{}, http.StatusRequestEntityTooLarge, fmt.Sprintf("the body is over %d bytes", tooBig.Limit)
}
return Order{}, http.StatusBadRequest, "the body is not one JSON order"
}
if err := dec.Decode(&struct{}{}); err != io.EOF {
return Order{}, http.StatusBadRequest, "the body holds more than one JSON value"
}
return o, 0, ""
}
func createOrder(w http.ResponseWriter, r *http.Request) {
o, status, msg := decodeOrder(w, r)
if status != 0 {
http.Error(w, msg, status)
return
}
w.WriteHeader(http.StatusCreated)
fmt.Fprintf(w, "order for %d copies to %s, paid by %s, for %s\n", o.Quantity, o.Pincode, o.Payment, o.Name)
}
func main() {
good := `{"quantity":2,"pincode":"560001","payment":"card","name":"Asha"}`
requests := []struct{ what, contentType, body string }{
{"a valid order", "application/json", good},
{"the same, with a charset", "application/json; charset=utf-8", good},
{"sent as a form", "application/x-www-form-urlencoded", "quantity=2"},
{"70,000 bytes", "application/json", `{"name":"` + strings.Repeat("a", 70000) + `"}`},
{"a misspelt field", "application/json", `{"quantitty":2,"pincode":"560001","payment":"card","name":"Asha"}`},
{"a string for a number", "application/json", `{"quantity":"two"}`},
{"two orders in one body", "application/json", good + good},
{"an empty body", "application/json", ""},
}
mux := http.NewServeMux()
mux.HandleFunc("POST /orders", createOrder)
for i, rq := range requests {
req := httptest.NewRequest("POST", "/orders", strings.NewReader(rq.body))
req.Header.Set("Content-Type", rq.contentType)
rec := httptest.NewRecorder()
mux.ServeHTTP(rec, req)
fmt.Printf("%d. %s: %d %s\n", i+1, rq.what, rec.Code, strings.TrimSpace(rec.Body.String()))
}
} Output
1. a valid order: 201 order for 2 copies to 560001, paid by card, for Asha 2. the same, with a charset: 201 order for 2 copies to 560001, paid by card, for Asha 3. sent as a form: 415 send the order as application/json 4. 70,000 bytes: 413 the body is over 65536 bytes 5. a misspelt field: 400 the body is not one JSON order 6. a string for a number: 400 the body is not one JSON order 7. two orders in one body: 400 the body holds more than one JSON value 8. an empty body: 400 the body is not one JSON order
Recorded with Go 1.27.1 on macOS 26 arm64. To run it yourself: go run decode.go
The two valid orders were created. The form got 415 before any JSON was read; 70,000 bytes got 413 with the limit, 65,536 bytes; the misspelt field, the string for a number, the second order and the empty body each got 400. Step through the gates with four of these requests, and one more that decodes perfectly and is still wrong:
The drawing shows the whole scene; its 6 steps are listed below.
Every step as text
- A valid order. The order for 2 copies to 560001, paid by card, has the media type application/json, fits in 64 KB, decodes as exactly one order and keeps every rule, so it is created with 201.
- A form. A body sent as application/x-www-form-urlencoded fails the first gate. The decoder never runs, and the reply is 415 Unsupported Media Type.
- 70,000 bytes. MaxBytesReader lets the decoder read 65,536 bytes and then returns a *http.MaxBytesError. The handler recognises that error and answers 413 Content Too Large.
- A misspelt field. quantitty matches no field of Order, and DisallowUnknownFields turns that into an error. Two orders in one body end here too, because the second Decode finds data instead of io.EOF.
- Zero copies. A body with quantity 0 is well-formed JSON with the right fields, so it passes every gate of the decoder. The rules refuse it, since quantity must be from 1 to 20, and the reply is 422.
- Each refusal names its cause. Each gate refuses a different mistake with its own status, so a client can tell a body it must reformat from one whose values it must change.
Checkpoint
Check: Media type, size limit and one strict decode
Validation decides whether a decoded order makes sense
{"quantity":0} decodes without an error, so decoding cannot be the last check. Validation compares each value with
the rules of the business, on the server, whatever the client checked. OWASP’s Input Validation Cheat Sheet asks for
rules that define what the application accepts and refuse everything else, rather than lists of known bad values.
validate.go writes four such rules and collects every one an order breaks:
| Field | Kind of rule | The rule in validate.go |
|---|---|---|
| payment | allowlist: A validation rule that lists the values a field may hold and refuses everything else. Unlike a denylist, which names what is refused, it stays safe when a value nobody thought of arrives. | one of card, cod, netbanking and upi |
| quantity | range, both ends | from 1 to 20 |
| pincode | format of the whole value | six digits, the first one not 0 |
| name | length in characters | 1 to 80, counted with utf8.RuneCountInString |
// validate.go: the rules an order must meet once it has decoded, written as an allowlist, a range, a format and a
// length, with every broken rule collected so that one reply can name them all.
package main
import (
"fmt"
"strings"
"unicode/utf8"
)
type Order struct {
Quantity int `json:"quantity"`
Pincode string `json:"pincode"`
Payment string `json:"payment"`
Name string `json:"name"`
}
// FieldError names one rule an order broke: the field, as a JSON Pointer into the body, and what it must be.
type FieldError struct {
Pointer string `json:"pointer"`
Detail string `json:"detail"`
}
// payments is an allowlist: a mode the shop accepts is listed, and everything else is refused.
var payments = map[string]bool{"card": true, "cod": true, "netbanking": true, "upi": true}
func validate(o Order) []FieldError {
var errs []FieldError
if o.Quantity < 1 || o.Quantity > 20 {
errs = append(errs, FieldError{"/quantity", "must be from 1 to 20"})
}
if !isPincode(o.Pincode) {
errs = append(errs, FieldError{"/pincode", "must be six digits, the first one not 0"})
}
if !payments[o.Payment] {
errs = append(errs, FieldError{"/payment", "must be one of card, cod, netbanking, upi"})
}
if n := utf8.RuneCountInString(o.Name); n < 1 || n > 80 { // the name as it will be stored, counted in characters
errs = append(errs, FieldError{"/name", "must have 1 to 80 characters"})
}
return errs
}
// isPincode accepts exactly six ASCII digits whose first digit is not 0.
func isPincode(s string) bool {
if len(s) != 6 || s[0] == '0' {
return false
}
for i := 0; i < len(s); i++ {
if s[i] < '0' || s[i] > '9' {
return false
}
}
return true
}
func main() {
orders := []Order{
{2, "560001", "card", "Asha"},
{2, "560001", "card", "अनन्या शर्मा"},
{0, "560001", "card", "Asha"},
{25, "056001", "wallet", ""},
{3, "5600011", "Card", strings.Repeat("x", 81)},
}
for i, o := range orders {
errs := validate(o)
if len(errs) == 0 {
fmt.Printf("%d. valid: %d for %q (%d runes, %d bytes)\n", i+1, o.Quantity, o.Name,
utf8.RuneCountInString(o.Name), len(o.Name))
continue
}
fmt.Printf("%d. refused, rules broken: %d\n", i+1, len(errs))
for _, e := range errs {
fmt.Printf(" %s %s\n", e.Pointer, e.Detail)
}
}
} Output
1. valid: 2 for "Asha" (4 runes, 4 bytes)
2. valid: 2 for "अनन्या शर्मा" (12 runes, 34 bytes)
3. refused, rules broken: 1
/quantity must be from 1 to 20
4. refused, rules broken: 4
/quantity must be from 1 to 20
/pincode must be six digits, the first one not 0
/payment must be one of card, cod, netbanking, upi
/name must have 1 to 80 characters
5. refused, rules broken: 3
/pincode must be six digits, the first one not 0
/payment must be one of card, cod, netbanking, upi
/name must have 1 to 80 characters
Recorded with Go 1.27.1 on macOS 26 arm64. To run it yourself: go run validate.go
The name in Devanagari passed with 12 characters, though it takes 34 bytes. The fourth order broke all four rules,
and validate named every one of them, each by a JSON Pointer: A path to one value inside a JSON document, defined by RFC 6901: member names and array indexes separated by slashes, such as /orders/1/paise for the paise of the second order. encoding/json/v2 names places in its errors this way. More in “JSON v2 in Go 1.27: encoding/json/v2 and jsontext” such as /quantity, so
that one reply can list all of them. An order that breaks a rule is answered with 422 Unprocessable Content: RFC 9110
uses it when the server understands the content but cannot process what it asks for.
Checkpoint
Check: Validation of the decoded values
Problem details tell the client what to fix
Every refusal needs a body a program can read. RFC 9457 defines one, served as application/problem+json: type, a
URI naming the kind of problem, title, a summary of that kind, status, the reply’s code, and detail, about this
occurrence. Extra members are allowed, and the RFC’s own example of a validation failure lists each invalid field
with its pointer. problem.go writes three replies:
// problem.go: every error of POST /orders written as RFC 9457 problem details, application/problem+json with type,
// title, status and detail, and an errors member that points at each broken field. A 500 says nothing about the cause.
package main
import (
"encoding/json"
"errors"
"fmt"
"log"
"net/http"
"net/http/httptest"
"os"
"strings"
)
type FieldError struct {
Pointer string `json:"pointer"`
Detail string `json:"detail"`
}
// Problem is the body of every error reply. Type is a URI that names the kind of problem, the same for every
// occurrence; Detail is about this occurrence.
type Problem struct {
Type string `json:"type"`
Title string `json:"title"`
Status int `json:"status"`
Detail string `json:"detail,omitempty"`
Errors []FieldError `json:"errors,omitempty"`
}
func writeProblem(w http.ResponseWriter, p Problem) {
w.Header().Set("Content-Type", "application/problem+json")
w.Header().Set("X-Content-Type-Options", "nosniff")
w.WriteHeader(p.Status) // the same status as in the body, as RFC 9457 requires
enc := json.NewEncoder(w)
enc.SetIndent("", " ")
enc.Encode(p)
}
var errStore = errors.New(`pq: relation "orders_v2" does not exist`) // a failure the client must never see
var logger = log.New(os.Stdout, " log: ", 0)
func createOrder(w http.ResponseWriter, r *http.Request) {
switch r.URL.Query().Get("case") {
case "invalid":
writeProblem(w, Problem{
Type: "https://library.test/problems/invalid-order",
Title: "The order is not valid",
Status: http.StatusUnprocessableEntity,
Detail: "2 fields break the rules for an order.",
Errors: []FieldError{
{"/quantity", "must be from 1 to 20"},
{"/payment", "must be one of card, cod, netbanking, upi"},
},
})
case "form":
writeProblem(w, Problem{
Type: "https://library.test/problems/media-type",
Title: "The body must be JSON",
Status: http.StatusUnsupportedMediaType,
Detail: "Send the order with Content-Type: application/json.",
})
default:
// The cause goes to the log, with what a person needs to find it; the reply gets a generic problem.
logger.Printf("POST /orders: saving the order: %v", errStore)
writeProblem(w, Problem{
Type: "about:blank",
Title: "Internal Server Error",
Status: http.StatusInternalServerError,
})
}
}
func main() {
for _, c := range []string{"invalid", "form", "store-down"} {
rec := httptest.NewRecorder()
createOrder(rec, httptest.NewRequest("POST", "/orders?case="+c, nil))
fmt.Printf("%s: %d, Content-Type %s\n", c, rec.Code, rec.Header().Get("Content-Type"))
fmt.Print(rec.Body.String())
if strings.Contains(rec.Body.String(), "orders_v2") {
fmt.Println(" check, the reply names no table: FAIL")
} else {
fmt.Println(" check, the reply names no table: pass")
}
}
} Output
invalid: 422, Content-Type application/problem+json
{
"type": "https://library.test/problems/invalid-order",
"title": "The order is not valid",
"status": 422,
"detail": "2 fields break the rules for an order.",
"errors": [
{
"pointer": "/quantity",
"detail": "must be from 1 to 20"
},
{
"pointer": "/payment",
"detail": "must be one of card, cod, netbanking, upi"
}
]
}
check, the reply names no table: pass
form: 415, Content-Type application/problem+json
{
"type": "https://library.test/problems/media-type",
"title": "The body must be JSON",
"status": 415,
"detail": "Send the order with Content-Type: application/json."
}
check, the reply names no table: pass
log: POST /orders: saving the order: pq: relation "orders_v2" does not exist
store-down: 500, Content-Type application/problem+json
{
"type": "about:blank",
"title": "Internal Server Error",
"status": 500
}
check, the reply names no table: pass
Recorded with Go 1.27.1 on macOS 26 arm64. To run it yourself: go run problem.go
The 422 named both broken fields. The 500 said nothing beyond its title, while the log line kept the cause, a missing
database table, and the check that the reply names no table passed. Clients switch on type, never on the wording
of title or detail.
Key idea
Refuse a body by what is wrong with it: 415 for the media type, 413 for the size, 400 for the shape and 422 for a value, each as problem details that point at the field to fix.
Checkpoint
Check: Problem details for every error
Three more things harden this endpoint. Decoder errors become messages about the client’s JSON, never about Go’s types. encoding/json/v2 refuses three bodies that encoding/json accepts. A test of known orders catches a weak rule.
Malformed bodies, the decoder's own errors and the stricter v2 decoder Pro: opens with a Pro, Premium or Ultimate pass
A strict decoder still accepts more than you might think, and its error texts were written for Go programmers, not for your clients. This part sends seventeen malformed bodies through it, maps each failure to a status and a safe message, and finds the three that only encoding/json/v2 refuses.
In this part:
- Seventeen malformed bodies, mapped to one status each
- Sizing the body limit, with worked numbers
- Three bodies that encoding/json accepts and encoding/json/v2 refuses
1 runnable example (Go) · about 6 minutes
Opening this part with your pass…
This part opens with any MySmartCoPilot pass.
- Pro pass · 1 week₹79$2.99
- 30 days₹179$5.99
- 1 year₹1,699$45
One payment; a pass never renews. The price shown is the total you pay: no fees or charges are added at checkout.
Passes can’t be bought right now, so this part can’t be opened at the moment. The free part above stands on its own. If you have a pass, enter your licence key.
It also opens every other in-depth part in Learn and every Pro tool. Sales are final, except where the law gives you a right to withdraw, for example in the EU and the UK (refund policy).
Validation rules that hold, checked against the weak ones Pro: opens with a Pro, Premium or Ultimate pass
A validation rule that looks right can accept bad values and refuse good ones at the same time. This part runs a weak validator and the fixed one against twelve orders whose verdicts are known, and uses the difference as the test that keeps the rules honest.
In this part:
- Four slips in validation rules, and a check that catches them
- A status for every refusal, and types a client can rely on
1 runnable example (Go) · about 5 minutes
Opening this part with your pass…
This part opens with any MySmartCoPilot pass.
- Pro pass · 1 week₹79$2.99
- 30 days₹179$5.99
- 1 year₹1,699$45
One payment; a pass never renews. The price shown is the total you pay: no fees or charges are added at checkout.
Passes can’t be bought right now, so this part can’t be opened at the moment. The free part above stands on its own. If you have a pass, enter your licence key.
It also opens every other in-depth part in Learn and every Pro tool. Sales are final, except where the law gives you a right to withdraw, for example in the EU and the UK (refund policy).
Practise: finish POST /orders
practice/orders.go has CreateOrder, which decodes leniently, ignores the error and answers 201 to everything. practice/orders_test.go sends it six bodies and an order that breaks two rules:
Go (orders.go) · practice/orders.go
// Package practice holds POST /orders to finish: strict decoding, validation and problem details.
package practice
import (
"encoding/json"
"net/http"
)
type Order struct {
Quantity int `json:"quantity"`
Pincode string `json:"pincode"`
Payment string `json:"payment"`
Name string `json:"name"`
}
type FieldError struct {
Pointer string `json:"pointer"`
Detail string `json:"detail"`
}
// Problem is the application/problem+json body of every error reply.
type Problem struct {
Type string `json:"type"`
Title string `json:"title"`
Status int `json:"status"`
Detail string `json:"detail,omitempty"`
Errors []FieldError `json:"errors,omitempty"`
}
// CreateOrder answers 201 for a valid order. Every refusal is a Problem: 415 for a body that is not
// application/json, 413 for one over 64 KB, 400 for one that is not exactly one order object, and 422, with an
// errors member, for an order that breaks the rules of validate.go in the lesson.
func CreateOrder(w http.ResponseWriter, r *http.Request) {
var o Order
json.NewDecoder(r.Body).Decode(&o) // accepts anything, and ignores the error
w.WriteHeader(http.StatusCreated)
} Go (orders_test.go) · practice/orders_test.go
package practice
import (
"encoding/json"
"net/http/httptest"
"slices"
"strings"
"testing"
)
const good = `{"quantity":2,"pincode":"560001","payment":"card","name":"Asha"}`
func post(contentType, body string) *httptest.ResponseRecorder {
req := httptest.NewRequest("POST", "/orders", strings.NewReader(body))
req.Header.Set("Content-Type", contentType)
rec := httptest.NewRecorder()
CreateOrder(rec, req)
return rec
}
func TestCreateOrder(t *testing.T) {
tests := []struct {
what, contentType, body string
code int
}{
{"a valid order", "application/json", good, 201},
{"a form", "application/x-www-form-urlencoded", "quantity=2", 415},
{"70,000 bytes", "application/json", `{"name":"` + strings.Repeat("a", 70000) + `"}`, 413},
{"a misspelt field", "application/json", `{"quantitty":2,"pincode":"560001","payment":"card","name":"Asha"}`, 400},
{"two orders", "application/json", good + good, 400},
{"zero copies", "application/json", `{"quantity":0,"pincode":"560001","payment":"card","name":"Asha"}`, 422},
}
for _, tt := range tests {
rec := post(tt.contentType, tt.body)
if rec.Code != tt.code {
t.Errorf("%s: status %d, want %d", tt.what, rec.Code, tt.code)
continue
}
if tt.code == 201 {
continue
}
var p Problem
if ct := rec.Header().Get("Content-Type"); ct != "application/problem+json" {
t.Errorf("%s: Content-Type %q, want application/problem+json", tt.what, ct)
} else if err := json.Unmarshal(rec.Body.Bytes(), &p); err != nil || p.Status != tt.code || p.Title == "" {
t.Errorf("%s: body %q is not a problem with status %d and a title", tt.what, rec.Body.String(), tt.code)
}
}
}
func TestInvalidOrderNamesEveryField(t *testing.T) {
rec := post("application/json", `{"quantity":25,"pincode":"560001","payment":"wallet","name":"Asha"}`)
var p Problem
json.Unmarshal(rec.Body.Bytes(), &p)
var pointers []string
for _, e := range p.Errors {
pointers = append(pointers, e.Pointer)
}
if rec.Code != 422 || !slices.Equal(pointers, []string{"/quantity", "/payment"}) {
t.Errorf("status %d with errors at %q, want 422 with errors at /quantity and /payment", rec.Code, pointers)
}
} from session import run
# The starter CreateOrder against its tests: it decodes leniently, ignores the error and answers 201 to everything.
run(
"""
cd practice && go test .
""",
expect_failure=("cd practice && go test .",),
) Output
$ cd practice && go test .
--- FAIL: TestCreateOrder (0.00s)
orders_test.go:36: a form: status 201, want 415
orders_test.go:36: 70,000 bytes: status 201, want 413
orders_test.go:36: a misspelt field: status 201, want 400
orders_test.go:36: two orders: status 201, want 400
orders_test.go:36: zero copies: status 201, want 422
--- FAIL: TestInvalidOrderNamesEveryField (0.00s)
orders_test.go:60: status 201 with errors at [], want 422 with errors at /quantity and /payment
FAIL
FAIL example.com/practice 1.141s
FAIL
[exit status 1]
This output changes from run to run: go test prints how long each test and the whole run took, which differs from one run to the next.
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 practice_session.py
Make go test . pass in the practice folder. A first hint: build it from decodeOrder in decode.go, validate in
validate.go and writeProblem in problem.go. Then the questions on gates, validation and problem details:
Practice that adapts to you
Practise this lesson
6 questions about 5 ideas of this lesson. Each answer is explained at once, and what you get right is kept in this browser.
Interview questions
Warm-up (fresher): When should an API answer 400, and when 422?
400 Bad Request when the body cannot be read as the request it should be: invalid JSON, the wrong shape, an unknown field or more than one value. 422 Unprocessable Content when the body is well-formed and understood, but a value breaks a rule, such as a quantity of 0. The split tells a client whether to fix its encoding or its data.
Three longer questions of the module bank build on this lesson:
-
Mid to senior
A partner's batch job sends POST /orders bodies of 200 MB by mistake, and the service's memory climbs until the process is killed. The handler decodes with json.NewDecoder(r.Body). What do you change, how do you choose the numbers, and what does the partner see afterwards?
-
Senior to staff
A validating gateway written in another language refuses orders above 20 copies, then passes them to a Go service that decodes with encoding/json. An audit finds orders for 500 copies; every one of those bodies holds the quantity field twice. Explain how they got through, and fix it.
-
Senior to staff
Design the error format of a public orders API used by web and mobile apps in four languages. Which statuses do you use, what does each body contain, how does an app find the field to highlight, and how do you change the format later without breaking old app versions?
Key takeaways
- Check the media type with mime.ParseMediaType, limit the body with http.MaxBytesReader, and decode exactly one value with unknown fields refused.
- Validate on the server after decoding, with allowlists, ranges with both ends, whole-value formats and lengths in characters, collecting every broken rule.
- Answer each refusal with its own status and an application/problem+json body whose type a client can switch on, and keep the cause of every 500 in the log.
Terms in this lesson
- allowlist
- A validation rule that lists the values a field may hold and refuses everything else. Unlike a denylist, which names what is refused, it stays safe when a value nobody thought of arrives.
- media type
- The format of a body, such as application/json or application/problem+json, named in the Content-Type header. Parameters such as charset=utf-8 may follow it, so it is read with mime.ParseMediaType rather than compared as a whole.
References
- Package net/http: MaxBytesReader and MaxBytesError (The Go Authors)
- Package encoding/json: Decoder.DisallowUnknownFields (The Go Authors)
- Package encoding/json/v2 (The Go Authors)
- Package mime: ParseMediaType (The Go Authors)
- RFC 9457: Problem Details for HTTP APIs (IETF)
- RFC 9110: HTTP Semantics (IETF)
- Go 1.27 Release Notes: encoding/json backed by the v2 implementation (The Go Authors)
- RFC 6901: JavaScript Object Notation (JSON) Pointer (IETF)
- Input Validation Cheat Sheet (OWASP Foundation)
Related tools
Report a problem with this lesson
Kept only in this browser. Your Learn progress