8.4 encoding/json — Tasks¶
Twelve exercises with acceptance criteria. Work them in order if you're new to
encoding/json; pick the ones that target your gaps if you're not. Each has a "what you should produce" section and a "tests you should write" section.
Task 1 — Round-trip a struct with timestamps¶
Define a Project type with ID string, Name string, CreatedAt time.Time, and an optional ArchivedAt *time.Time. Marshal it to JSON, write it to a file, read it back, unmarshal, and verify the round-trip is exact (including the missing ArchivedAt case).
What you should produce.
- A
Save(path string, p Project) errorthat writes the project as pretty-printed JSON to disk. - A
Load(path string) (Project, error)that reads it back. - The output JSON for an unarchived project should not contain an
"archived_at"key (useomitemptyplus a pointer field).
Tests you should write.
- Round-trip an archived project: original
CreatedAtandArchivedAtequal the loaded values. - Round-trip an unarchived project:
ArchivedAtisnilafter load, and the on-disk JSON does not mention"archived_at". - Reject a JSON file with malformed dates with a clear error.
Task 2 — Build an NDJSON streaming client¶
Write a function Stream(url string, fn func(Event) error) error that issues GET url, expects an application/x-ndjson response, and calls fn for each event. The function should:
- Cap the response body via
io.LimitReader(e.g., 100 MiB). - Drain and close the body even on early return so the connection can be reused.
- Stop at the first error from
fnand propagate it. - Stop cleanly on
io.EOF.
What you should produce.
type Event struct {
Time time.Time `json:"time"`
Type string `json:"type"`
Data json.RawMessage `json:"data"`
}
func Stream(url string, fn func(Event) error) error
Tests you should write.
- A test server that streams 1000 events; verify
fnis called exactly 1000 times in order. - A test where
fnreturns an error on event 5; verifyStreamreturns that error and stopped reading. - A test where the server closes the connection mid-event; verify the error is non-nil and includes context.
Task 3 — Polymorphic message type¶
Implement a Message interface with three concrete types: TextMessage{Body string}, ImageMessage{URL string, Width int, Height int}, and LinkMessage{URL string, Title string}. The wire format is:
{"type":"text","body":"hi"}
{"type":"image","url":"...","width":100,"height":200}
{"type":"link","url":"...","title":"..."}
What you should produce.
- A
func Marshal(m Message) ([]byte, error)that emits the right envelope. - A
func Unmarshal(b []byte) (Message, error)that returns the correct concrete type based on"type". - An
UnknownMessageerror type for unrecognized"type"values.
Tests you should write.
- Round-trip each concrete type: marshal then unmarshal returns the same value.
- Unmarshal a message with an unknown
"type"returnsUnknownMessage, not a generic error. - Unmarshal of an image message missing a required field (
"url") returns anUnmarshalTypeError-style error.
Task 4 — Flexible field: string-or-int¶
A third-party API sometimes sends a field as 42 and sometimes as "42". Define a custom type FlexInt int64 that accepts both on unmarshal and emits the integer form on marshal.
What you should produce.
type FlexInt int64
func (f FlexInt) MarshalJSON() ([]byte, error)
func (f *FlexInt) UnmarshalJSON(b []byte) error
Behavior:
42->FlexInt(42)"42"->FlexInt(42)" 42 "->FlexInt(42)(trim whitespace inside the string before parsing)null->FlexInt(0)(don't error)"hello"-> error42.5-> error (not an integer)- Marshal of
FlexInt(42)->42
Tests you should write.
- Each of the cases above, individually.
- Round-trip: marshal then unmarshal returns the original value.
Task 5 — Pretty-and-compact API client¶
Write a small client func Get(url string, v any) error that issues GET url, accepts the response as JSON regardless of whether it's pretty-printed or compact, and decodes into v. The client must:
- Set
Accept: application/json. - Reject responses larger than 10 MiB.
- Reject responses with
Content-Typenot starting withapplication/json. - Use
Decoder.DisallowUnknownFields(test that this is plumbed through by passing a struct that doesn't have all fields the server sends).
What you should produce.
type Client struct{ base string; http *http.Client }
func (c *Client) Get(ctx context.Context, path string, v any) error
Tests you should write.
- Pretty-printed response decodes correctly.
- Compact response decodes correctly.
- Response with extra fields fails with "unknown field" error.
- Response over 10 MiB fails with a "too large" error.
Task 6 — JSON config loader with defaults¶
Write a LoadConfig(path string) (*Config, error) function. The Config struct has nested fields, optional sections, and several fields with sensible defaults. The loader:
- Returns the defaults if the file doesn't exist.
- Decodes the file with
DisallowUnknownFieldsenabled. - After decode, applies defaults for missing fields (a missing
Timeoutof0->30 * time.Second). - Validates the result and returns errors with field paths.
What you should produce.
type Config struct {
ListenAddr string `json:"listen_addr"`
Timeout time.Duration // accept "30s" string in JSON
DB struct {
URL string `json:"url"`
MaxConn int `json:"max_conn"`
} `json:"db"`
}
func LoadConfig(path string) (*Config, error)
Tests you should write.
- Load a complete config: every field matches the file.
- Load a partial config: missing fields use defaults.
- Load with an unknown field: returns a "unknown field" error.
- Load with a malformed
Timeout("xyz"): returns a clear error. - Missing file: returns the zero defaults, no error.
Task 7 — Nested JSON to flat key=value¶
Write Flatten(data []byte) (map[string]string, error) that takes arbitrary JSON and produces a flat key=value map where nested keys are joined with .:
becomes
What you should produce.
Use Decoder.UseNumber() so numbers don't lose precision through float64. Stringify booleans as true/false, null as the empty string.
Tests you should write.
- Empty object -> empty map.
- Deeply nested object: each leaf is one entry.
- Array elements: keys end with the index.
- Big integer (over 2^53): preserved exactly.
- Invalid JSON: returns a
*json.SyntaxError.
Task 8 — JSON pretty-printer that handles streaming input¶
Write a CLI-shaped function func Pretty(in io.Reader, out io.Writer) error that reads a stream of JSON values from in and writes them to out, each pretty-printed and separated by a blank line.
Constraints:
- Don't load the whole input into memory.
- Don't break on inputs that have multiple top-level values.
- Don't emit a trailing blank line after the last value.
What you should produce.
A function with the signature above. Internally use json.Decoder and json.MarshalIndent (or Encoder.SetIndent).
Tests you should write.
- Single value: pretty-printed.
- Three concatenated values (whitespace-separated): each pretty-printed, blank lines between, no trailing blank.
- A 1 GiB synthetic input doesn't OOM. (Use a small stream and measure peak memory.)
Task 9 — JSON-merge-patch (RFC 7396) implementation¶
Implement func Merge(target, patch []byte) ([]byte, error) that applies a JSON Merge Patch (RFC 7396) to a target document.
The rules:
- If
patchis not a JSON object, returnpatchunchanged. - Otherwise, for each key
kinpatch: - If
patch[k]isnull, removekfromtarget. - Else if
target[k]andpatch[k]are both objects, merge recursively. - Else, set
target[k] = patch[k].
What you should produce.
The output should be canonical JSON (lexicographically sorted keys at every level). Use map[string]json.RawMessage for efficient pass-through of unmodified subtrees.
Tests you should write.
The official RFC 7396 examples (there are seven). Each is one test case.
Task 10 — Streaming aggregator¶
Build a tool that reads NDJSON from stdin, where each line is
and prints (also as NDJSON, to stdout) a running per-category total every N lines. Acceptance:
- Memory stays bounded regardless of input length.
- Numbers use
json.Numberto avoid precision loss on big totals. - Outputs are sorted by category in each batch.
What you should produce.
Tests you should write.
- Small synthetic input: verify the totals.
- Input with 64-bit-precision amounts: verify exact totals.
- Malformed line in the middle: function returns an error pointing at the offending byte offset.
Task 11 — Schema-versioned envelope¶
You have v1 and v2 of an event schema. v1 has {user_id, action}; v2 has {actor: {id, type}, action, version: 2}. Implement func DecodeEvent(b []byte) (Event, error) that returns the version-appropriate concrete type, plus a normalizer that upgrades v1 to v2 in memory.
What you should produce.
type EventV1 struct{ UserID, Action string }
type EventV2 struct{ Actor struct{ ID, Type string }; Action string }
func DecodeEvent(b []byte) (any, error) // returns EventV1 or EventV2
func ToV2(any) EventV2 // upgrades v1 to v2 in memory
Hint: peek at the bytes via RawMessage plus a probe of version to choose the version.
Tests you should write.
- v1 input ->
EventV1;ToV2produces anEventV2withActor.Type == "user"(default upgrade rule). - v2 input ->
EventV2;ToV2returns it unchanged. - Input without
versionkey: assume v1. - Input with
version: 99: error "unsupported version".
Task 12 — Round-trip stability under field reordering¶
Property test: given a struct, marshal it twice with the fields declared in different orders. The marshaled JSON should differ (structs preserve field order), but unmarshaling either form into either struct ordering should yield identical values.
What you should produce.
A test file with a property-style test: generate random struct values, swap two fields' declaration order, marshal both, then unmarshal both into the original struct, and assert equality.
Tests you should write.
- 100 random instances pass.
- A failing case (where the two struct orderings have different tags) is detected and reported.
- A
map[string]anyround-trip is order-stable (lexicographic).