Files
fuj-management/docs/plans/2026-05-07-1650-go-rewrite-m5-1-api-structs-schemas.md
Jan Novak f253e3fcb1
All checks were successful
Deploy to K8s / deploy (push) Successful in 7s
feat(go): M5.1 — hand-author /api/* wire types + JSON Schemas
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>
2026-05-07 17:36:46 +02:00

16 KiB

M5.1 — Hand-author Go API structs + emit JSON Schemas

Companion to:

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 by github.com/invopop/jsonschema. Schemas only — no handlers, no Python /api/X routes, no parity tool.
  • M5.2 — Implement Go handlers that compose services/* results into these structs.
  • M5.3 — Add Python /api/X shadow endpoints in app.py.
  • M5.4 — cmd/parity/main.go + make parity target.

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

  1. 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/X JSON contract uses the un-stringified nested form. Rationale:

    • The master design doc references total_balance, original_expected, attendance_count as struct fields (2026-05-03-2349-go-backend-rewrite.md:223-225), which are inside member_data — only meaningful if it's a nested object.
    • The Expected MarshalJSON design (emit 42 or "?") (same doc:233-238) only fires inside member_data; pointless if that field is a string.
    • Strings-of-JSON make invopop/jsonschema schemas 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/X will be jsonify(unwrap_json_strings(view_model_dict)) — a 4-line shim, not real transformation logic.

  2. New package: internal/web/api/. The Go side has no api/ 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.

  3. Wire types are separate from domain/reconcile.Result. domain/reconcile/reconcile.go:88-92 defines Result, MemberResult, MonthData, TxEntry, etc. — none have json: 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 in internal/web/api/ and convert in M5.2's handlers.

  4. Expected{Value int; Unknown bool} with custom MarshalJSON/UnmarshalJSON for junior expected and original_expected. Already prescribed by the master design.

  5. Transaction amount / inferred_amount may be int or "" (Sheets UNFORMATTED_VALUE returns empty string for blank cells per scripts/match_payments.py rows 250-260 in the views.py exploration). Use a custom SheetsNumber type with MarshalJSON/UnmarshalJSON that emits 0/null for 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.

  6. Schema generation lives in internal/web/api/schemagen_test.go with -update flag, à la golden tests. Default test run (go test ./internal/web/api/...) re-generates schemas in memory and asserts byte-equality vs the committed files in tests/fixtures/api-schema/. go test -update ./internal/web/api/... rewrites the committed files. Avoids a separate cmd/gen-api-schema binary; CI catches drift automatically.

  7. 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
    
  8. Adults and Juniors are not the same type. Their outer shapes match (same keys), but MonthCell.Text semantics differ (juniors render "?", "?(3)", :NJ,MA breakdowns) and member_data semantics differ (juniors carry Expected sentinel). Define AdultsResponse and JuniorsResponse separately even if they share most field types — clearer schemas and easier to evolve independently. Share scalar types (Transaction, Exception, MonthCell, TotalCell) in types.go.

  9. Money is integer CZK (master design:55-56). All amount fields use int. The one exception: member_data[name].months[YYYY-MM].paid is a float64 in the Python output (proportional allocation produces fractional CZK like 33.333333). Use float64 only 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 through cmd/fuj/main.go:79. The VersionResponse type is a thin renaming of this with json tags. Decide in M5.2 whether to also tag BuildInfo directly (it's not really domain) or keep VersionResponse separate. M5.1 just defines the wire struct.
  • scripts/views.py builders — the spec for every key/type. Treat as authoritative.

Tasks

  1. Add github.com/invopop/jsonschema dependency. cd go && go get github.com/invopop/jsonschema && go mod tidy. Single commit.
  2. Capture one fresh member_data dump for adults and juniors to pin down inner-dict field names and types precisely (especially paid precision, transactions[] shape, others[] if present). Run make web-py on a known-good cache, hit /adults and /juniors, dump view_model["member_data"] to a scratch file (gitignored), and inspect. Do not commit raw dumps — PII rule. This is for shape inspection only.
  3. Author internal/web/api/types.go with SheetsNumber, Expected, Exception, Transaction, MonthCell, TotalCell, MemberRow, Credit. Implement MarshalJSON/UnmarshalJSON for SheetsNumber and Expected. Use struct tags json:"snake_case_key" matching Python exactly.
  4. Author internal/web/api/adults.go with AdultsMonthData, AdultsMemberData, AdultsResponse. Cross-check every key against the dump from step 2.
  5. Author internal/web/api/juniors.go similarly, with Expected fields and the J/A breakdown.
  6. Author internal/web/api/payments.go and version.go (small).
  7. Author internal/web/api/schemagen_test.go that:
    • Imports github.com/invopop/jsonschema.
    • Defines a schemaCases slice: {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 -update flag (flag.Bool) that rewrites the committed file instead of asserting.
    • One t.Run(case.name, ...) per route for clear failure output.
  8. Run with -update once to populate tests/fixtures/api-schema/*.schema.json. Eyeball each schema: required fields list, oneOf for Expected, additionalProperties on map types. Commit.
  9. Lint + test: cd go && go vet ./... && make go-test && make go-lint. Fix any issues. (Expect zero — this is read-only data structures.)
  10. CHANGELOG entry + tick M5.1 in docs/plans/2026-05-03-2349-go-backend-rewrite-progress.md:100 with the merge SHA.
  11. Branch + MR per CLAUDE.md workflow: branch feat/go-m5-1-api-structs, push with -u, open MR via tea pr create. Do not merge from CLI.

Verification

  1. cd go && go test ./internal/web/api/...schemas regenerate identically to committed files.
  2. cd go && go test ./internal/web/api/... -update then git diff go/tests/fixtures/api-schema/ — diff is empty (idempotent generation).
  3. cd go && make go-lint — clean.
  4. cd go && go vet ./... — clean.
  5. Manual schema inspection: open adults.schema.json, confirm:
    • Top-level required list contains every Python key.
    • member_data is additionalProperties: { ... AdultsMemberData ... } (a map keyed by name).
    • expected and original_expected (juniors only) are oneOf: [{type: integer}, {const: "?"}].
    • amount and inferred_amount on Transaction accept number or empty/null.
  6. 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/X shadow endpoints (M5.3) — including the unwrap_json_strings(view_model) shim noted in design decision #1.
  • cmd/parity/main.go and make parity target (M5.4).
  • Tagging domain/reconcile.Result with json: tags — explicitly avoided.
  • Refactoring the strings-of-JSON fields out of the Python view-model — they stay in views.py for the template path, the /api/X shadow unwraps them.