Skip to content

Tighten drinks JSON schema to match real data; remove dead variant-parsing #349

Description

@richardthe3rd

Summary

The drinks JSON is generated by a structured backend, and the app's fromJson parsing defends against many type variants. An empirical survey of all data the API currently serves shows most of that variance never actually occurs — the data is far cleaner than the parsing (and the current loose schema) implies. This issue captures that finding, proposes a descriptive-strict JSON Schema that matches reality, and identifies the defensive code a schema + validation gate would let us delete.

This is learning-capture + tech-debt, not a user-facing bug. No behaviour changes proposed yet.

Empirical survey (method)

Fetched live (all HTTP 200) on 2026-05-28 and measured the runtime type of every variant-prone field:

  • Summer census: cbf2024 beer, cbf2025 beer/cider/mead, cbf2026 beer777 drinks / 356 producers
  • Winter census: cbfw2025 beer + low-no123 drinks / 67 producers (full type census; first survey of the low-no category)
  • vegan-key probe additionally across cbf2023 (201 drinks)
  • Registry (https://data.cambeerfestival.app/festivals.json) serves only: cbf2026, cbf2025, cbfw2025, cbf2024. cbf2022 and older → 404.

What the data actually contains (summer census, 777 drinks)

Field What fromJson handles What live data is Verdict
id name category dispense string string 777/777 clean
abv String / int / double / else String 777/777 numeric branch never fires
style notes status_text bar string (+coercions) string or null only clean (bar never int/bool)
is_vegan bool / num / string + legacy vegan bool(206) / null(36) / absent(535) num/string branches + vegan are dead
allergens values int / bool / num (string skipped) int 1(410) / bool true(207)false(72) / string ''(207) ✅ genuinely mixed — keep
year_founded int / String str(205) / int(108) / null(43) ✅ genuinely mixed — keep
status_text (distinct values) substring matching (see #348) only 6: Some beer remaining(246), Plenty left(160), Sold Out(121), A little remaining(107), ''(98), Nearly finished!(20) bounded set

Winter festival (cbfw2025) corroboration — and one important nuance

The winter drop confirms the thesis but proves the variant unions are per-festival, so you cannot infer the schema from any single festival:

Field Summer (777) Winter (123) Takeaway
abv String 100% String 100% clean everywhere
is_vegan / vegan is_vegan bool/null/absent; vegan never both entirely absent (123/123) is_vegan is per-festival, not chronological (winter is Dec 2025 yet has none); vegan still appears nowhere
year_founded str(205)/int(108)/null str(64)/null(3), zero int winter quotes all years → union is real
allergens values int/bool/string int(116)/string(56), zero bool bool only in some festivals → full int/bool/string union is required
allergen string values all '' (absent) all '' (absent) string-skip stays correct; no real allergens dropped (verified)
status_text 6 values adds new value Arrived(4) vocabulary is not stable across festivals — see note below
dispense adds keykeg (cask/keg/keykeg) enum must cover winter variants

Key conclusions

  • Most variance is mechanical, not chaotic. year_founded is just "quoted vs unquoted integer"; allergen values are spellings of present/absent (1/true = present, false/'' = absent). The string-skip in allergen parsing is correct — every string value across both summer and winter is ''.
  • abv, bar, is_vegan are single-typed in practice — the variant handling matches what the schema permits, not what the API emits.
  • The variant unions are per-festival. Winter is all-string year_founded with no bool allergens; summer is mixed. A strict schema must therefore keep both unions — it can't be narrowed from one festival's data.
  • The vegan "legacy" claim is unverifiable. vegan appears in zero served datasets (cbf2023, cbfw2025, cbf2024-26). The "legacy" label exists only in the schema doc and the is_vegan ?? vegan code comment — no observable data supports it. Pre-2023 data that might use it is 404'd.

Is a strict schema possible?

Yes — JSON Schema (additionalProperties: false, required, enum, pattern, type unions) is expressive enough. The constraint is the data: it can only be as strict as the backend is consistent. Two flavours:

  1. Descriptive-strict (achievable now, no backend access): tighten to exactly what the API emits while keeping the two real unions. Proposed below.
  2. Prescriptive-strict (needs backend normalization): define the ideal (abv: number, is_vegan: boolean, year_founded: integer, allergen values boolean), fix the data pipeline to conform, then validate. Out of scope here — the data pipeline is upstream.

Proposed descriptive-strict rules (replacing docs/code/api/beer-list-schema.json)

  • abv: type: string, pattern: ^\\d+(\\.\\d+)?$ (NOT number)
  • is_vegan: type: [boolean, null]; remove vegan from the schema
  • bar style notes status_text: type: [string, null]
  • category, dispense: keep enum — ensure dispense includes keykeg
  • product + producer objects: additionalProperties: false
  • year_founded: type: [string, integer, null], string pattern: ^\\d{4}$, minimum: 1000, maximum: 2100 (union retained)
  • allergens: additionalProperties: { type: [integer, boolean, string] } (union retained)

Defensive code this would unlock for removal

Once a schema + CI validation gate guarantees the shapes, these branches in lib/models/drink.dart become dead and can be simplified:

  • abv (lines ~85-86): drop the is num branch → double.tryParse((json['abv'] as String?) ?? '') ?? 0.0
  • bar (lines ~119-120): drop the is int branch → json['bar'] as String?
  • is_vegan (lines ~124-139): drop the is num / is String branches and the ?? json['vegan'] fallback → json['is_vegan'] as bool?

Must keep (genuinely variant across festivals): year_founded int/String parsing (Producer.fromJson ~21-27) and the allergen int/bool/num handling (Product.fromJson ~96-110).

⚠️ Removing the vegan fallback is the one judgment call: it's absent from all currently served data (verified across cbf2023, cbfw2025, cbf2024-26) but we can't inspect pre-2023 snapshots. Safe to remove for the app, since the app only loads festivals from the live registry.

Suggested validation approach

Validating against live data in CI is flaky (data changes hourly during a festival). Instead:

  • Commit representative fixtures (summer + winter, one per category incl. low-no) under test/fixtures/ and validate them against the strict schema in a validate:drinks mise task, mirroring the existing validate:festivals pattern.
  • Optionally add a separate, non-blocking scheduled job that validates live data and reports drift.

Related

  • Availability status: replace fragile substring matching with an ordered status model #348 — availability substring matching. The winter data adds the status value Arrived (maps to plenty), not in the summer set — proving the status_text vocabulary is not stable across festivals. This is actually an argument for keeping resilient matching over a hard-coded enum, provided no new value produces a false substring match. Also note Some beer remaining (most common summer status, ~32%) currently maps to AvailabilityStatus.low via the remaining substring, which under-represents availability.

Files: docs/code/api/beer-list-schema.json, lib/models/drink.dart, mise.toml (new validate:drinks task), test/fixtures/ (new).

Metadata

Metadata

Assignees

No one assigned

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions