Org Web Adapter

hungryroot/cookbooks/mobile-transition-flattened-fields.org

ID
f6fc4950-07e6-459b-a256-c18282ca9256

Mobile Transition Plan: Cookbooks V3 Nested → Flattened Fields

Context

[[https://hungryroot.atlassian.net/browse/BE-7924][BE-7924]] added flat replacements (=author_id=, =pairing_ids=, =product_ids=,

=section_ids=, =collection_ids=) for the nested =author= object and =sections=

array on the V3 cookbook endpoints, gated behind

=BoolDynamicConfigEnum.COOKBOOK_LIST_REMOVE_DEPRECATED_FIELDS=

(=app/constants/dynamic_config.py=). The flag currently defaults to =0=

(off) — nested fields are still live in production today.

Both endpoints are affected, since =CookbookDetailSerializer= subclasses

=CookbookListSerializer= and inherits its field-dropping logic:

- =GET /api/v3/cookbooks/= (list) — =CookbookListSerializer=,

=cookbooks/views.py:575-594=

- =GET /api/v3/cookbooks/<slug>/= (detail) — =CookbookDetailSerializer=,

=cookbooks/views.py:636-638=

What flipping the flag actually does

=CookbookListSerializer.to_representation= (=cookbooks/serializers.py:346-351=)

unconditionally pops =author= and =sections= from the response dict when the

flag is active — for *every* request, *every* client, *every* app version,

the instant it flips. There is no per-client staging, no expand param, no

version gate. This is a single global switch:

#+begin_src python

def to_representation(self, instance: Any) -> Any:

data = super().to_representation(instance)

if bool_config_is_active(BoolDynamicConfigEnum.COOKBOOK_LIST_REMOVE_DEPRECATED_FIELDS):

for name in ("author", "sections"):

data.pop(name, None)

return data

#+end_src

Rollback is equally global and instant: flip the config back to =0= and the

nested fields return, no deploy needed either direction.

*The safe ordering is always "clients finish migrating first, flag flips

second." Flipping it before every active client version has shipped

flat-field parsing is an instant breaking change for whoever hasn't.*

Current field availability (as of this doc)

| Field | List default | List via =?expand= | Detail default | Detail via =?expand= |

|------------------------+----------------------+-----------------------+-----------------+----------------------------|

| =author= (nested) | present | n/a | present | n/a |

| =sections= (nested) | present | n/a | present | n/a |

| =author_id= | *always present* | n/a | always present | n/a |

| =pairing_ids= | absent | =pairing_ids= | always present | n/a |

| =product_ids= | absent | =product_ids= | always present | n/a |

| =section_ids= | absent | =section_ids= | always present | n/a |

| =collection_ids= | absent | =collection_ids= | absent | =collection_ids= |

(=cookbooks/serializers.py:239-364=, =CookbookSerializer=/=CookbookListSerializer=/=CookbookDetailSerializer=)

=author_id= needs no client change at all — it's already unconditional on

both endpoints today. =pairing_ids=/=product_ids=/=section_ids= need no

change on detail either. The only new work for mobile is: (1) requesting

=?expand=pairing_ids,product_ids,section_ids,collection_ids= on the *list*

endpoint wherever those are read today via nested =sections=, and (2)

replacing any read of =author.<field>= (either endpoint) with an

=author_id=-based lookup.

What Mobile Needs to Change

- Any list-view code parsing =cookbook.sections[].items[].pairing_id=/

=product_id= → switch to requesting =?expand=pairing_ids,product_ids= and

reading the flat arrays directly.

- Any code reading section groupings from the nested =sections[].name=/

=position= structure on the list endpoint → request

=?expand=section_ids= if only IDs are needed; if display-level section

metadata (names, positions) is needed from the *list* endpoint, flag that

back to backend — =section_ids= alone won't cover it, and that's a real

gap worth raising before committing to a phase-2 flip.

- Any code reading =cookbook.author.name=/=.slug=/=.avatar= etc. (list or

detail) → resolve author display data via =author_id= instead — either a

lookup against the author detail route or from an author record the

client already has cached from elsewhere. *This is very likely the

riskiest piece for mobile*, since =author_id= alone carries no display

data.

- Any code reading =collections= — this endpoint doesn't (and never did)

expose a nested =collections= field; =collection_ids= is the only shape

that's existed. If mobile already reads collection membership from this

endpoint, confirm it's already using =collection_ids= via expand — this

doc's flag doesn't change that field's behavior either way.

- Detail-view code reading =pairing_ids=/=product_ids=/=section_ids=

already gets them unconditionally — only the =author= parsing above needs

a change there.

Rollout Phases

Sequence-gated, not date-gated — each phase's start depends on the previous

phase actually completing, not a calendar date.

Phase 0 — Inventory (mobile-owned)

Backend has no visibility into client code, so this step has to start on

the mobile side:

- Confirm every iOS/Android screen or feature that reads =author=,

=sections=, or nested section/author data from

=/api/v3/cookbooks/=list or detail=.

- Confirm the oldest app version still active in production traffic, and

whether *that* version depends on the nested shape (it almost certainly

does, since flat fields are recent).

Phase 1 — Mobile ships flat-field parsing

- iOS and Android each add =?expand=pairing_ids,product_ids,section_ids,collection_ids=

(whichever subset each screen actually needs) to list requests, and

switch parsing to the flat fields.

- Replace any =author.*= reads with an =author_id=-based resolution path.

- Ship as a normal release — no backend coordination required yet, since

the flag stays off through this phase. Nested fields keep flowing

alongside the new flat ones, so there's no forced cutover risk here.

- Remove the old nested-parsing code path once the new path is verified in

that release — no need to keep both indefinitely, since the server isn't

dropping anything yet.

Phase 2 — Confirm adoption before flipping

- Watch Datadog for `/api/v3/cookbooks/` and `/api/v3/cookbooks/<slug>/`

traffic grouped by =client_name=/=client_version= (already tagged on

every request via =get_client_name_and_version=,

=app/utils.py:1543-1574=, visible on =request_finished= logs).

- The flag is safe to flip only once production traffic shows every active

app version is running Phase-1-or-later parsing. In practice this means

waiting out the long tail of users who haven't updated — app-store

adoption, not a backend timeline.

- If adoption stalls, that's a product/mobile call (forced upgrade prompt,

extended soak, etc.) — not something backend can resolve unilaterally.

Phase 3 — Flip =COOKBOOK_LIST_REMOVE_DEPRECATED_FIELDS=

- Toggle the dynamic config to =1= — no deploy required.

- Effective immediately, for all clients: =author= and =sections= vanish

from both list and detail responses.

- Recommend flipping during a lower-traffic window first and watching

mobile's crash/error monitoring briefly before considering it settled.

- Rollback: flip back to =0=, equally instant, if anything unexpected shows

up.

Phase 4 — Cleanup (optional, separate ticket)

Once the flag has stayed on through a full soak period with no rollback,

consider removing the flag branch and the dead =author=/=sections=/

=get_sections= code from =CookbookListSerializer= entirely, closing out

BE-7924 for good. Not urgent — leaving the flag permanently on is a

perfectly fine steady state indefinitely; this phase is pure tech-debt

cleanup whenever someone has the time.

Open Questions (mobile-owned — backend can't answer these)

- Exact inventory of screens/features touching =author=/=sections= today

(Phase 0).

- Do iOS and Android need to ship Phase 1 in the same release, or can they

land independently? The flag is global, so whichever platform is slower

gates Phase 3 regardless of the other's readiness.

- Is there a forced-upgrade / minimum-supported-version mechanism, or does

this wait out the natural update tail?

- What crash/error dashboard should backend watch (or be looped into)

during the Phase 3 soak window?

- How is =author= display data (name, slug, avatar) actually used today —

does resolving it from =author_id= alone require a new lookup, or is it

already cached client-side from another call?

Verification (backend side, once the flag is flipped)

1. ~python manage.py test cookbooks.tests.test_api.CookbookV3ReadAPITestCase --keepdb~

— exercises the existing DC-gated coverage

(=test_cookbook_list_removes_author_and_sections_when_dynamic_config_enabled=

and siblings).

2. Manual: hit both endpoints with the config overridden on locally,

confirm =author=/=sections= are absent and =author_id=/=pairing_ids=/

=product_ids=/=section_ids= are present.