Un mini serveur d'API en Ruby

On a souvent besoin de tester des requêtes Internet quand on développe une application, par exemple pour vérifier dans une app iOS que la connexion se fait bien en arrière-plan, que le JSON reçu est bien décodé, etc.

On serait tenté de s'adresser à son serveur de prod, ou même d'utiliser Dropbox… mais il y a plus cool : se faire son propre mini serveur de tests.

Et avec Ruby, c'est très simple, et ça prend à peine vingt lignes de code.

Let’s go!

Présentation

Ces temps-ci j'ai souvent besoin de tester la validité d'un payload JSON, ou la solidité d'un upload d'image vers le Web, ou la qualité d'une connexion et de son timeout, etc.

Au lieu d'utiliser plusieurs services pour ça, et même de laisser traîner des fichiers dans des dossiers protégés de mon serveur de prod, comme on est souvent tenté de le faire, ou de s'adresser à des outils qui ne sont pas faits pour ça comme Dropbox, il est plus efficace - et même plus simple - de se faire son propre serveur en local.

Avec Ruby et Sinatra ça prend quelques lignes à peine et ça rend un service incroyable.

Le serveur

On va avoir besoin de Sinatra, donc on l'installe :

gem install sinatra

On crée ensuite un simple fichier Ruby app.rb avec cet en-tête :

#!/usr/bin/env ruby
# encoding: utf-8

require "sinatra"
require "json"

On peut obtenir une version fonctionnelle pour tester l'install avec juste trois lignes à ajouter :

get '/' do
    "Hello World"
end

Ca veut dire que Sinatra va surveiller l'URL racine, dans notre cas localhost sur le port 4567, et répondre par le contenu de do ... end.

On le lance puis on teste :

ruby app.rb
curl localhost:4567

Résultat : “Hello World” s'affiche dans le terminal.

Stoppez le serveur en faisant CTRL+C.

On vient de voir que créer un serveur minimal était simplissime. On va maintenant créer des routes et fonctions plus utiles.

JSON

Par exemple, on veut une URL “localhost:4567/api” qui retourne du JSON.

On crée donc une route pour l'URL :

get '/api' do
end

et dans cette methode on va créer le contenu qui sera retourné.

Comme on veut du JSON, on va faire un hash (un dictionnaire) et le convertir en JSON.

get '/api' do
    # On précise le format pour Sinatra
    content_type :json
    # On créer un hash avec nos contenus
    jj = {
        'meta' => {
            'code' => 200,
            'message' => 'Welcome to MiniServer API.'
        }
    }
    # On transforme le hash en joli JSON
    # Comme on est en Ruby, la dernière déclaration est retournée, donc par convention on n'indique pas explicitement `return`
    JSON.pretty_generate(jj)
end

Résultat :

ruby app.rb
curl localhost:4567/api
{
  "meta": {
    "code": 200,
    "message": "Welcome to MiniServer API."
  }
}

Fichier JSON

Et si on veut que notre serveur retourne du JSON plus complexe ? On peut lui demander, au lieu de créer un hash et de le convertir, de lire un fichier JSON existant.

Dans notre exemple très simple sans configuration, le fichier devra être présent au même niveau que app.rb.

get '/file/*.*' do
    # `params` est le nom de la variable globale de Sinatra qui contient les paramètres passés dans l'URL, et `splat` représente un format contenant `*`
    name = params["splat"].join(".")
    File.read(name)
end

Disons que j'aie un fichier test.json, je fais :

curl localhost:4567/file/test.json

et j'obtiens le JSON.

Sinatra autorise à forcer une URL sous forme de nom de fichier avec les deux astérisques et le point, qui retourne un array qu'il faut donc rejoindre avec un point.

On lit ensuite le contenu du fichier, qui est automatiquement retourné.

Donc par exemple au lieu de taper sans cesse une vraie API en ligne qui distribue du JSON, vous pouvez enregistrer le résultat d'une requête puis la rejouer autant que vous voulez dans votre propre mini serveur :

curl www.big-api.com/api/bigdatachunk > test.json
ruby app.rb
curl localhost:4567/file/test.json

Fichier à downloader

Ici j'utilise une image en exemple. Sinatra permet de faire ça très simplement grâce à un de ses helpers :

get '/picture' do
    send_file '/Users/you/Images/bird.jpg'
end

Collez http://localhost:4567/picture dans un browser, et votre image s'affiche.

Détails d'une requête

On peut vouloir inspecter l'URL formée à partir de paramètres, on va donc partir de notre précédente méthode associée à l'URL /api et lui ajouter ces fonctions :

get '/api/*' do
    jj = {
        'meta' => {
            'code' => 200,
            'message' => 'Welcome to MiniServer. Request accepted.'
        }
    }
    if params['splat'].first != ""
        jj['data'] = {}
        jj['data']['components'] = params['splat'].first.split('/')
        if !params["q"].nil?
            jj['data']['query'] = params["q"]
        end
    end
    content_type :json
    JSON.pretty_generate(jj)
end

Ce n'est qu'un exemple pour démontrer le principe.

Requête :

curl localhost:4567/api/test/server/whatever/\?q=yo

Résultat :

{
  "meta": {
    "code": 200,
    "message": "Welcome to MiniServer. Request accepted."
  },
  "data": {
    "components": [
      "test",
      "server",
      "whatever"
    ],
    "query": "yo"
  }
}

404

Il faut bien se préparer aussi un petit 404, encore une fois Sinatra nous aide avec un helper pour, cette-fois, la route :

not_found do
    {
        'meta' => {
            'code' => 404,
            'message' => 'Welcome to MiniServer. Request not found.'
        }
    }.to_json
end

Ici j'ai volontairement choisi de retourner du JSON brut, non pretty-print, pour montrer que c'est encore plus simple à faire.

Upload

Bien sûr, on peut aussi tester autre chose que GET. Par exemple, pour l'upload d'un fichier :

post '/upload' do
    jj = {
        'meta' => {
            'code' => 200,
            'message' => 'Welcome to MiniServer API. File received.'
        }, 
        'data' => {
            'env' => request.env
        }
    }
    content_type :json
    JSON.pretty_generate(jj)
end

On fait par exemple avec notre précédent fichier de test (mais ça pourrait être une image ou tout autre fichier) :

curl -X POST -d @"test.json" localhost:4567/upload

Résultat :

{
  "meta": {
    "code": 200,
    "message": "Welcome to MiniServer API. File received."
  },
  "data": {
    "env": {
      "SERVER_SOFTWARE": "thin 1.6.3 codename Protein Powder",
      "SERVER_NAME": "localhost",
      "rack.input": "#<StringIO:0x007ff1e298b2b0>",
      "rack.version": [
        1,
        0
      ],
      "rack.errors": "#<IO:0x007ff1e20ca4f0>",
      "rack.multithread": true,
      "rack.multiprocess": false,
      "rack.run_once": false,
      "REQUEST_METHOD": "POST",
      "REQUEST_PATH": "/upload",
      "PATH_INFO": "/upload",
      "REQUEST_URI": "/upload",
      "HTTP_VERSION": "HTTP/1.1",
      "HTTP_USER_AGENT": "curl/7.35.0",
      "HTTP_HOST": "localhost:4567",
      "HTTP_ACCEPT": "*/*",
      "CONTENT_LENGTH": "49",
      "CONTENT_TYPE": "application/x-www-form-urlencoded",
      "GATEWAY_INTERFACE": "CGI/1.2",
      "SERVER_PORT": "4567",
      "QUERY_STRING": "",
      "SERVER_PROTOCOL": "HTTP/1.1",
      "rack.url_scheme": "http",
      "SCRIPT_NAME": "",
      "REMOTE_ADDR": "127.0.0.1",
      "async.callback": "#<Method: Thin::Connection#post_process>",
      "async.close": "#<EventMachine::DefaultDeferrable:0x007ff1e21e3558>",
      "rack.request.form_input": "#<StringIO:0x007ff1e298b2b0>",
      "rack.request.form_hash": {
        "{\"meta\":{\"code\":200,\"message\":\"Test from file.\"}}": null
      },
      "rack.request.form_vars": "{\"meta\":{\"code\":200,\"message\":\"Test from file.\"}}",
      "sinatra.commonlogger": true,
      "rack.logger": "#<Logger:0x007ff1e2872680>",
      "rack.request.query_string": "",
      "rack.request.query_hash": {
      },
      "sinatra.route": "POST /upload"
    }
  }
}

Yeah, c'est plutôt complet ce que nous donne Sinatra comme infos, pas mal du tout !

Exemple complet

#!/usr/bin/env ruby
# encoding: utf-8

require "sinatra"
require "json"

# C'est pratique aussi d'avoir un log
configure do
  file = File.new("#{Dir.home}/temp/server.log", 'a+')
  file.sync = true
  use Rack::CommonLogger, file
end

# On peut choisir une liste de serveurs compatibles, choisir le port, et choisir le dossier racine
set :server, %w[thin webrick]
set :port, 4567
set :root, File.dirname(__FILE__)

# Routes

get '/' do
    "yo"
end

get '/api' do
    content_type :json
    jj = {
        'meta' => {
            'code' => 200,
            'message' => 'Welcome to MiniServer API.'
        }
    }
    JSON.pretty_generate(jj)
end

get '/file/*.*' do
    name = params["splat"].join(".")
    File.read(name)
end

get '/api/*' do
    jj = {
        'meta' => {
            'code' => 200,
            'message' => 'Welcome to MiniServer. Request accepted.'
        }
    }
    if params['splat'].first != ""
        jj['data'] = {}
        jj['data']['components'] = params['splat'].first.split('/')
        if !params["q"].nil?
            jj['data']['query'] = params["q"]
        end
    end
    content_type :json
    JSON.pretty_generate(jj)
end

get '/picture' do
    send_file '/Users/me/images/bird.jpg'
end

post '/upload' do
    jj = {
        'meta' => {
            'code' => 200,
            'message' => 'Welcome to MiniServer API. File received.'
        }, 
        'data' => {
            'env' => request.env
        }
    }
    content_type :json
    JSON.pretty_generate(jj)
end

not_found do
    jj = {
        'meta' => {
            'code' => 404,
            'message' => 'Welcome to MiniServer. Request not found.'
        }
    }
    content_type :json
    JSON.pretty_generate(jj)
end
Auteur: Eric Dejonckheere