CodeRigor

LESSON

Decode bounded JSON before changing state

Read at most a declared body bound, reject duplicate/unknown fields and validate a trimmed title without leaking partial input.

Treat the request body as untrusted bytes. A Content-Type header is a declaration, not proof that the bytes are valid or the title is useful.

The create contract accepts one object with one title string. It rejects unknown fields, duplicate keys (including escaped spellings), trailing JSON, invalid UTF-8, controls and titles outside the declared length range. The native exercise supplies the strict schema parser; you implement bounded reading and error propagation.

Bytes become a value only after validation
start / 3
A sentinel-bounded read precedes strict JSON/schema/title validation. Only a complete accepted value can reach repository creation.
Bytes become a value only after validation
A sentinel-bounded read precedes strict JSON/schema/title validation. Only a complete accepted value can reach repository creation.

Read at most 1,025 bytes with io.LimitReader. A 1,024-byte body is allowed if it otherwise validates; receiving the extra byte proves the declared limit was exceeded. Do not read the entire stream merely to discover its size.

A reader error differs from a size error. Both return a zero CreateInput; the handler maps them into stable client-facing error categories without echoing raw payloads.

Go
package main
import ("fmt";"io";"strings")
func main(){
const limit=4
b,_:=io.ReadAll(io.LimitReader(strings.NewReader("abcdef"),limit+1));fmt.Println(len(b),len(b)>limit)
}

Go’s JSON decoder can accept duplicate object keys and replace invalid UTF-8 by default. That is insufficient for this stricter API contract. The supplied token-based helper records whether title has appeared, rejects other keys, validates UTF-8 before decoding and checks that the stream ends after one object.

The title policy counts Unicode code points after trimming, not raw UTF-8 bytes. The byte-size bound still applies to the entire encoded document. A string is not accepted merely because it has the correct Go type; semantic length/control checks remain necessary.

Validation is separate from storage. No note ID should be consumed and no partial title should escape when input fails. Later handler tests verify that failed requests leave the repository unchanged.

Runtime validation has several layers
start / 3
Media type, bounded bytes, JSON/schema and title semantics answer different questions. A successful decode is only one layer.
Runtime validation has several layers
Media type, bounded bytes, JSON/schema and title semantics answer different questions. A successful decode is only one layer.
Go
package main
import ("fmt";"encoding/json")
func main(){
var value any;err:=json.Unmarshal([]byte(`{"title":null}`),&value);fmt.Println(err==nil,value.(map[string]any)["title"]==nil)
}

Complete the bounded read, then run duplicate-key, invalid-UTF-8, over-limit, exact-limit and reader-error cases. Notice which supporting parser checks are supplied and which IO boundary you wrote.

Next we make successful and failed responses obey one JSON representation contract.

Actual handler output · Original fixture

Follow one request through the service.

These seven exchanges were generated by the downloadable Go replay against one fresh in-memory store. Choose a recorded step to inspect its request, response and resulting note count. Choosing a step does not send a live request.

  1. Client requestused
  2. Route & methodused
  3. Bounded decoderused
  4. Repositoryused
  5. JSON responseused
POST /notes201 Created1 stored note after this exchange

Request

Text
POST /notes
Content-Type: application/json
JSON
{
  "title": " learn "
}

Recorded response

JSON
{
  "Content-Length": "25",
  "Content-Type": "application/json; charset=utf-8",
  "Location": "/notes/1",
  "X-Content-Type-Options": "nosniff"
}
JSON
{
  "id": 1,
  "title": "learn"
}

Headers, status, body and stored count are actual recorder results. The highlighted stages annotate the corresponding code path; this is a replay, not a distributed trace. The duplicate-key step leaves the stored count at one.

Download the runnable service, replay and tests →

Watch & read

Free lectures, talks and readings that cover this skill from another angle. All resources →

Optional design discussion of handlers, dependencies and tests. This 2019 talk predates current Go routing changes; use the current net/http documentation and this course’s explicit route contract for the lab.