Créer soi-même un raccourcisseur URL avec Flask

Introduction

Pour rappel, Flask est un micro-framework basé sur Python, permettant d’écrire des applications web rapidement et simplement, il convient parfaitement pour des applications basique tel qu’un raccourcisseur d’URL, un parfait exemple donc pour une première approche de Flask.

Définissons ce qu’on attend de cette application.

  • Rediriger vers le lien d’origine pour un lien raccourcis donnée
  • Gérer l’authentification d’un utilisateur unique
  • Afficher la liste des liens raccourcis pour d’administrateur
  • Afficher un compteur à la liste pour chaque redirection effectué
  • Possibilité d’effacer un lien pour l’administrateur

Exemple en image, ou directement sur le lien suivant (sans la partie visible aux authentifiés) :

urlshorter1 300x148 Créer soi même un raccourcisseur URL avec Flask

Nic0's URL Shorter

Mon nom de domaine n’est pas très court, mais peu importe, faisons le simplement pour l’amusement, de plus, étant le seul utilisateur de mon raccourcisseur, je n’ai pas besoin d’une possibilité de liens énorme. L’URL se composera de lettres majuscules et minuscules, ainsi que des chiffres, la casse étant respecté pour ces liens.

La méthode utilisé pour généré l’URL, ne prendra pas deux fois la même lettre pour une url donnée. On ne pourra pas avoir ‘AAic3′, mais regardons quand même le nombre d’URL disponible en fonction du nombre de caractères dans l’URL.

  1. 62
  2. 3.782
  3. 226.920
  4. 13.388.280
  5. 776.520.240
  6. 44.261.653.680

N’aillant pas besoin de milliards d’URL, 200.000 devrait être suffisent à voir large, sans avoir de problème pour trouver une URL raccourcis libre. Tenons nous en à 3 caractères, ce qui est amplement suffisant.

L’application n’est vraiment pas compliqué, et écrite « dans la soirée ».

La structure

Flask suit une structure de dossier simplifié, dont la vue et le contrôleur sont séparé, dont voici la structure complète, une fois l’application terminé. Notez pour le moment surtout deux répertoire.

  • static: contenant les éléments comme feuille de style, images et javascripts.
  • templates: les templates avec quoi sera généré la vue.
$ tree            
.
??? schema.sql
??? static
?   ??? style.css
??? templates
?   ??? home.html
?   ??? layout.html
?   ??? login.html
??? url.db
??? urlshorter.py

2 directories, 7 files

La base de donnée

Correspondant au fichier schema.sql

Par simplicité, la table de données sera géré par sqlite3, rien de compliqué, il nous faut

  • id: un classique ;
  • short: ça sera le lien raccourcis, une chaine de caractères de trois lettres donc ;
  • url: l’url d’origine afin de rediriger ;
  • count: notre petit compteur, parce qu’on aime tous les chiffres.

Insérons tout ça dans une table, dans le fichier schema.sql

DROP TABLE IF EXISTS entries;
CREATE TABLE entries (
  id INTEGER PRIMARY KEY autoincrement,
  short string NOT NULL,
  url string NOT NULL,
  COUNT INTEGER NOT NULL
);

Le contrôleur

Correspondant au fichier urlshorter.py.

C’est le corps de l’application, il fait office ici de contrôleur, et de modèle également (dans un modèle MVC). Regardons un peu plus en détail ce fichier, qui sera le plus compliqué à suivre.

Le minimum

Voici avec quoi on va partir :

# -*- coding: utf-8 -*-

import re
import random
import string
import sqlite3
from contextlib import closing
from flask import Flask, render_template, flash, session
from flask import request, g, url_for, redirect, abort

DATABASE = 'url.db'
DEBUG = True
SECRET_KEY = 'superkey'
USERNAME = 'admin'
PASSWORD = 'default'

app = Flask(__name__)
app.config.from_object(__name__)
app.config.from_envvar('URLSHORTER_SETTINGS', silent=True)

if __name__ == '__main__':
    app.run()

Dans cette base de code, nous retrouvons tout les imports dont nous auront besoin par la suite, des variables servant de configuration, récupéré par app.config.from_envvar, le reste est surtout propre à Flask. Tout le code suivant viendra s’incruster juste au dessus de if __name__.

La connexion SQL

Ajoutons les utilitaires pour géré la connexion à la base de données, ce code est repris de l’exemple Flaskr, tutorial du site officiel.

def connect_db():
    """Returns a new connection to the database."""
    return sqlite3.connect(app.config['DATABASE'])

def init_db():
    """Creates the database tables."""
    with closing(connect_db()) as db:
        with app.open_resource('schema.sql') as f:
            db.cursor().executescript(f.read())
        db.commit()

@app.before_request
def before_request():
    """Make sure we are connected to the database each request."""
    g.db = connect_db()

@app.teardown_request
def teardown_request(exception):
    """Closes the database again at the end of the request."""
    if hasattr(g, 'db'):
        g.db.close()

Un peu obscure au premier coup d’œil, mais tout se tiens simplement, en gérant l’ouverture et fermeture de la base avant et après les requêtes.

Home page

Dans notre home page, on veut un formulaire pour créer un raccourcis, et on veut la liste des urls déjà raccourcis avec le maximum d’information sur chacune. Dans le contrôleur, la seul chose qui nous intéresse, c’est de fournir à la vue la liste d’url, le reste sera géré directement avec la vue.

@app.route('/')
def home():
    cur = g.db.execute(
        'select id, url, short, count from entries order by id desc')
    urls = [dict(id=row[0], url=row[1],
                 short=row[2], count=row[3])
                 for row in cur.fetchall()]
           
    return render_template('home.html', urls=urls)

Rien de sorcier, on écrit une requête, sur lequel on créer un dictionnaire qui sera envoyé en argument dans la vue. On notera qu’il n’y a pas de pagination de géré dans ce script relativement simplifié, on ne pourrait prendre également que les 20 derniers urls généré. N’aillant pas un gros besoin, je ne l’ai pas fait, mais ça serait une idée d’amélioration judicieuse.

Générer un raccourcis

L’index (home) contient un formulaire qui pointera vers la page /add. C’est là qu’on va géré la création de l’url raccourcis. Première interrogation, que ce passera t’il si j’ai une url généré s’appelant add, puisque j’ai décidé de ne prendre que trois caractères. Ce ne sera pas un problème, car la méthode add n’interceptera que les requêtes POST, les redirections classiques étant avec GET, cela ne posera pas de conflit.

Cette méthode est certainement la plus longue, on doit s’assurer de certaines choses.

  • L’url fournis ne doit pas déjà être présent, sinon on retourne le résultat déjà généré
  • Le raccourcis disponible doit être unique
  • Vérifier que le lien fournis en est bien un
  • Effectuer les redirections et les messages d’informations pour les différents cas
@app.route('/add', methods=['POST'])
def add():
    if not is_url(request.form['url']):
        return go_home('It is not an URL!')
    data = g.db.execute(
        'select short from entries where url=(?)', [request.form['url']])
    short = data.fetchall()
    message = 'URL correctly shorten is http://url.nicosphere.net/%s'
    # Déjà dans la base, on retourne l'url court utilisé
    if short:
        return go_home(message % short[0])
    short = randomize_url()
    while (short_url_lookup(short)):
        short = randomize_url()
    g.db.execute(
        'insert into entries (short, url, count) values (?, ?, ?)',
        [short, request.form['url'], 0]
    )
    g.db.commit()
    flash(message % short)

    return redirect(url_for('home'))

Avec les méthodes suivant pour alléger le code:

def go_home(message):
    flash(message)
    return redirect(url_for('home'))

def randomize_url():
    short = random.sample(string.ascii_letters + string.digits, 3)
    short = ''.join(short)
    return short

def short_url_lookup(short):
    data = g.db.execute( 'select id from entries where short=(?)', [short])
    data = data.fetchall()
    if len(data) > 0:
        return True
    else:
        return False

def is_url(url):
    url_regex = 'http[s]?://(?:[a-zA-Z]|[0-9]|[$-_@.&+]|[!*\\(\\),]|(?:%[0-9a-fA-F][0-9a-fA-F]))+'
    resp = re.findall(url_regex, url)
    if len(resp) != 1:
        return False
    else:
        return True
  • randomize_url sert à retourner aléatoirement trois lettres/chiffres, comme expliqué plus haut.
  • short_url_lookup est un petit utilitaire vérifiant que le raccourcis n’existe pas déjà dans la base.
  • go_home gère une redirection à l’index avec un message en argument.

Pour la méthode principale, l’idée, dans l’ordre est de.

  1. Regarder si l’url est déjà dans la base
  2. Généré un raccourcis jusqu’à que celui-ci soit disponible (avec 200.000 possibilité, il est peu probable qu’il soit déjà pris, encore faut-il gérer ce cas)
  3. Enregistrer les informations obtenues.
  4. Rediriger vers l’index avec un message approprié

Login/Logout

Ils sont repris sur l’exemple de Flaskr, cité plus haut, mais je les mets là pour information.

@app.route('/login', methods=['GET', 'POST'])
def login():
    error = None
    if request.method == 'POST':
        if request.form['username'] != app.config['USERNAME']:
            error = 'Invalid username'
        elif request.form['password'] != app.config['PASSWORD']:
            error = 'Invalid password'
        else:
            session['logged_in'] = True
            flash('You were logged in')
            return redirect(url_for('home'))
    return render_template('login.html', error=error)

@app.route('/logout')
def logout():
    session.pop('logged_in', None)
    flash('You were logged out')
    return redirect(url_for('home'))

Effacer une URL

@app.route('/del/<id>', methods=['GET'])
def delete(id):
    if not session.get('logged_in'):
        abort(401)
    g.db.execute('delete from entries where id=(?)', [id])
    g.db.commit()

    return redirect(url_for('home'))

Le plus important à noter dans ce code, c’est la gestion de login. Un visiteur ne doit pas pouvoir effacer un lien existant. D’où la restriction avec abort.

La redirection

Et la dernière méthode, gérant la redirection et le compteur.

@app.route('/<id>')
def redirector(id):
    if len(id) is not 3:
       return go_home('Invalid URL sorry...')
    try:
        query = g.db.execute(
            'select id, url, count from entries where short=(?)', [id])
        data = [dict(id=row[0], url=row[1], count=row[2])
                for row in query.fetchall()][0]
        update_count(data)
        return redirect(data['url'])
    except IndexError:
       return go_home('Invalid URL sorry...')

def update_count(data):
    g.db.execute('update entries set count=(?) where id=(?)',
                 [data['count'] + 1, data['id']])
    g.db.commit()

La vue

L’idée est d’avoir un layout général, et que les autres templates héritent de ce layout, en redéfinissant des blocks prédéfini. Pour ceux connaissant Synfony2, c’est exactement le même principe que avec Twig, la prise en main est donc extrèment rapide, un simple coup d’œil à la documentation suffit.

templates/layout.html

<!doctype html>
<title>Nic0's URL Shorter</title>
<link rel=stylesheet type=text/css href="{{ url_for('static', filename='style.css') }}">
<div class=page>
  <div class=metanav>
    {% if not session.logged_in %}
      <a href="{{ url_for('login') }}">log in</a>
    {% else %}
      <a href="{{ url_for('logout') }}">log out</a>
    {% endif %}
  </div>
  <h1>Nic0's URL Shoter</h1>
  {% with flashes = get_flashed_messages() %}
    {% if flashes %}
      <ul class=flashes>
      {% for message in flashes %}
        <li>{{ message }}
      {% endfor %}
      </ul>
    {% endif %}
  {% endwith %}
  <div class=body>
  {% block body %}{% endblock %}
  </div>
</div>

templates/home.html

{% extends "layout.html" %}
{% block body %}
  {% if error %}<div class=error><strong>Error:</strong> {{ error }}</div>{% endif %}
  <div class="shorter">
    <form action="{{ url_for('add') }}" method=post>
      <ul>
        <li>Da Long URL</li>
        <li><input type=text name=url size=60 value="{{ request.form.url }}"><li>
        <div class=actions><input type=submit value="Make It Short!"></div>
      </ul>
    </form>
  </div>

  {% if session.logged_in %}
    <table>
      <tr>
        <th>Id</th>
        <th>Url</th>
        <th>Raccourcis</th>
        <th>Compteur</th>
        <th>Admin</th>
      <tr>
    {% for url in urls %}
      <tr>
        <td>{{ url.id }}</td>
        <td><a href="{{ url.url }}">{{ '/'.join(url.url.split('/')[2:])[:50] }}</a></td>
        <td><a href="{{ url_for('redirector', id=url.short) }}">{{ url.short }}</a></td>
        <td>{{ url.count }}</td>
        <td><a href="{{ url_for('delete', id=url.id) }}">Delete</a>
      </tr>
    {% else %}
      <li><em>Y a pas encore d'url de fournis!</em>
    {% endfor %}
    </table>
  {% endif %}
{% endblock %}

templates/login.html

{% extends "layout.html" %}
{% block body %}
  <h2>Login</h2>
  {% if error %}<p class=error><strong>Error:</strong> {{ error }}{% endif %}
  <form action="{{ url_for('login') }}" method=post>
    <dl>
      <dt>Username:
      <dd><input type=text name=username>
      <dt>Password:
      <dd><input type=password name=password>
      <dd><input type=submit value=Login>
    </dl>
  </form>
{% endblock %}

La plus intéressante à lire est celle pour le home.html, gérant la session pour afficher ou pas la liste d’url.

La feuille de style

La petite touche finale, rajouter une feuille de style potable à tout ça, je la place sur gist (pastebin de Github), car d’une part dans les grandes lignes c’est celle fournis avec l’exemple de flaskr, et que cette feuille n’a pas vraiment d’intérêt ici.

Feuille de style sur Gist

Initialiser la base de donnée

Pour initialiser la base de donnée, lancer un prompt de Python, et faite comme suit :

Python 2.7.2 (default, Jun 29 2011, 11:17:09)
[GCC 4.6.1] on linux2
Type "help", "copyright", "credits" or "license" for more information.
>>> from urlshorter import init_db
>>> init_db()

Conclusion

Encore une fois, c’est vraiment une application qui s’écrit dans la soirée, c’est l’avantage de micro-framework, avec une prise en main rapide, il y aurait bien sûr des choses à amériorer, notamment sur la feuille de style ou la pagination, mais l’idée du billet est surtout d’avoir une base sur lequel créer sa propre application.

Pour le déploiment, des solutions sont sur l’Internet, le résultat de l’application est visible sur ce lien, qui est en fait sur le serveur que j’avais gagné grâce au concours de Linuxfr.org.

Ce billet donnera peut être des idées de codage pour le week-end à certain.

Vus : 3814
Publié par Nicolas Paris : 149