Your country

Tools that support it use your country for local currency, number formats, units and paper size. Your choice is saved only in this browser.

Type a name or a two-letter code. Use the up and down arrow keys to move through the countries, Enter to choose one and Escape to close.

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.

  • Intermediate
  • 20 minutes
  • Examples run with Go 1.27.1 and Python 3.14.8
  • By MySmartCoPilot
Free core, Pro depth: 7 of 20 minutes free
Free: A body passes three gates before it becomes an order · Validation decides whether a decoded order makes sense · Problem details tell the client what to fix. With a pass: 2 in-depth parts and 3 interview answers, about 13 minutes.

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: eight requests through three gates Go · decode.go
// 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:

Interactive · step by step Five requests through the gates of POST /orders Step through the checks a body passes before it becomes an order, and where each refused request leaves.
Five requests through the gates of POST /orders, step 6 of 6. Showing Is it application/json?, Read at most 64 KB, Decode one order, Check the order's rules, Create the order, 415, 413, 400, 422. In focus: 415, 413, 400, 422.Is it application/json?mime.ParseMediaTypeRead at most 64 KBhttp.MaxBytesReaderDecode one orderunknown fields refusedCheck the order's rulesquantity, pincode, paymentCreate the order201 Created415Unsupported Media Type413Content Too Large400Bad Request422Unprocessable Content

The drawing shows the whole scene; its 6 steps are listed below.

Every step as text
  1. 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.
  2. 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.
  3. 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.
  4. 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.
  5. 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.
  6. 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

  1. Question 1 of 2 How does decodeOrder know that a body was too large, so that it can answer 413 rather than 400?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: The decoder's error is a *http.MaxBytesError, which MaxBytesReader returns once the limit is passed

    http.MaxBytesReader wraps the body and returns an error of type *http.MaxBytesError for a read past the limit. Checking for that type separates "too large" from every other decoding failure, and the error's Limit field gives the number to put in the message.

  2. Question 2 of 2 A body holds two orders, one after the other. Why does decodeOrder refuse it, when one call of Decode succeeds?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: Decode reads only the first value, so a second Decode must return io.EOF, and here it finds the second order

    A json.Decoder is made for streams, so one Decode stops after the first value. A handler that expects exactly one value has to ask for a second and require io.EOF, as the json lesson of this track showed.

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: four rules, five orders Go · validate.go
// 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

  1. Question 1 of 2 Why does validate.go keep the payment modes in an allowlist rather than refusing the modes it knows to be wrong?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: An allowlist refuses everything not listed, so a mode nobody thought of is refused instead of accepted

    For a field with a known, small set of values, list the values that are valid. Anything else, a typo, a new mode or text built to slip through, is refused with a message that lists the allowed ones.

  2. Question 2 of 2 A rule allows names of up to 20 characters and checks len(name) <= 20. What happens to the name अनन्या शर्मा?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: It is refused, because len counts 34 bytes of UTF-8 although the name has 12 characters

    Count characters with utf8.RuneCountInString, not bytes with len, or a rule meant for 20 characters turns away a legitimate name in a script that needs several bytes a letter.

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: a 422, a 415 and a 500 as problem details Go · problem.go
// 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

  1. Question 1 of 2 The body {"quantity":0,"pincode":"560001","payment":"card","name":"Asha"} reaches POST /orders. Which reply fits?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: 422 with a problem whose errors member points at /quantity

    The lesson's split: 415 for the wrong media type, 413 for a body over the limit, 400 for a body that is not exactly one order object, and 422 for an order whose values break a rule, with one entry per broken rule.

  2. Question 2 of 2 Saving an order fails with pq: relation "orders_v2" does not exist. What goes into the 500 reply?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: A generic problem, about:blank with the title Internal Server Error, while the cause goes to the log

    A 5xx is the server's problem, and its cause belongs in the server's log. The reply keeps the problem-details format, with a type and title that say only that the server failed.

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.

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.

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:

practice/orders.go and practice/orders_test.go

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)
	}
}
The starter CreateOrder against its tests Python · practice_session.py
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.

  1. Question 1 of 6 A client sends Content-Type: application/json; charset=utf-8. What does decodeOrder do?

    Choose one answer.

    Show the answer: Question 1 of 6

    Answer: It accepts it, because mime.ParseMediaType separates the media type from its parameters

    Parse the header with mime.ParseMediaType and compare the media type alone, so that parameters such as charset do not turn valid requests away.

  2. Question 2 of 6 What would happen to the order with the misspelt field quantitty if decodeOrder did not call DisallowUnknownFields?

    Choose one answer.

    Show the answer: Question 2 of 6

    Answer: The unknown field would be skipped and quantity left at 0, so the error would surface later, as a broken rule, if at all

    Without DisallowUnknownFields, a client's typo turns into a missing value. Refusing unknown fields reports the typo as such, by name, where the client can see and fix it.

  3. Question 3 of 6 Why does validate return every broken rule instead of stopping at the first one?

    Choose one answer.

    Show the answer: Question 3 of 6

    Answer: So that one reply can tell the client everything to fix, instead of one rule per attempt

    A client that fixes one field per round trip needs four requests for the fourth order. Collect every field error, return them in the errors member of one 422, and the client fixes all of them at once.

  4. Question 4 of 6 A product manager asks for orders of up to 1,000 copies for schools. What do you change, and what stays?

    Choose one answer.

    Show the answer: Question 4 of 6

    Answer: Raise the quantity range to 1 to 1,000, keep a range check and an upper bound, and tell clients through the rule's message

    Validation rules encode business limits, so their numbers change; their shape, both bounds checked on the server with a message that states them, does not.

  5. Question 5 of 6 In a problem-details body, which member should a client switch on to decide what to do?

    Choose one answer.

    Show the answer: Question 5 of 6

    Answer: type, the URI that names the kind of problem, the same for every occurrence

    A client compares type, a stable URI such as https://library.test/problems/invalid-order, and shows title and detail to people. A missing type means about:blank, a problem that says no more than its status code.

  6. Question 6 of 6 A mobile app parses error bodies only when their Content-Type is application/problem+json. A new handler sends errors with http.Error. What does the app see?

    Choose one answer.

    Show the answer: Question 6 of 6

    Answer: A text/plain body it does not parse, so it can show only the status, not which fields to fix

    Problem details only help when every error path uses them. One writeProblem function, called from every refusal, keeps the media type and the members the same across the API.

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:

  1. 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?

    Model answer, Pro. Model answers open with any pass; prices are in the notice on this page. First step and follow-ups

  2. 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.

    Model answer, Pro. First step and follow-ups

  3. 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?

    Model answer, Pro. First step and follow-ups

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

Related tools

Report a problem with this lesson

Quick answers and tool search

Type to search tools or to get a quick answer, for example 18% of 2500. Use the up and down arrow keys to move through the results, Enter to choose, and Escape to close.