# Rails 5 API : modéliser les disponibilités d'une marketplace de services > Comment on a construit l'API Rails 5 d'une marketplace multi-services : calcul de disponibilités en temps réel, réservations récurrentes avec AASM, et profils polymorphiques pour des verticales métier hétérogènes. Date : 05/11/2016 Auteurs : Raphael P., Aurélien N. Tags : Ruby on Rails, Architecture, API, Backend --- Rails 5 est sorti en juin — et on n'a pas attendu pour migrer. Quatre mois plus tard, on boucle le premier gros projet qu'on a construit dessus : [Hively](/blog/beespoke-marketplace-collaborative-ionic-rails), une marketplace de services à domicile. L'app mobile (Ionic) communique avec une API Rails qui gère les prestataires, les disponibilités, les réservations, le chat en temps réel via Action Cable et le back-office. C'est un projet qu'on connaît bien chez imagine — on avait déjà construit le [backend Rails de Simone.paris](/blog/simone-paris-rails-backend-app-ios) l'an dernier, une app de réservation de coiffure à domicile, et plusieurs autres projets de ce type depuis. Hively va nettement plus loin : multi-services (ménage, fitness, garde d'enfants, pet-care, beauté), réservations récurrentes, communautés, et un moteur de recherche de prestataires avec calcul de disponibilités en temps réel. On a construit une architecture polymorphique data-driven qui permet d'ajouter un nouveau service sans toucher une ligne de code applicatif — uniquement de la configuration. Cet article détaille trois aspects de l'architecture backend qui illustrent pourquoi Rails reste, à notre avis, le framework le plus productif pour ce type de projet. ## Rails 5 en mode API : le bon timing ### Ce qu'on faisait avant — et pourquoi c'était pénible On utilisait déjà Rails comme backend d'apps mobiles — c'est ce qu'on fait depuis [Simone](/blog/simone-paris-rails-backend-app-ios). Mais jusqu'à Rails 4.2, construire une API propre demandait du travail supplémentaire. **Option 1 : Grape.** On a testé Grape, le framework Ruby spécialisé API, sur un projet précédent. L'idée est séduisante — un DSL dédié, une documentation Swagger auto-générée, un routage indépendant de Rails. En pratique, on se retrouve avec deux frameworks dans la même app (Grape pour l'API, Rails pour le back-office), deux systèmes de routing, deux façons de gérer les erreurs, et des modèles qui doivent servir les deux. La maintenance devient un casse-tête. **Option 2 : Rails allégé à la main.** C'est ce qu'on faisait sur Simone. On partait d'un `rails new` classique, puis on passait une demi-journée à déshabiller le framework : ```ruby # Ce qu'on devait faire en Rails 4.2 — à la main, à chaque projet config.middleware.delete ActionDispatch::Cookies config.middleware.delete ActionDispatch::Session::CookieStore config.middleware.delete ActionDispatch::Flash config.middleware.delete Rack::MethodOverride # Supprimer les générateurs de vues config.generators do |g| g.template_engine nil g.assets false g.helper false end # Ignorer l'asset pipeline dans les contrôleurs # Et prier pour que le prochain gem qu'on installe # n'assume pas que les vues existent... ``` Fragile, non documenté, et chaque gem tierce pouvait casser en assumant la présence des middlewares qu'on avait supprimés. ActiveAdmin, par exemple, avait besoin des sessions — il fallait les réactiver chirurgicalement pour le namespace `/admin`. **Option 3 : rails-api.** La gem `rails-api` de Santiago Pastorino — un précurseur du mode API officiel. On l'a utilisée brièvement, mais la gem restait un patch non officiel sur Rails, avec un risque de compatibilité à chaque montée de version. ### Rails 5 : enfin officiel Rails 5 met fin à ce bricolage. `rails new hively --api` génère un projet allégé nativement : ```ruby # application.rb — Rails 5 API mode module Hively class Application < Rails::Application config.api_only = true end end ``` Ce flag supprime les middlewares de session, de cookies, de CSRF, de flash — tout ce qui concerne le rendu HTML. Le contrôleur de base hérite de `ActionController::API` au lieu de `ActionController::Base`. Le projet démarre léger, et on n'ajoute que ce dont on a besoin. En pratique, on a quand même besoin de sessions pour le back-office ActiveAdmin — on réactive les middlewares cookie/session pour le namespace `/admin` uniquement. Le reste de l'app est purement stateless, avec une authentification par token Bearer. ### L'authentification par token On a construit un système de tokens custom plutôt que d'utiliser Devise pour l'API. Devise est génial pour l'authentification web (on l'utilise pour les prestataires et le back-office), mais pour une API mobile, un token simple et robuste suffit : ```ruby # app/models/access_token.rb class AccessToken < ApplicationRecord ACCESS_TOKEN_EXPIRATION_DELAY = 3.months belongs_to :authenticatable, polymorphic: true scope :active, -> before_create :generate_token def self.find_by_access_token(clear_token) token = active.find_by( token_digest: Digest::SHA256.hexdigest(clear_token) ) token&.touch(:last_accessed_at) token end private def generate_token clear_token = SecureRandom.urlsafe_base64(96) self.token_digest = Digest::SHA256.hexdigest(clear_token) self.clear_token = clear_token # attribut virtuel, renvoyé une seule fois self.expires_at = ACCESS_TOKEN_EXPIRATION_DELAY.from_now end end ``` Le token clair (128 caractères) est renvoyé au client une seule et unique fois, à la création. Côté serveur, on ne stocke que le digest SHA-256. Si la base de données est compromise, les tokens sont inutilisables. Le concern `TokenAuthenticatable` est inclus dans les modèles `User` et `Provider` — polymorphisme classique Rails. Le concern côté contrôleur intercepte chaque requête et charge l'utilisateur courant : ```ruby # app/controllers/concerns/token_authentication.rb module TokenAuthentication extend ActiveSupport::Concern included do before_action :authenticate_with_token! end def current_user @current_user ||= lookup_user_from_token end private def lookup_user_from_token token = request.headers['Authorization']&.gsub(/^Bearer /, '') return nil unless token.present? AccessToken.find_by_access_token(token)&.authenticatable end end ``` C'est volontairement simple. Pas de JWT (on n'a pas besoin de claims embarqués dans le token), pas de refresh token (l'expiration à trois mois suffit pour une app mobile), pas d'OAuth2 complet (surdimensionné pour un projet mono-client). Le token est un identifiant opaque — il identifie, il n'autorise pas. ### Les concerns : notre arme secrète Rails 4 avait introduit les concerns — des modules réutilisables inclus dans les modèles. Sur Hively, on a poussé ce pattern plus loin que sur aucun projet précédent. Chaque aspect transversal du domaine est encapsulé dans un concern : ```ruby # Le TokenAuthenticatable fonctionne pour User ET Provider module TokenAuthenticatable extend ActiveSupport::Concern included do has_many :access_tokens, as: :authenticatable, dependent: :destroy end def generate_access_token! access_tokens.create! end def invalidate_tokens! access_tokens.active.update_all(canceled_at: Time.current) end end # Le SocialAuthenticatable gère Facebook pour les deux modèles module SocialAuthenticatable extend ActiveSupport::Concern class_methods do def find_or_create_from_social_profile!(provider, profile) social = SocialId.find_by(provider: provider, social_id: profile[:id]) return social.social_authenticatable if social ActiveRecord::Base.transaction do user = create!( email: profile[:email], first_name: profile[:first_name], last_name: profile[:last_name] ) user.social_ids.create!(provider: provider, social_id: profile[:id]) user end end end end ``` Le résultat : `User` et `Provider` partagent exactement le même code d'authentification. Quand on a ajouté la connexion Facebook pour les prestataires (deux mois après la livraison initiale pour les utilisateurs), il a suffi d'ajouter `include SocialAuthenticatable` dans le modèle Provider. Zéro duplication. On a huit concerns sur ce projet : `TokenAuthenticatable`, `SocialAuthenticatable`, `Profilable`, `Postcodes`, `Password`, `Username`, `Referrable`, `UserFavorite`. Chacun encapsule une responsabilité unique. Le modèle `User` fait 50 lignes — pas 500. ### ApplicationRecord : le nouveau standard Un détail de Rails 5 qui simplifie les concerns : `ApplicationRecord`. Avant, tous les modèles héritaient de `ActiveRecord::Base` directement — il n'y avait pas de classe intermédiaire pour mutualiser du comportement. Si on voulait ajouter une méthode à tous les modèles, on devait monkey-patcher `ActiveRecord::Base` (dangereux) ou créer un concern inclus partout (verbeux). Rails 5 introduit `ApplicationRecord` — une classe abstraite qui s'intercale entre les modèles et `ActiveRecord::Base`. On y met les comportements communs à tous nos modèles : les conventions de sérialisation JSON, les scopes partagés, les callbacks de logging. C'est propre, explicite, et ça ne pollue pas le framework. ### Le safe navigation operator `&.` Ruby 2.3 (sorti en décembre dernier) nous a donné `&.` — l'opérateur de navigation sûre. On l'utilise partout dans les contrôleurs API : ```ruby # Avant — verbeux et défensif def current_user token = AccessToken.find_by_access_token(raw_token) if token token.authenticatable else nil end end # Après — une ligne, idiomatique def current_user AccessToken.find_by_access_token(raw_token)&.authenticatable end ``` Ce n'est pas spécifique à Rails, mais Ruby 2.3 + Rails 5 forment un combo où chaque ligne de code est plus concise et plus sûre. Le `&.` élimine les `nil` checks défensifs qui polluaient nos contrôleurs — et chaque ligne gagnée est une ligne de moins à maintenir. ## Le moteur de disponibilités C'est le morceau le plus intéressant de l'architecture — et le plus complexe. ### Le problème Un utilisateur cherche un prestataire de ménage disponible mardi prochain entre 9h et 12h dans le quartier de Shoreditch. L'API doit : 1. Trouver les prestataires qui couvrent ce code postal 2. Qui proposent le service "ménage" 3. Qui sont disponibles sur ce créneau — c'est-à-dire qui ont déclaré être disponibles le mardi, qui n'ont pas posé de congé ce jour-là, et qui n'ont pas déjà une réservation confirmée sur cette plage Les deux premiers filtres sont triviaux (des `WHERE` sur la base). Le troisième est un calcul. ### La composition de plages horaires On a développé une classe `RangeComposition` qui manipule des ensembles de plages numériques. Le principe est simple : on part des disponibilités du prestataire, on soustrait les congés et les réservations, on obtient les créneaux libres. ```ruby class RangeComposition attr_accessor :ranges def initialize(exclude_border: false) @ranges = [] @exclude_border = exclude_border end def add(range_or_ranges) Array(range_or_ranges).each self end def remove(range_or_ranges) Array(range_or_ranges).each self end private def add_range(new_range) before, overlapping, after = @ranges.partition_three merged_begin = [new_range.begin, *overlapping.map(&:begin)].min merged_end = [new_range.end, *overlapping.map(&:end)].max @ranges = before + [merged_begin..merged_end] + after end def remove_range(other) @ranges = @ranges.flat_map do |range| if overlaps?(range, other) remaining = [] remaining << (range.begin..other.begin) if range.begin < other.begin remaining << (other.end..range.end) if other.end < range.end remaining else [range] end end end def overlaps?(a, b) a.begin <= b.end && b.begin <= a.end end end ``` L'algorithme de `add_range` est le point délicat. Quand on ajoute une plage, il faut fusionner toutes les plages existantes qui la chevauchent. On partitionne les plages en trois groupes : celles qui sont avant (pas de chevauchement), celles qui chevauchent, et celles qui sont après. Les plages qui chevauchent sont fusionnées en prenant le `min` des débuts et le `max` des fins. `remove_range` fait l'inverse : pour chaque plage existante qui chevauche la plage à retirer, on coupe et on garde les morceaux restants. ### L'assemblage dans le modèle Provider Le modèle `Provider` utilise `RangeComposition` pour calculer ses créneaux disponibles : ```ruby class Provider < ApplicationRecord def slots(starting_day: 1, ending_day: 14) composition = RangeComposition.new(exclude_border: true) # 1. Ajouter les disponibilités hebdomadaires composition.add(available_ranges( starting_day: starting_day, ending_day: ending_day )) # 2. Soustraire les congés composition.remove(holidays_ranges) # 3. Soustraire les réservations confirmées composition.remove(booked_availability_ranges) # 4. Soustraire le passé (+ buffer de 12h) composition.remove([0..12.hours.from_now.to_i]) # 5. Convertir en créneaux exploitables composition.ranges.map do |range| end end private def available_ranges(starting_day:, ending_day:) working_days(starting_day: starting_day, ending_day: ending_day) .flat_map do |date| availabilities.active.where(weekday: date.wday).map do |a| day_start = date.beginning_of_day.to_i (day_start + a.beginning)..(day_start + a.ending) end end end end ``` Le `exclude_border: true` est subtil. Quand un prestataire a un créneau de 9h à 12h et un autre de 12h à 15h, on ne veut pas les fusionner en un seul créneau de 9h à 15h — 12h est une frontière intentionnelle (pause déjeuner, par exemple). L'option `exclude_border` considère que deux plages qui se touchent exactement ne se chevauchent pas. ### La validation de créneau Quand l'utilisateur réserve un créneau, on ne se contente pas de vérifier que le créneau est dans les disponibilités — on vérifie qu'il respecte les contraintes métier : ```ruby class AbstractBooking < ApplicationRecord validate :beginning_at_is_a_valid_multiple_of_30_minutes validate :ending_at_is_a_valid_multiple_of_15_minutes validate :datetime_in_slots private def beginning_at_is_a_valid_multiple_of_30_minutes return unless beginning_at unless beginning_at.min % 30 == 0 errors.add(:beginning_at, "must start on a 30-minute slot") end end def datetime_in_slots return unless provider && beginning_at unless provider.datetime_in_slots?(beginning_at, booking_id: id) errors.add(:beginning_at, "is not in provider's available slots") end end end ``` Les créneaux commencent toutes les 30 minutes (9:00, 9:30, 10:00…) — c'est un compromis entre granularité et complexité d'interface. La validation `datetime_in_slots` rappelle le calcul de disponibilité du provider en excluant la réservation courante (pour permettre la modification). ## La machine à états des réservations ### AASM pour les transitions Une réservation n'est pas un simple enregistrement en base — c'est un objet métier avec un cycle de vie. On utilise **AASM** (Acts As State Machine) pour modéliser les transitions et les effets de bord : ```ruby class AbstractBooking < ApplicationRecord include AASM aasm column: :state do state :pending, initial: true state :confirmed, :declined, :cancelled, :completed event :confirm do transitions from: :pending, to: :confirmed after do FeedService.booking_request_accepted(self) ReminderBookingJob.set(wait_until: beginning_at - 2.hours) .perform_later(id) end end event :decline do transitions from: :pending, to: :declined after end event :cancel do transitions from: [:pending, :confirmed], to: :cancelled after end event :complete do transitions from: :confirmed, to: :completed after end end end ``` AASM apporte trois choses qu'un simple champ `status` ne donne pas : 1. **Les transitions sont déclaratives.** On ne peut pas passer de `declined` à `confirmed` — AASM lève une exception. Pas besoin de `if/else` défensif dans les contrôleurs. 2. **Les callbacks sont liés aux transitions.** L'email de confirmation part quand on passe de `pending` à `confirmed`, pas quand le champ `state` contient `confirmed`. La nuance est importante : un état restauré manuellement en base ne déclenche pas les effets de bord. 3. **Les scopes sont générés automatiquement.** `Booking.confirmed`, `Booking.pending` — pas besoin de les écrire. ### Les réservations récurrentes Un cas métier qui a demandé du travail : les plans de réservation. Un utilisateur peut réserver un ménage hebdomadaire — tous les mardis de 9h à 12h. Le `BookingPlan` génère les `Booking` enfants : ```ruby class BookingPlan < AbstractBooking has_many :bookings def create_bookings(start_date = beginning_at) book_datetimes(start_date).each do |datetime| bookings.create!( user: user, provider: provider, service: service, beginning_at: datetime, ending_at: datetime + duration.minutes, duration: duration, address: address, options: options, state: state # les enfants héritent de l'état du plan ) end end end ``` Le job `BookingPlanJob` est déclenché quand le plan est confirmé. Il génère les réservations individuelles pour les prochaines semaines, en vérifiant la disponibilité du prestataire pour chaque créneau. Si un créneau est déjà pris, il est sauté — pas de conflit silencieux. ## Les profils polymorphiques ### Le défi du multi-services Un prestataire de ménage a un tarif horaire et des options (repassage, linge, matériel fourni). Un coach fitness propose du yoga, du pilates ou du personal training en sessions de 30 ou 60 minutes avec des tarifs différents. Un dog-walker distingue les balades solo et en groupe, par taille de chien. On ne peut pas mettre tout ça dans un seul modèle `Provider`. La solution : des **profils polymorphiques** — un modèle par vertical, liés au provider par une association `belongs_to :profile, polymorphic: true`. ### Le concern Profilable et la configuration YAML La vraie astuce, c'est le concern `Profilable` combiné avec un fichier `profiles.yml`. Plutôt que de coder en dur les attributs de chaque profil, on les déclare dans un YAML : ```yaml # config/profiles.yml cleaning: attributes: hourly_rate: type: MinMax min: 8 max: 50 step: 1 ironing: type: Boolean laundry: type: Boolean brings_equipment: type: Boolean booking_options: - ironing - extra_clean dog_walking: attributes: solo_30_rate: type: MinMax min: 5 max: 30 group_30_rate: type: MinMax min: 3 max: 20 large_dogs: type: Boolean max_dogs: type: Direct booking_options: - walk_type - dog_size ``` Le concern `Profilable` charge cette configuration au boot et génère dynamiquement les validations, les scopes de filtrage et les attributs exposés par l'API : ```ruby module Profilable extend ActiveSupport::Concern included do has_one :provider, as: :profile def self.profile_config Rails.application.config_for(:profiles)[service_key] end def self.filtrable_attributes profile_config['attributes'].select end def self.displayable_attributes profile_config['attributes'].select end end def short_description # Génère une description lisible à partir des attributs actifs active_attrs = self.class.displayable_attributes.keys .select active_attrs.map(&:humanize).join(', ') end end ``` L'avantage est considérable : quand on a ajouté la verticale "beauté" en cours de projet, on a créé une migration pour la table `profile_blow_dries`, ajouté une entrée dans `profiles.yml`, et c'était en ligne. Pas de changement dans le contrôleur API, pas de changement dans le `FilterService`, pas de changement dans le back-office — tout est piloté par la configuration. ### Le FilterService Le `FilterService` lit la configuration YAML pour savoir quels filtres appliquer à chaque service. Un filtre `MinMax` sur le tarif devient un `WHERE hourly_rate_cents BETWEEN ? AND ?`. Un filtre `Boolean` sur "repassage" devient un `WHERE ironing = true`. Le service encapsule cette logique : ```ruby class FilterService def initialize(providers, params, service) @providers = providers @params = params @config = service.profile_class.constantize.profile_config end def filter @config['attributes'].each do |attr_name, attr_config| value = @params[attr_name] next unless value.present? @providers = case attr_config['type'] when 'Boolean' @providers.where(attr_name => cast_boolean(value)) when 'MinMax' @providers.where("#_cents >= ? AND #_cents <= ?", value['min'].to_i * 100, value['max'].to_i * 100) when 'ChoiceSet' @providers.where("# = ANY(?)", value) end end @providers end end ``` C'est du Rails idiomatique — on compose des scopes ActiveRecord. La requête SQL finale est construite par enchaînement de `where`, et ActiveRecord s'occupe de l'optimisation. Sur un dataset de quelques centaines de prestataires par zone géographique, c'est instantané. ## Sidekiq : les jobs en arrière-plan Le projet utilise cinq jobs Sidekiq, chacun avec une responsabilité claire : ```ruby # Génère les réservations d'un plan récurrent class BookingPlanJob < ApplicationJob def perform(booking_plan_id) plan = BookingPlan.find(booking_plan_id) plan.create_bookings end end # Annule les réservations pending après délai class CancelBookingJob < ApplicationJob def perform(booking_id) booking = AbstractBooking.find(booking_id) return unless booking.pending? booking.cancel! end end # Rappel 2h avant la prestation class ReminderBookingJob < ApplicationJob def perform(booking_id) booking = AbstractBooking.find(booking_id) return unless booking.confirmed? FeedService.reminder_booking_upcoming(booking) end end ``` Le pattern est toujours le même : le job reçoit un ID, recharge l'objet, vérifie l'état, agit. Le `return unless booking.pending?` est important — entre le moment où le job est planifié et le moment où il s'exécute, l'état peut avoir changé. Un job doit être idempotent et résistant aux changements d'état. Sidekiq tourne sur Redis avec six workers en concurrence. Les mailers ont leur propre queue avec une priorité inférieure — un pic d'envoi d'emails ne bloque pas les jobs métier. ## Ce qu'on en retient Après Simone.paris, Hively confirme que **Rails 5 est le bon choix pour les API mobiles** — et qu'on avait raison de migrer dès la sortie. Le mode API officialise une pratique qu'on défend depuis deux ans. Action Cable intègre le temps réel sans dépendance externe. La commande unifiée `rails` élimine une source de confusion quotidienne. Là où d'autres équipes hésitent encore entre Express.js, Sails ou Laravel pour leurs backends d'apps mobiles, on livre des projets complets en Rails avec un écosystème qui n'a pas d'équivalent : Devise pour l'auth, AASM pour les machines à états, Sidekiq pour les jobs asynchrones, ActiveAdmin pour le back-office, CarrierWave pour les uploads, Mandrill et Nexmo pour les notifications. Tout s'emboîte. On ne passe pas trois jours à choisir et câbler des briques — on les branche et on se concentre sur le métier. Les patterns qu'on recommande : 1. **Tokens plutôt que sessions** pour les APIs mobiles. Pas besoin de JWT — un token opaque avec digest SHA-256 couvre 95% des cas. Plus simple, plus sûr, plus facile à révoquer. 2. **AASM pour tout ce qui a un cycle de vie.** Les réservations, les inscriptions de prestataires, les candidatures — dès qu'un objet passe par des états, AASM rend les transitions explicites et les effets de bord prévisibles. On l'utilise systématiquement sur tous nos projets depuis Simone. 3. **Le polymorphisme Rails, avec modération.** Les profils polymorphiques nous ont sauvé la mise sur un projet multi-services. Mais on ne polymorphise que ce qui est vraiment hétérogène — les adresses, les tokens, les participants de conversation sont polymorphiques aussi, et ça ajoute de la complexité au schéma. 4. **La configuration YAML plutôt que le code** pour les attributs métier qui varient par verticale. C'est l'approche la plus ambitieuse du projet — et la plus payante. Ajouter un service sans toucher au code applicatif, c'est la différence entre une demi-journée et une semaine de développement. Rails 5.1 est déjà annoncé avec l'intégration native de Webpack via Webpacker et le support de Yarn. La direction est claire : Rails embrasse l'écosystème JavaScript frontend au lieu de le combattre. C'est exactement ce qu'on fait déjà — on a juste un peu d'avance. Le prochain article portera sur le front Ionic — [le pipeline Webpack et ES6 qu'on a mis en place](/blog/webpack-babel-es6-ionic-pipeline-moderne).