All checks were successful
Deploy to K8s / deploy (push) Successful in 7s
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>
241 lines
16 KiB
Markdown
241 lines
16 KiB
Markdown
# M5.1 — Hand-author Go API structs + emit JSON Schemas
|
|
|
|
Companion to:
|
|
- [2026-05-03-2349-go-backend-rewrite.md](2026-05-03-2349-go-backend-rewrite.md) (master design)
|
|
- [2026-05-03-2349-go-backend-rewrite-progress.md](2026-05-03-2349-go-backend-rewrite-progress.md) (progress tracker — M5.1 row)
|
|
- [2026-05-07-1431-m5-json-api-parity.md](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 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](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](scripts/views.py#L227-L237)). 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](docs/plans/2026-05-03-2349-go-backend-rewrite.md#L223-L225)), which are *inside* `member_data` — only meaningful if it's a nested object.
|
|
- The `Expected` `MarshalJSON` design (emit `42` or `"?"`) ([same doc:233-238](docs/plans/2026-05-03-2349-go-backend-rewrite.md#L233-L238)) 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](docs/plans/2026-05-07-1431-m5-json-api-parity.md#L87-L89)) 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](go/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](go/internal/domain/reconcile/reconcile.go#L88-L92) 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](docs/plans/2026-05-03-2349-go-backend-rewrite.md#L55-L56)). 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)
|
|
|
|
```go
|
|
// 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"`
|
|
}
|
|
```
|
|
|
|
```go
|
|
// 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](scripts/views.py#L290-L298).
|
|
|
|
`PaymentsResponse`:
|
|
|
|
```go
|
|
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](app.py#L67)):
|
|
|
|
```go
|
|
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](go/internal/domain/reconcile/reconcile.go#L88)) — 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](go/internal/web/server.go#L11-L15)) — 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](docs/plans/2026-05-03-2349-go-backend-rewrite-progress.md#L100) 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.
|