On vient de livrer un projet de réservation de transport pour un client du secteur VTC. L’application Rails permet de réserver des trajets sur des créneaux prédéfinis, et chaque réservation est automatiquement poussée vers le système de gestion du prestataire via son API REST. Sur le papier, ça semble simple. En pratique, on a appris pas mal de choses.

Note : cet article est co-signé avec Aurélien, qui a repris et refactoré une bonne partie de ce que j’avais écrit initialement. Les sections “avant/après” reflètent ce processus — c’est un article sur l’intégration d’une API, mais aussi sur ce qu’on apprend en pair-prog avec quelqu’un de plus expérimenté.

Le contexte

L’application en elle-même n’est pas très complexe : un formulaire de réservation, un back-office ActiveAdmin pour gérer les disponibilités et les créneaux horaires, et une base PostgreSQL. Le vrai morceau, c’est l’intégration avec l’API du prestataire de transport — une API REST authentifiée par JWT, avec des payloads JSON profondément imbriqués.

L’API en question est écrite en PHP. On le sait parce que ça se sent : les conventions de nommage, la structure des réponses, les fautes d’orthographe dans les endpoints… tout trahit un backend codé à la main, probablement par un développeur solo. C’est pas un reproche — ça marche — mais ça impose un effort d’adaptation particulier quand on vient du monde Ruby.

Comprendre le JWT : plus qu’un simple token

Avant ce projet, j’avais une compréhension assez vague de JWT. “C’est un token signé, ça remplace les sessions.” En pratique, c’est plus nuancé que ça.

Un token JWT se compose de trois parties, séparées par des points :

Le site jwt.io a été mon meilleur ami pendant ce projet. On peut y coller un token brut et voir instantanément le header décodé, le payload, et vérifier la signature. Indispensable pour debugger quand l’API renvoie un message d’erreur incompréhensible (ou pas de message du tout).

Un usage non conventionnel du JWT

Dans la plupart des implémentations que j’avais vues, le JWT sert de token d’authentification : on le reçoit après un login, et on le renvoie dans le header Authorization des requêtes suivantes. Ici, c’est totalement différent.

Le token JWT est la requête. Tout le payload métier — les données client, la commande, les adresses — est encodé dans le JWT lui-même. Le token est envoyé comme corps brut du POST, pas dans un header. La signature JWT sert à la fois d’authentification et de garantie d’intégrité des données.

En pratique ça veut dire qu’on ne peut pas inspecter les requêtes dans les logs réseau sans décoder le JWT — un passage systématique par jwt.io. La gem jwt en Ruby gère ça très bien : le quatrième argument de JWT.encode permet de passer des headers custom — ici apiKey et time. C’est une fonctionnalité peu documentée mais essentielle pour notre cas d’usage.

Une API qui sent le PHP

Je ne dis pas ça pour troller — j’ai moi-même fait du PHP avant de passer à Ruby. Mais il y a des indices qui ne trompent pas, et qui créent des frictions quand on intègre depuis Rails.

Les noms de champs : des colonnes SQL à peine maquillées

Les clés du JSON ressemblent à des noms de colonnes MySQL copiés-collés depuis phpMyAdmin. Des abréviations françaises de 3 lettres (COT pour Contact, CLI pour Client, MIS pour Mission, LIE pour Lieu…), des ID numériques magiques partout, aucune utilisation des codes ISO standards.

L’endpoint “ressource” — avec deux S

L’URL de l’API, c’est /gdsv3/set-ressource. Pas resource, pas resourcesressource, à la française. Ça m’a coûté une heure de debug la première fois parce que j’écrivais naturellement resource (l’orthographe anglaise).

Des références croisées en notation PHP

Le plus exotique : certains champs de l’API contiennent des références à d’autres champs du même payload, dans une syntaxe qui ressemble à de la notation PHP :

COM_COT_ID: 'C_Gen_Contact[0][COT_ID]',
FAC_CLI_ID: 'C_Gen_Client[0][CLI_ID]',

L’API interprète ça pour résoudre l’ID du contact ou du client qu’on vient de créer dans la même requête. Ingénieux, mais complètement opaque quand on ne connaît pas la convention.

La date zéro de MySQL

FAC_DATE: '0000-00-00'  # la "date nulle" MySQL — en PostgreSQL on utilise NULL

Ce genre de détail rappelle qu’on interface deux mondes qui n’ont pas les mêmes conventions.

L’architecture du service — première version

Ma première implémentation était… fonctionnelle. Mais quand Aurélien a fait la code review, il a eu un sourire poli qui en disait long. On a passé un après-midi en pair-prog pour refactorer ça ensemble.

Voici ce que j’avais écrit. Le client HTTP, d’abord :

# Ma V1 — "ça marche"
module VTCLimo
  class Client
    include HTTParty
    base_uri 'https://www.limo-vtc.fr/gdsv3'
    format :json

    def initialize(limo, apiKey, password)
      @limo = limo
      @apiKey = apiKey
      @password = password
    end

    def set_resource(params)
      call_api('set-ressource', params)
    end

    def get_resource(params)
      call_api('get-ressource', params)
    end

    def call_api(method, params)
      payload = [{ limo: @limo, params: params }]
      token = JWT.encode payload, @password, 'HS256', {
        apiKey: @apiKey, time: Time.now.to_i
      }
      self.class.post "/#{method}",
        body: token,
        headers: { 'Content-Type' => 'application/json' }
    end
  end
end

Et le service qui l’utilise — je vous épargne le payload complet de 80 lignes, voici la partie intéressante :

# Ma V1 — gestion d'erreur "ça passe"
def create_order(order)
  Rails.logger.debug "CREATE ORDER VTCLIMOSERVICE #{order}"
  # ... 60 lignes de construction du payload ...
  vtc_response = client.set_resource(parameters)
  Rails.logger.debug "receive from LIMOVTC: #{vtc_response.as_json}"
  json = vtc_response.as_json
  return json
rescue
  raise Error.new("could not create order: #{$!.message}")
end

Et ma conversion d’horaires :

# Ma V1 — "ça convertit"
def format_time(time_seconde)
  m = time_seconde / 60
  reste_m = m % 60
  h = m / 60
  if reste_m < 10
    time_m = "0#{reste_m}"
  else
    time_m = "#{reste_m}"
  end
  if h < 10
   time_h = "0#{h}"
 else
   time_h = "#{h}"
 end
 "#{time_h}:#{time_m}:00 +0000"
end

La session de pair-prog qui a tout changé

Aurélien n’a rien cassé, il n’a pas rejeté mon code. Il a dit “c’est bien, ça marche — maintenant on va le rendre maintenable”. On a passé l’après-midi dessus, et j’ai plus appris en 4 heures qu’en 3 mois de tutos.

Première leçon : nommer les choses

“Ton code parle à une API PHP avec des conventions bizarres. Si tu n’encapsules pas ces conventions, elles vont contaminer tout ton codebase.” On a commencé par mapper les magic numbers :

module VTCLimo
  # Les constantes de l'API — documentées ici une fois pour toutes,
  # plutôt qu'en commentaires inline partout dans le code
  module Constants
    COUNTRY_FRANCE    = '65'
    CLIENT_TYPE       = '1'
    PRICING_GRID      = '1'
    LOCATION_TYPES    = { airport: '1', station: '2', other: '3' }.freeze
    SERVICE_TYPES     = { transfer: '1' }.freeze
    MISSION_SUBTYPES  = { standard: '14' }.freeze
    INVOICE_CURRENCY  = '1'
    NULL_DATE         = '0000-00-00'  # convention MySQL de l'API
  end
end

Là où j’écrivais LIE_TLI_ID: '3' avec un commentaire, on écrit maintenant LIE_TLI_ID: LOCATION_TYPES[:other]. Le code se lit tout seul.

Deuxième leçon : un objet Response plutôt que du JSON brut

“Ne parse jamais le même JSON deux fois.” Aurélien m’a montré comment encapsuler la réponse de l’API dans un objet dédié :

module VTCLimo
  class Response
    attr_reader :raw

    def initialize(http_response)
      @raw = http_response.parsed_response
    end

    def success?
      !error?
    end

    def error?
      raw.is_a?(Hash) && raw['code'].present? && raw['msg'].present?
    end

    def error_message
      return unless error?
      "#{raw['code']}: #{raw['msg']}"
    end

    def data
      raw
    end
  end
end

Simple, mais ça change tout. Au lieu de if json['code'] && json['msg'] disséminé partout, on a response.error?. Le code appelant devient limpide.

Troisième leçon : le client qui se respecte

Aurélien a repris le client HTTP pour y ajouter la télémétrie, un vrai pattern de gestion d’erreur, et la couche Response :

module VTCLimo
  class Client
    include HTTParty
    base_uri 'https://www.limo-vtc.fr/gdsv3'
    format :json

    class RequestError < StandardError; end

    def initialize(credentials)
      @name       = credentials.fetch(:name)
      @api_key    = credentials.fetch(:api_key)
      @api_secret = credentials.fetch(:api_secret)
    end

    def set_resource(params)
      call(:post, 'set-ressource', params)
    end

    def get_resource(params)
      call(:post, 'get-ressource', params)
    end

    private

    def call(http_method, endpoint, params)
      payload = [{ limo: @name, params: params }]
      token   = sign(payload)

      response = instrument(endpoint) do
        self.class.send(http_method, "/#{endpoint}",
          body:    token,
          headers: { 'Content-Type' => 'application/json' },
          timeout: 15
        )
      end

      result = Response.new(response)
      raise RequestError, result.error_message if result.error?
      result
    end

    def sign(payload)
      JWT.encode(payload, @api_secret, 'HS256',
        apiKey: @api_key,
        time:   Time.now.to_i
      )
    end

    def instrument(endpoint)
      started = Process.clock_gettime(Process::CLOCK_MONOTONIC)
      result  = yield
      elapsed = ((Process.clock_gettime(Process::CLOCK_MONOTONIC) - started) * 1000).round

      ActiveSupport::Notifications.instrument('request.vtc_limo',
        endpoint: endpoint,
        duration_ms: elapsed,
        status: result.code
      )

      Rails.logger.info { "[vtc_limo] #{endpoint}#{elapsed}ms — HTTP #{result.code}" }
      result
    rescue => e
      ActiveSupport::Notifications.instrument('error.vtc_limo',
        endpoint: endpoint,
        error: e.class.name,
        message: e.message
      )
      raise
    end
  end
end

Les différences clés avec ma V1 :

  • Credentials en hash plutôt qu’en arguments positionnels — plus de risque d’inverser api_key et api_secret
  • Timeout explicite — ma version pouvait bloquer indéfiniment si l’API ne répondait pas
  • instrument sépare la télémétrie de la logique métier — on peut brancher des subscribers sans toucher au client
  • sign est une méthode à part — testable indépendamment, et le code de call reste lisible
  • L’erreur remonte depuis le client, pas depuis le service — fail fast, au plus près de la source

Quatrième leçon : le service qui construit le payload

Le service lui-même est devenu plus propre — chaque sous-structure du payload est construite par une méthode dédiée, pas un hash monolithique de 80 lignes :

class VTCLimoService
  include VTCLimo::Constants

  class OrderError < StandardError; end

  def initialize(client: VTCLimo::Client.new(credentials))
    @client = client
  end

  def create_order(order)
    contact   = order.contact
    place     = order.travel_place
    time_slot = order.time_range

    params = build_client_params(contact,
      commande: build_commande(order, contact, place, time_slot)
    )

    @client.set_resource(params)
  rescue VTCLimo::Client::RequestError => e
    raise OrderError, "Échec création commande ##{order.id}: #{e.message}"
  end

  private

  def build_client_params(contact, commande:)
    ref = prefixed_ref(contact)
    {
      C_Gen_Client: [{
        ref: ref,
        CLI_TCC_ID:     CLIENT_TYPE,
        CLI_SOCIETE:    "#{contact.full_name} (#{env_name}) #{Date.current}",
        CLI_FACT_NOM:   contact.last_name,
        CLI_FACT_PAY_ID: COUNTRY_FRANCE,
        C_Gen_Grilleclient: [{ GRL_GRI_ID: PRICING_GRID, ref: ref }],
        C_Gen_Contact:      [build_contact(contact, ref)],
        C_Com_Commande:     [commande]
      }]
    }
  end

  def build_contact(contact, ref)
    {
      ref:           ref,
      COT_NOM:       contact.last_name,
      COT_PRENOM:    contact.first_name,
      COT_EMAIL:     contact.email,
      COT_TELEPHONE: contact.phone_number
    }
  end

  def build_commande(order, contact, place, time_slot)
    order_ref = prefixed_ref(order, suffix: 'order')
    {
      ref: prefixed_ref(contact, suffix: 'cmd'),
      C_Gen_Mission: [{
        ref:             order_ref,
        MIS_SMI_ID:      MISSION_SUBTYPES[:standard],
        MIS_DATE_DEBUT:  format_date(order.date),
        MIS_HEURE_DEBUT: format_time(time_slot.start),
        MIS_PAX:         order.crew_number,
        C_Com_FraisMission:  [build_fees(order, order_ref)],
        C_Gen_EtapePresence: build_stages(place, order_ref)
      }]
    }
  end

  def build_fees(order, ref)
    ht_price = (order.price.to_f * 100 / (100 + tax_rate)).round(4)
    {
      ref:         ref,
      FMI_SER_ID:  SERVICE_TYPES[:transfer],
      FMI_LIBELLE: "Transfert avec #{env_name}",
      FMI_QTE:     '1',
      FMI_VENTE_HT: ht_price.to_s,
      FMI_TVA:      tax_rate.to_s
    }
  end

  def build_stages(place, order_ref)
    [place.address_start, place.address_end].each_with_index.map do |address, i|
      {
        ref:     "#{i.zero? ? 'start' : 'end'}#{order_ref}",
        EPR_TRI: i.to_s,
        EPR_LIE_ID: {
          LIE_TLI_ID:  LOCATION_TYPES[:other],
          LIE_FORMATED: address,
          LIE_PAY_ID:  COUNTRY_FRANCE,
          LIE_TIMEZONE: 'Europe/Paris'
        }
      }
    end
  end

  # ── Formatage ──

  def format_date(date)
    date.in_time_zone('Europe/Paris').strftime('%Y-%m-%d')
  end

  def format_time(seconds_since_midnight)
    Time.at(seconds_since_midnight).utc.strftime('%H:%M:%S +0000')
  end

  def prefixed_ref(record, suffix: nil)
    [env_name, record.id, suffix].compact.join('-')
  end

  def tax_rate
    @tax_rate ||= (ENV['VTC_LIMO_INVOICE_TAX_RATE'] || 20).to_f
  end

  def env_name
    @env_name ||= ENV.fetch('VTC_LIMO_ENVIRONMENT_NAME', 'Albion')
  end

  def credentials
    {
      name:       ENV.fetch('VTC_LIMO_NAME'),
      api_key:    ENV.fetch('VTC_LIMO_API_KEY'),
      api_secret: ENV.fetch('VTC_LIMO_API_SECRET')
    }
  end
end

Comparé à ma V1 :

  • format_time passe de 11 lignes à 1 — Time.at(seconds).utc.strftime fait tout le travail. Aurélien m’a montré ça en une seconde et j’ai eu honte de mes if/else.
  • format_date utilise strftime au lieu de .to_time.to_s.first(10) — plus explicite, plus sûr.
  • Chaque sous-structure a sa méthodebuild_contact, build_commande, build_fees, build_stages. Quand un champ de l’API change, on sait exactement où intervenir.
  • build_stages utilise each_with_index.map au lieu de copier-coller deux blocs quasi identiques.
  • Le rescue est typérescue VTCLimo::Client::RequestError => e au lieu d’un rescue nu. On sait exactement ce qu’on attrape.
  • L’injection du client dans le constructeur permet de tester avec un mock, sans appeler l’API réelle.
  • ENV.fetch plutôt que ENV[] — crash immédiat et explicite si une variable manque, au lieu d’un nil qui propage des bugs cryptiques.

Le système de double commande

Un détail d’architecture qui mérite d’être mentionné : chaque réservation génère en réalité deux commandes côté API — un trajet aller et un trajet retour. Le contrôleur crée la première commande, vérifie la réponse, puis crée la seconde seulement si la première a réussi.

C’est un risque d’état incohérent : si la première commande réussit mais que la seconde échoue, on se retrouve avec un aller sans retour dans le système du prestataire. On a wrappé ça dans une transaction :

def create_booking(params)
  ActiveRecord::Base.transaction do
    contact = Contact.create!(contact_params(params))
    outbound = create_leg(contact, params, :outbound)
    inbound  = create_leg(contact, params, :inbound)

    service = VTCLimoService.new
    outbound_response = service.create_order(outbound)
    inbound_response  = service.create_order(inbound)

    [outbound, inbound]
  end
rescue VTCLimoService::OrderError => e
  # La transaction rollback les deux orders en local
  # Côté API, l'aller peut exister sans retour — pas d'endpoint de suppression.
  # On log l'état incohérent pour traitement manuel.
  Rails.logger.error("[booking] État incohérent possible: #{e.message}")
  raise
end

L’API ne proposant pas d’endpoint de suppression, on ne peut pas faire de compensation automatique. On log et on traite manuellement — c’est pas idéal, mais c’est honnête.

Télémétrie : les subscribers

Aurélien a insisté sur un point : la télémétrie ne doit pas être dans le code métier. On utilise ActiveSupport::Notifications côté client (cf. la méthode instrument plus haut), et des subscribers séparés qui écoutent les événements :

# config/initializers/vtc_limo_telemetry.rb
ActiveSupport::Notifications.subscribe('request.vtc_limo') do |*args|
  event = ActiveSupport::Notifications::Event.new(*args)
  StatsD.measure('vtc_limo.request.duration', event.payload[:duration_ms],
    tags: ["endpoint:#{event.payload[:endpoint]}"]
  )
  StatsD.increment('vtc_limo.request.count',
    tags: [
      "endpoint:#{event.payload[:endpoint]}",
      "status:#{event.payload[:status]}"
    ]
  )
end

ActiveSupport::Notifications.subscribe('error.vtc_limo') do |*args|
  event = ActiveSupport::Notifications::Event.new(*args)
  StatsD.increment('vtc_limo.error.count',
    tags: ["endpoint:#{event.payload[:endpoint]}", "type:#{event.payload[:error]}"]
  )

  # Alerte si trop d'erreurs en 5 minutes
  error_key = "vtc_limo:errors:#{Time.current.strftime('%H%M').to_i / 5}"
  count = Redis.current.incr(error_key)
  Redis.current.expire(error_key, 600)
  AdminMailer.api_alert(event.payload).deliver_later if count == 10
end

Ça nous a permis de découvrir que ~8% des appels échouaient silencieusement en production. Principalement des problèmes de format de date — un format DD/MM/YYYY qui passait en staging (timezone Paris) mais pas en prod (UTC). Le genre de bug qu’on ne trouve qu’avec de la télémétrie.

Ce que j’ai appris

L’intégration elle-même était un bon exercice. Mais c’est la session de pair-prog avec Aurélien qui a transformé le projet.

Quelques principes que je retiens :

  1. Nommer les magic numbers. Un module de constantes coûte 5 minutes et économise des heures de “c’est quoi ce '65' déjà ?”

  2. Séparer les couches. Client HTTP → Response → Service → Controller. Chaque couche a une seule responsabilité. Quand l’API change un format de champ, on touche une méthode, pas 4 fichiers.

  3. rescue typé, toujours. Un rescue nu avec $! est un aveu d’ignorance sur ce qui peut planter. Nommer l’exception, c’est documenter le contrat.

  4. La télémétrie est de l’infrastructure, pas du code métier. ActiveSupport::Notifications + subscribers = zéro couplage.

  5. ENV.fetch > ENV[]. Fail fast. Un crash au boot vaut mieux qu’un nil en production à 3h du matin.

  6. Tester avec jwt.io. On a perdu une journée sur un bug qui venait d’un api_secret avec un espace en fin de chaîne — visible immédiatement en décodant le token.

Intégrer une API tierce, c’est rarement juste “faire un POST avec le bon JSON”. C’est comprendre les choix (parfois étranges) du fournisseur, s’adapter à son modèle de données, et surtout isoler proprement cette complexité du reste de l’application. Quand l’API en face est issue d’un autre écosystème — PHP, .NET, Java — les frictions viennent rarement du protocole HTTP, mais des conventions culturelles : nommage des champs, gestion des erreurs, format des dates.

Le pattern Service Object en Rails reste notre meilleur allié pour ça. Et un bon pair-prog vaut mieux que dix articles de blog.


On a réutilisé le pattern Service Object sur de nombreux projets depuis — notamment sur le backend Rails de Simone.paris et sur l’architecture Hively.