Skip to content

feat(api): complete the /api/v1 iOS layer - #4

Open
VictorHarri-Chal wants to merge 48 commits into
mainfrom
feature/api-v1-ios
Open

feat(api): complete the /api/v1 iOS layer#4
VictorHarri-Chal wants to merge 48 commits into
mainfrom
feature/api-v1-ios

Conversation

@VictorHarri-Chal

@VictorHarri-Chal VictorHarri-Chal commented Jul 4, 2026

Copy link
Copy Markdown
Owner

Brings /api/v1/ up to date with main (the branch was 243 commits behind) and finishes the layer: 189 routes, 1 283 tests, 0 failures.

The full reference is docs/api/CONTRACT.md — 13 sections, two of them generated from the code, written in English.


For Carlos

Read sections 1 to 7 of the contract before the endpoint reference. That's where the things you can't guess from a route list are.

The five that will bite you otherwise:

  1. Send JSON, in either envelope. {"weight_kg": 80} and {"weight_entry": {"weight_kg": 80}} both apply, on every write endpoint. This used to be half-true: a wrapped body reaching a flat endpoint answered 200 without applying anything. Fixed, and now frozen by all four cells of the matrix. Still doesn't hold for form-encoded bodies.
  2. Read food_name, recipe_name, exercise_name — never the association. Logs carry a frozen copy. Deleting a food detaches its logs (food_idnull) without erasing history; the detached flag tells you. Reading through the association makes deleted items vanish from your screens.
  3. Don't recompute totals. total_calories and friends are stored, not derived at read time. Recomputing gives numbers that differ from the website.
  4. Don't hardcode any enum. The API publishes every list (§2, "Allowed-value lists"). Cardio machines matter most: GET /exercise_metadata gives each machine's required_fields, and omitting one returns a 422 whose cause isn't guessable — a treadmill needs speed and incline, an outdoor run only speed.
  5. Every 401 means "session over". There is no refresh token; tokens last 30 days and you re-authenticate. Don't treat a 401 as a transient error to retry.

Also worth knowing: GET /statistics serves one tab per request and supports ETag / If-None-Match — a 365-day window is an expensive aggregation, don't redo it on every screen open. Rate limits apply to the five auth endpoints only (§2); a 429 carries Retry-After.

Your review of the old PR (#14)

Your PR4-REVIEW-FINDINGS.md was worked through point by point against the current branch. It held up well: 14 of your findings had already been fixed independently on this branch (unassignable age, decimals serialised as strings, the native bundle-id audience on Apple tokens, the onboarding redirect hitting API requests, the missing devise.mapping on POST /sessions, the Training API still on the dropped flat columns, confirmation_required on registration, food_id ownership — now enforced by a shared validator on all five paths — and the "no tests at all", which is now 1 283).

Three of your findings were real and not covered here. They're fixed in this PR:

  • C-9, and wider than you found it. Nine controllers ignored a wrapped body, not just /settings. Three different failure modes: silent no-op on PATCH /profile, an empty record created on POST /shopping_lists, a clean 422 on POST /weight_entries.
  • M-4. CORS was still origins "*" in production.
  • db/seeds.rb was broken — inherited from main, not from this branch. All three dinner-recipe branches raised UnknownAttributeError since the cooked-weight refactor. Verified fixed by running the whole seed end-to-end.

Your ngrok host allowlist is in too (with anchored regexes — unanchored ones also match ngrok-free.dev.example.com).

Two things were not taken:

  • RSpec + factory_bot. The project is Minitest; a second test stack in parallel isn't something we'll maintain. Your instinct was right, though — there genuinely were zero API tests when you looked.
  • The enum renames (maintenancemaintain, desk_jobsedentary). These are the values stored in the database and shown on the website; renaming touches the model, four views, four locale files, a Stimulus controller and needs a data migration. Note that the two follow-up bugs you found (implied_goal_direction returning an invalid value, desk_job "stale" in _form_fields) only exist because of the rename — both are correct and consistent on this branch, verified. Never hardcode those lists: GET /profile returns available_goals and available_job_activity_levels.

Everything else on your list was a shape difference rather than a defect. Those are now documented with their reasons in §2 "Deliberate shape choices" of the contract — root key data, flat /profile, PATCH toggle_favorite, /auth/apple, 200 on logout, allergens, automatic SSO account linking, per_page capped at 100. The web app is the functional source of truth and the API exposes it; a shape that diverged from the web would mean two behaviours to maintain for one rule. Read that table before opening any of them again.

Still open as product decisions, not API work: Food has no image of any kind, so there's no product photo to show, and there's no way to filter your own foods by barcode (only GET /foods/lookup?barcode=, which now returns the full matching food, so scanning something you already own no longer needs a second request).

The contract freezes at your first TestFlight

This branch was never deployed, so the contract was reshaped freely — 71 response views changed, endpoints changed shape, apple.client_id was renamed. That window closes the moment a build reaches a real device: an installed app keeps asking for the field names it knows, and no server change can reach it.

After that: fields get added, never renamed or removed. If you need a breaking change, say so and we ship /api/v2 alongside.


Before deploying (Victor)

Nothing is needed to merge. To deploy, DEVISE_JWT_SECRET_KEY must be exported in the shell that runs it — config/deploy.yml now declares it, so kamal deploy will stop with a clear error if it's missing. Without it, devise-jwt silently falls back to secret_key_base.

export DEVISE_JWT_SECRET_KEY=<same value as the worktree .env>
bin/kamal secrets print | grep DEVISE_JWT_SECRET_KEY   # confirm it resolves
bin/kamal deploy
bin/kamal app exec --reuse "bin/rails runner 'puts ENV[%q(DEVISE_JWT_SECRET_KEY)].to_s.length'"  # expect 128

Rate limits only run in production, so the deploy is their first real test.


What's covered

Twelve test files act as a firewall, each answering a different question:

Guarantee Test
Declared routes exist, and existing routes are declared route_inventory_test
No route is reachable without a token auth_matrix_test
No route serves another user's data scoping_matrix_test
Every endpoint actually works, not merely refuses happy_path_matrix_test
An object has the same shape on create, update and read shape_parity_test
No number is serialised as a string numeric_types_sweep_test
Error shapes and the frozen list of 19 machine codes error_contract_test
No malformed input produces a 500 input_robustness_test
Every writable text field is bounded writable_field_bounds_test
Disabled sections answer 403 feature_guard_matrix_test
Auth rate limits cover the API, not only the web rate_limiting_test
The contract's own claims are true, including its counts contract_claims_test

Two audit tools, not run by default: scripts/route_coverage.sh separates "exercised" from "exercised successfully" (it found 32 actions that had never received a valid request), and scripts/mutate.sh breaks a line on purpose to check a test actually catches it.

… views

- Add devise-jwt (JTIMatcher revocation), rack-cors, omniauth-apple/google
- Add jti column to users (UUID backfill before unique index)
- Add identities table for Apple/Google SSO (provider + uid)
- Api::V1::BaseController with JWT auth, null_session CSRF, Pagy helper
- Sessions + Registrations controllers (Devise with JWT dispatch)
- Passwords controller (forgot/change), SSO auth controller (Apple JWKS + Google tokeninfo)
- Full CRUD: foods, food_labels, day_food_groups, recipes, recipe_items
- Recipe ratings (1–5, one per user), recipe favorites, add to shopping list
- Days calendar: index, show (eager-loaded), update, update_water, update_steps, copy_yesterday
- Day entries: day_foods and day_recipes (nested under /days/:date/)
- Workout sessions + sets (auto-PR detection, MET calorie estimation)
- Cardio sessions + blocks (ACSM equations, user weight injection via .target)
- Exercises: index/show/create/update/destroy, search, favorites, recents, last_performance
- Workout programs: full CRUD, activate, duplicate, program_days, program_exercises (reorder, move)
- Weight entries, shopping lists + items, profile, settings, account delete
- Statistics endpoint: nutrition, training, cardio, wellbeing (period=7|30|90|365)
- Fix N+1 in statistics wellbeing_stats (weight_entries preloaded, days query deduped)
- Fix cardio inject_weight to use .target for new nested records
- Fix recipe show to preload recipe_ratings and avoid double query
- OmniAuth web callbacks for Apple + Google (web SSO flow)
- CORS configured for /api/* with Authorization header exposed
- DEVISE_JWT_SECRET_KEY env var documented in .env.example
Web (desktop app):
- Ajoute boutons "Continuer avec Apple/Google" sur les pages de
  connexion et d'inscription via la partial devise/shared/_omniauth
- Fix: private_key → pem dans config.omniauth :apple (omniauth-apple
  utilise options.pem, pas options.private_key — causait curve_name nil)

iOS API (auth_controller):
- Validation du claim aud (Bundle ID) dans verify_apple_token
- Cache Rails 1h sur le JWKS Apple (évite un appel réseau à chaque auth)
- rescue JWT::ExpiredSignature / JWT::DecodeError au lieu de StandardError
- find_or_create_from_sso retourne [user, is_new] — réponse JSON inclut
  is_new_user: bool pour l'onboarding côté Carlos
@VictorHarri-Chal VictorHarri-Chal changed the title feat: implement /api/v1/ iOS API — JWT auth, 29 controllers, jbuilder… feat: implement /api/v1/ iOS API Jul 5, 2026
@VictorHarri-Chal VictorHarri-Chal changed the title feat: implement /api/v1/ iOS API feat: implement /api/v1/ iOS API + SSO Jul 5, 2026
- days > day_foods, day_recipes (by :day_date param)
- days > workout_sessions > workout_sets
- days > cardio_sessions > cardio_blocks
- recipes > recipe_items, recipe_ratings
- workout_programs > program_days > program_exercises
- shopping_lists > shopping_list_items

Member action URLs (update/destroy) are now flat, no parent param needed.
Create actions remain nested under their parent.

Controllers use user-scoped joins to authenticate member actions without
relying on URL-provided parent IDs.
@Sotilrac1 Sotilrac1 mentioned this pull request Jul 7, 2026
Phase 0 — rebase et fondations
- Merge de main (243 commits) : Gemfile, user.rb, routes.rb, schema.rb résolus
- Migrations jti / identities redatées après celles de main, + password_set_at
- Purge de 2 lignes orphelines dans schema_migrations : les versions étaient
  marquées « up » alors que les colonnes n'existaient pas, donc db:migrate les
  aurait sautées en silence

Phase 1 — débloquer l'API
- BaseController : skip du portillon d'onboarding (302 HTML sur toute l'API),
  plafond dur de pagination (full_result était une requête non bornée),
  require_feature! et parse_api_date! stricts
- User : invalidate_other_sessions! fait aussi tourner le jti (sinon les tokens
  mobiles survivaient à « déconnecter mes autres sessions »), password_usable?

Sécurité — IDOR corrigé (confirmé en exécution avant correctif)
- recipe_ratings ne scopait que la recette parente : le propriétaire d'une
  recette pouvait modifier ou supprimer la note laissée par un autre utilisateur

Suite de tests — socle + matrices
- test/support : ApiTestCase, ApiRouteInventory (162 routes déclarées),
  ApiFixtures
- route_inventory_test : échoue si une route existe sans être déclarée
- auth_matrix_test : 128 routes protégées × sans token / token malformé /
  token révoqué
- scoping_matrix_test : 80 routes membres testées contre l'accès d'autrui
- shared_resource_scoping_test : couvre l'angle mort de la matrice (parent et
  enfant appartenant à deux utilisateurs différents)
@VictorHarri-Chal VictorHarri-Chal changed the title feat: implement /api/v1/ iOS API + SSO feat(api): complete the /api/v1 iOS layer Aug 13, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant