# Intégrer une API tierce dans une app Rails > Authentification JWT, payloads imbriqués, gestion d'erreurs — les leçons tirées de l'intégration d'une API de réservation transport dans un projet Rails. Date : 22/04/2016 Auteur : Aurélien N. Tags : Ruby on Rails, API, Intégration, JWT --- 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](https://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 : ```ruby 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 ```ruby 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 : ```ruby # 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 = [] token = JWT.encode payload, @password, 'HS256', self.class.post "/#", body: token, headers: end end end ``` Et le service qui l'utilise — je vous épargne le payload complet de 80 lignes, voici la partie intéressante : ```ruby # Ma V1 — gestion d'erreur "ça passe" def create_order(order) Rails.logger.debug "CREATE ORDER VTCLIMOSERVICE #" # ... 60 lignes de construction du payload ... vtc_response = client.set_resource(parameters) Rails.logger.debug "receive from LIMOVTC: #" json = vtc_response.as_json return json rescue raise Error.new("could not create order: #") end ``` Et ma conversion d'horaires : ```ruby # 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#" else time_m = "#" end if h < 10 time_h = "0#" else time_h = "#" end "#:#: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 : ```ruby 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 = .freeze SERVICE_TYPES = .freeze MISSION_SUBTYPES = .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é : ```ruby 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? "#: #" 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 : ```ruby 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 = [] token = sign(payload) response = instrument(endpoint) do self.class.send(http_method, "/#", body: token, headers: , 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] # — #ms — HTTP #" } 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 : ```ruby 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 ##: #" end private def build_client_params(contact, commande:) ref = prefixed_ref(contact) { C_Gen_Client: [{ ref: ref, CLI_TCC_ID: CLIENT_TYPE, CLI_SOCIETE: "# (#) #", CLI_FACT_NOM: contact.last_name, CLI_FACT_PAY_ID: COUNTRY_FRANCE, C_Gen_Grilleclient: [], C_Gen_Contact: [build_contact(contact, ref)], C_Com_Commande: [commande] }] } end def build_contact(contact, ref) 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: [] } 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 #", 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: "##", EPR_TRI: i.to_s, EPR_LIE_ID: } 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 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éthode** — `build_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 : ```ruby 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: #") 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 : ```ruby # 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:#"] ) StatsD.increment('vtc_limo.request.count', tags: [ "endpoint:#", "status:#" ] ) end ActiveSupport::Notifications.subscribe('error.vtc_limo') do |*args| event = ActiveSupport::Notifications::Event.new(*args) StatsD.increment('vtc_limo.error.count', tags: ["endpoint:#", "type:#"] ) # Alerte si trop d'erreurs en 5 minutes error_key = "vtc_limo:errors:#" 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](/blog/simone-paris-rails-backend-app-ios) et sur [l'architecture Hively](/blog/beespoke-marketplace-collaborative-ionic-rails).*