feat(api): complete the /api/v1 iOS layer - #4
Open
VictorHarri-Chal wants to merge 48 commits into
Open
Conversation
… 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
- 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.
Closed
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
force-pushed
the
feature/api-v1-ios
branch
from
August 13, 2026 13:52
64eb702 to
f34a74f
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Brings
/api/v1/up to date withmain(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:
{"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 answered200without applying anything. Fixed, and now frozen by all four cells of the matrix. Still doesn't hold for form-encoded bodies.food_name,recipe_name,exercise_name— never the association. Logs carry a frozen copy. Deleting a food detaches its logs (food_id→null) without erasing history; thedetachedflag tells you. Reading through the association makes deleted items vanish from your screens.total_caloriesand friends are stored, not derived at read time. Recomputing gives numbers that differ from the website.GET /exercise_metadatagives each machine'srequired_fields, and omitting one returns a 422 whose cause isn't guessable — a treadmill needs speed and incline, an outdoor run only speed.Also worth knowing:
GET /statisticsserves one tab per request and supportsETag/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 carriesRetry-After.Your review of the old PR (#14)
Your
PR4-REVIEW-FINDINGS.mdwas 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 (unassignableage, decimals serialised as strings, the native bundle-id audience on Apple tokens, the onboarding redirect hitting API requests, the missingdevise.mappingonPOST /sessions, the Training API still on the dropped flat columns,confirmation_requiredon registration,food_idownership — 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:
/settings. Three different failure modes: silent no-op onPATCH /profile, an empty record created onPOST /shopping_lists, a clean422onPOST /weight_entries.origins "*"in production.db/seeds.rbwas broken — inherited frommain, not from this branch. All three dinner-recipe branches raisedUnknownAttributeErrorsince 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:
maintenance→maintain,desk_job→sedentary). 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_directionreturning 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 /profilereturnsavailable_goalsandavailable_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,200on logout,allergens, automatic SSO account linking,per_pagecapped 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:
Foodhas 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 (onlyGET /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_idwas 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/v2alongside.Before deploying (Victor)
Nothing is needed to merge. To deploy,
DEVISE_JWT_SECRET_KEYmust be exported in the shell that runs it —config/deploy.ymlnow declares it, sokamal deploywill stop with a clear error if it's missing. Without it, devise-jwt silently falls back tosecret_key_base.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:
route_inventory_testauth_matrix_testscoping_matrix_testhappy_path_matrix_testshape_parity_testnumeric_types_sweep_testerror_contract_testinput_robustness_testwritable_field_bounds_testfeature_guard_matrix_testrate_limiting_testcontract_claims_testTwo audit tools, not run by default:
scripts/route_coverage.shseparates "exercised" from "exercised successfully" (it found 32 actions that had never received a valid request), andscripts/mutate.shbreaks a line on purpose to check a test actually catches it.