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 :
{
"alg": "HS256",
"apiKey": "••••••",
"time": 1461312000
} clé API dans le header [{
"limo": "compte",
"params": {
"C_Gen_Client": [...]
}
}] les données métier entières HMACSHA256(
base64(header) + "."
+ base64(payload),
api_secret
) 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 resources — ressource, à 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_keyetapi_secret - Timeout explicite — ma version pouvait bloquer indéfiniment si l’API ne répondait pas
instrumentsépare la télémétrie de la logique métier — on peut brancher des subscribers sans toucher au clientsignest une méthode à part — testable indépendamment, et le code decallreste 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_timepasse de 11 lignes à 1 —Time.at(seconds).utc.strftimefait tout le travail. Aurélien m’a montré ça en une seconde et j’ai eu honte de mesif/else.format_dateutilisestrftimeau lieu de.to_time.to_s.first(10)— plus explicite, plus sûr.- Chaque sous-structure a sa méthode —
build_contact,build_commande,build_fees,build_stages. Quand un champ de l’API change, on sait exactement où intervenir. build_stagesutiliseeach_with_index.mapau lieu de copier-coller deux blocs quasi identiques.- Le rescue est typé —
rescue VTCLimo::Client::RequestError => eau lieu d’unrescuenu. 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.fetchplutôt queENV[]— crash immédiat et explicite si une variable manque, au lieu d’unnilqui 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 :
-
Nommer les magic numbers. Un module de constantes coûte 5 minutes et économise des heures de “c’est quoi ce
'65'déjà ?” -
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.
-
rescuetypé, toujours. Unrescuenu avec$!est un aveu d’ignorance sur ce qui peut planter. Nommer l’exception, c’est documenter le contrat. -
La télémétrie est de l’infrastructure, pas du code métier.
ActiveSupport::Notifications+ subscribers = zéro couplage. -
ENV.fetch>ENV[]. Fail fast. Un crash au boot vaut mieux qu’unnilen production à 3h du matin. -
Tester avec jwt.io. On a perdu une journée sur un bug qui venait d’un
api_secretavec 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.