Add internal/web/api package with Go structs for every /api/X route:
AdultsResponse, JuniorsResponse, PaymentsResponse, VersionResponse.
All fields carry explicit json: tags matching the Python view-model keys.
Key design choices:
- member_data / month_labels / raw_payments are nested objects (not
the pre-serialised JSON strings used in Jinja templates)
- Expected{Value int; Unknown bool} with custom MarshalJSON emits int
or the string "?" for junior single-attendance months
- RawTransaction covers the full 11-column payments sheet row
schemagen_test.go reflects all four response types via
github.com/invopop/jsonschema and golden-compares against committed
schemas in tests/fixtures/api-schema/. The JSONSchema() method on
Expected lives in the test file so the prod binary has no jsonschema
dependency.
Closes M5.1 in docs/plans/2026-05-03-2349-go-backend-rewrite-progress.md.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
16 KiB
M5.1 — Hand-author Go API structs + emit JSON Schemas
Companion to:
- 2026-05-03-2349-go-backend-rewrite.md (master design)
- 2026-05-03-2349-go-backend-rewrite-progress.md (progress tracker — M5.1 row)
- 2026-05-07-1431-m5-json-api-parity.md (Python view-model extraction prep — already merged as
b562ce3/32a16ff/59223c0)
Context
M4 (IO layer behind interfaces) just landed. M5 is the JSON-parity contract phase — byte-equal JSON between Python and Go for /api/adults, /api/juniors, /api/payments, /api/version. Within M5, the work splits four ways:
- M5.1 — this plan. Define the wire contract: hand-authored Go structs with explicit
json:tags matching Python keys, plus committed JSON Schemas generated bygithub.com/invopop/jsonschema. Schemas only — no handlers, no Python/api/Xroutes, no parity tool. - M5.2 — Implement Go handlers that compose
services/*results into these structs. - M5.3 — Add Python
/api/Xshadow endpoints in app.py. - M5.4 —
cmd/parity/main.go+make paritytarget.
The recent Python view-model extraction (scripts/views.py) lays the groundwork: every Python builder now returns a plain dict that an /api/X shadow can jsonify (with one minor unwrap step — see decision #1 below). M5.1 is the matching Go side: types and schemas that pin down the contract before any code writes JSON to a wire.
Key design decisions
-
Wire format is nested objects, not strings-of-JSON. The Python view-model dicts contain three template-only fields that are pre-serialized JSON strings:
member_data,month_labels_json,raw_payments_json. Those exist purely to feed inline<script>blocks in Jinja templates (scripts/views.py:227-237). The/api/XJSON contract uses the un-stringified nested form. Rationale:- The master design doc references
total_balance,original_expected,attendance_countas struct fields (2026-05-03-2349-go-backend-rewrite.md:223-225), which are insidemember_data— only meaningful if it's a nested object. - The
ExpectedMarshalJSONdesign (emit42or"?") (same doc:233-238) only fires insidemember_data; pointless if that field is a string. - Strings-of-JSON make
invopop/jsonschemaschemas useless for those fields ({"type": "string"}describes nothing). - The Python prep plan (2026-05-07-1431-m5-json-api-parity.md:87-89) already anticipates this: "that route will produce a sibling dict with the wrappers stripped".
This refines (does not contradict) M5.3's "no transformation" wording. M5.3's
/api/Xwill bejsonify(unwrap_json_strings(view_model_dict))— a 4-line shim, not real transformation logic. - The master design doc references
-
New package:
internal/web/api/. The Go side has noapi/package today — only internal/web/server.go with one hello route. The api package owns wire types and (in M5.2) handlers. Sub-files per route keep diffs small. -
Wire types are separate from
domain/reconcile.Result. domain/reconcile/reconcile.go:88-92 definesResult,MemberResult,MonthData,TxEntry, etc. — none havejson:tags. Don't tag the domain types: that bleeds wire concerns into pure logic and locks the JSON contract to internal field names. Define wire types ininternal/web/api/and convert in M5.2's handlers. -
Expected{Value int; Unknown bool}with customMarshalJSON/UnmarshalJSONfor juniorexpectedandoriginal_expected. Already prescribed by the master design. -
Transaction
amount/inferred_amountmay beintor""(SheetsUNFORMATTED_VALUEreturns empty string for blank cells perscripts/match_payments.pyrows 250-260 in the views.py exploration). Use a customSheetsNumbertype withMarshalJSON/UnmarshalJSONthat emits0/nullfor empty and the number otherwise. Document in code comment with a one-liner. Verify exact behavior by inspecting one or two existing scrubbed reconcile fixtures before coding. -
Schema generation lives in
internal/web/api/schemagen_test.gowith-updateflag, à la golden tests. Default test run (go test ./internal/web/api/...) re-generates schemas in memory and asserts byte-equality vs the committed files intests/fixtures/api-schema/.go test -update ./internal/web/api/...rewrites the committed files. Avoids a separatecmd/gen-api-schemabinary; CI catches drift automatically. -
One schema per route, named to match the Go type:
go/tests/fixtures/api-schema/adults.schema.json go/tests/fixtures/api-schema/juniors.schema.json go/tests/fixtures/api-schema/payments.schema.json go/tests/fixtures/api-schema/version.schema.json -
Adults and Juniors are not the same type. Their outer shapes match (same keys), but
MonthCell.Textsemantics differ (juniors render"?","?(3)",:NJ,MAbreakdowns) andmember_datasemantics differ (juniors carryExpectedsentinel). DefineAdultsResponseandJuniorsResponseseparately even if they share most field types — clearer schemas and easier to evolve independently. Share scalar types (Transaction,Exception,MonthCell,TotalCell) intypes.go. -
Money is integer CZK (master design:55-56). All amount fields use
int. The one exception:member_data[name].months[YYYY-MM].paidis afloat64in the Python output (proportional allocation produces fractional CZK like33.333333). Usefloat64only there; document the why in a one-line comment.
Files to create
go/internal/web/api/
├── types.go # SheetsNumber, Expected, Exception, Transaction, MonthCell, TotalCell, MemberRow
├── adults.go # AdultsResponse + adults-specific MemberData
├── juniors.go # JuniorsResponse + juniors-specific MemberData (with Expected fields)
├── payments.go # PaymentsResponse
├── version.go # VersionResponse
└── schemagen_test.go # generates + golden-asserts schemas
go/tests/fixtures/api-schema/
├── adults.schema.json
├── juniors.schema.json
├── payments.schema.json
└── version.schema.json
Struct skeleton (illustrative, not final wording)
// internal/web/api/types.go
// SheetsNumber wraps a value that may arrive from Google Sheets as either
// a JSON number or "" (empty string for blank cells). Marshals as 0 when
// missing; the consumer treats Missing+Value=0 as "no data".
type SheetsNumber struct {
Value float64
Missing bool
}
// Expected carries a junior's expected fee or the "?" sentinel
// (single-attendance month requires manual review).
type Expected struct {
Value int
Unknown bool
}
type Exception struct {
Amount int `json:"amount"`
Note string `json:"note"`
}
type Transaction struct {
Date string `json:"date"`
Amount SheetsNumber `json:"amount"`
ManualFix string `json:"manual_fix"`
Person string `json:"person"`
Purpose string `json:"purpose"`
InferredAmount SheetsNumber `json:"inferred_amount"`
Sender string `json:"sender"`
Message string `json:"message"`
BankID string `json:"bank_id"`
}
type MonthCell struct {
Text string `json:"text"`
Overridden bool `json:"overridden"`
Status string `json:"status"` // "empty"|"ok"|"partial"|"unpaid"|"surplus"
Amount int `json:"amount"`
Month string `json:"month"` // display label, e.g. "Apr+May 2025"
RawMonth string `json:"raw_month"` // YYYY-MM
Tooltip string `json:"tooltip"`
}
type TotalCell struct {
Text string `json:"text"`
Status string `json:"status"`
}
type MemberRow struct {
Name string `json:"name"`
Months []MonthCell `json:"months"`
Balance int `json:"balance"`
UnpaidPeriods string `json:"unpaid_periods"`
RawUnpaidPeriods string `json:"raw_unpaid_periods"`
PayableAmount int `json:"payable_amount"`
}
type Credit struct {
Name string `json:"name"`
Amount int `json:"amount"`
}
// internal/web/api/adults.go
type AdultsMonthData struct {
Status string `json:"status"`
Expected int `json:"expected"`
Paid float64 `json:"paid"` // float — proportional allocator can produce 33.333…
Exception *Exception `json:"exception"`
AmountToPay int `json:"amount_to_pay"`
// ... transactions, others, etc — match exact keys from result["members"][name]["months"][YYYY-MM]
}
type AdultsMemberData struct {
TotalBalance int `json:"total_balance"`
Months map[string]AdultsMonthData `json:"months"` // YYYY-MM key
Transactions []Transaction `json:"transactions"`
}
type AdultsResponse struct {
Months []string `json:"months"`
RawMonths []string `json:"raw_months"`
Results []MemberRow `json:"results"`
Totals []TotalCell `json:"totals"`
MemberData map[string]AdultsMemberData `json:"member_data"`
MonthLabels map[string]string `json:"month_labels"` // was month_labels_json (string)
RawPayments map[string][]Transaction `json:"raw_payments"` // was raw_payments_json (string)
Credits []Credit `json:"credits"`
Debts []Credit `json:"debts"`
Unmatched []Transaction `json:"unmatched"`
AttendanceURL string `json:"attendance_url"`
PaymentsURL string `json:"payments_url"`
BankAccount string `json:"bank_account"`
CurrentMonth string `json:"current_month"`
}
JuniorsResponse mirrors AdultsResponse but the inner MonthData carries Expected and OriginalExpected (both Expected type), and adds the :NJ,MA breakdown fields produced by scripts/views.py:290-298.
PaymentsResponse:
type PaymentsResponse struct {
GroupedPayments map[string][]Transaction `json:"grouped_payments"`
SortedPeople []string `json:"sorted_people"`
AttendanceURL string `json:"attendance_url"`
PaymentsURL string `json:"payments_url"`
}
VersionResponse mirrors Python's BUILD_META (app.py:67):
type VersionResponse struct {
Tag string `json:"tag"`
Commit string `json:"commit"`
BuildDate string `json:"build_date"`
}
Exact fields inside the per-month MonthData/MemberData will be finalized by inspecting one scrubbed member_data JSON dump from a current /adults and /juniors call (see Verification step 1) — names and types have to be identical.
Reusable existing code
domain/reconcile.Result(go/internal/domain/reconcile/reconcile.go:88) — source data for adults/juniors. M5.2 maps it to wire types; M5.1 only needs to know the field set.web.BuildInfo(go/internal/web/server.go:11-15) — already wired throughcmd/fuj/main.go:79. TheVersionResponsetype is a thin renaming of this with json tags. Decide in M5.2 whether to also tagBuildInfodirectly (it's not really domain) or keepVersionResponseseparate. M5.1 just defines the wire struct.scripts/views.pybuilders — the spec for every key/type. Treat as authoritative.
Tasks
- Add
github.com/invopop/jsonschemadependency.cd go && go get github.com/invopop/jsonschema && go mod tidy. Single commit. - Capture one fresh
member_datadump for adults and juniors to pin down inner-dict field names and types precisely (especiallypaidprecision,transactions[]shape,others[]if present). Runmake web-pyon a known-good cache, hit/adultsand/juniors, dumpview_model["member_data"]to a scratch file (gitignored), and inspect. Do not commit raw dumps — PII rule. This is for shape inspection only. - Author
internal/web/api/types.gowithSheetsNumber,Expected,Exception,Transaction,MonthCell,TotalCell,MemberRow,Credit. ImplementMarshalJSON/UnmarshalJSONforSheetsNumberandExpected. Use struct tagsjson:"snake_case_key"matching Python exactly. - Author
internal/web/api/adults.gowithAdultsMonthData,AdultsMemberData,AdultsResponse. Cross-check every key against the dump from step 2. - Author
internal/web/api/juniors.gosimilarly, withExpectedfields and the J/A breakdown. - Author
internal/web/api/payments.goandversion.go(small). - Author
internal/web/api/schemagen_test.gothat:- Imports
github.com/invopop/jsonschema. - Defines a
schemaCasesslice:{name: "adults", typ: AdultsResponse{}, ...}etc. - For each case, generates a schema, marshals indented JSON, compares against committed file at
../../tests/fixtures/api-schema/<name>.schema.json. - Honors a
-updateflag (flag.Bool) that rewrites the committed file instead of asserting. - One
t.Run(case.name, ...)per route for clear failure output.
- Imports
- Run with
-updateonce to populatetests/fixtures/api-schema/*.schema.json. Eyeball each schema: required fields list, oneOf forExpected, additionalProperties on map types. Commit. - Lint + test:
cd go && go vet ./... && make go-test && make go-lint. Fix any issues. (Expect zero — this is read-only data structures.) - CHANGELOG entry + tick
M5.1in docs/plans/2026-05-03-2349-go-backend-rewrite-progress.md:100 with the merge SHA. - Branch + MR per CLAUDE.md workflow: branch
feat/go-m5-1-api-structs, push with-u, open MR viatea pr create. Do not merge from CLI.
Verification
cd go && go test ./internal/web/api/...— schemas regenerate identically to committed files.cd go && go test ./internal/web/api/... -updatethengit diff go/tests/fixtures/api-schema/— diff is empty (idempotent generation).cd go && make go-lint— clean.cd go && go vet ./...— clean.- Manual schema inspection: open
adults.schema.json, confirm:- Top-level
requiredlist contains every Python key. member_dataisadditionalProperties: { ... AdultsMemberData ... }(a map keyed by name).expectedandoriginal_expected(juniors only) areoneOf: [{type: integer}, {const: "?"}].amountandinferred_amountonTransactionaccept number or empty/null.
- Top-level
- No production code paths exercised yet — handlers come in M5.2. Compile-time success + schema golden-test = M5.1 done.
Out of scope (later M5 tasks)
- Wiring Go HTTP handlers for
/api/X(M5.2). - Adding Python
/api/Xshadow endpoints (M5.3) — including theunwrap_json_strings(view_model)shim noted in design decision #1. cmd/parity/main.goandmake paritytarget (M5.4).- Tagging
domain/reconcile.Resultwithjson:tags — explicitly avoided. - Refactoring the strings-of-JSON fields out of the Python view-model — they stay in
views.pyfor the template path, the/api/Xshadow unwraps them.