среда, 19 марта 2014 г.

Let's have a REST, часть III

В части I рассказано о том, что такое REST и что значит для приложения быть RESTful. На несложном примере проиллюстрирован процесс проектирования RESTful приложения. В части II рассмотрены некоторые детали протокола HTTP в связи с реализацией на его основе RESTful приложений. В частности, рассказано, в чем разница между HTTP-методами POST и PUT, что такое идемпотентность, как обойти ограничения языка HTML и сделать браузерное HTML-приложение RESTful (ну, почти RESTful).

В данной, заключительной части, будут рассмотрены два RESTful приложения, написанные на Python с использованием микрофреймворка Flask. Оба приложения позволяют вести список книг, то есть, просматривать, добавлять, изменять и удалять книги из списка.

Эти два приложения:

  • RESTful web-сервис и его клиент,
  • RESTful HTML-приложение, с которым пользователь работает в браузере.

Я не буду рассказывать о том, как установить Flask (соответствующая инструкция есть на сайте), а также об основах работы с Flask (об этом отлично рассказано в разделе Quickstart руководства пользователя).

Перейду сразу к делу и представлю код web-сервиса, возвращающего CSV-представление списка книг:

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

from flask import Flask, url_for

app = Flask(__name__)

books = {1 : [u'Лев Толстой', u'Война и мир']}
HEADERS = {'Content-Type' : 'text/csv; charset=utf-8'}

def csvbook(id):
    return u"%s;%s;%s\n" % (id, books[id][0], books[id][1])

@app.route('/')
@app.route('/books')
def index():
    text = ''
    for key in books.keys():
        text += csvbook(key)
    return text, 200, HEADERS


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

Поскольку приложение призвано продемонстрировать принципы REST, то все, что сопутствует этой демонстрации, написано как можно проще, чтобы занимать меньше места и быть понятным без объяснений. Так, книги будем хранить не в базе данных, а в словаре books, где ключ - целое число, а значение - список из двух строковых значений: автор книги, название книги.

Функция index() обрабатывает запросы GET для URL /books и /. Формируется CSV-представление списка книг из словаря books, используя функцию csvbook(id) для получения CSV-строки с данными каждой книги. Сформированное представление возвращается клиенту, причем ответ имеет статус 200 (OK) и HTTP-заголовок, задающий тип и кодировку возвращаемых данных.

Запустив наш сервис

C:\> python restful-ws-01.py
 * Running on http://127.0.0.1:5000/
 * Restarting with reloader

и введя в брузере адрес http://localhost:5000/books, получим файл, содержащий

1;Лев Толстой;Война и мир

Не очень удобно тестировать RESTful web-сервис с помощью браузера. В интернет-магазине Chrome есть приложение Advanced Rest Client, которое существенно упрощает ручное тестирование RESTful web-сервиса. Рекомендую попробовать.

Но в этой статье пойду другим путем и напишу клиента для нашего web-сервиса на Python:

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

import requests

def print_response(resp):
    print "    url: %s" % resp.url
    print " status: %s %s" % (resp.status_code, resp.reason)
    print "headers: %s " % resp.headers
    print "   data:\n%s" % resp.text

print_response(requests.get("http://localhost:5000/books"))

Я использую библиотеку Requests, которая делает отправку HTTP-запросов с различными методами тривиальной задачей. Результат выполнения приведенного кода:

    url: http://localhost:5000/books
 status: 200 OK
headers: CaseInsensitiveDict({'date': 'Thu, 13 Mar 2014 04:39:50 GMT', 'content-length': '45', 'content-type': 'text/csv; charset=utf-8', 'server': 'Werkzeug/0.9.4 Python/2.7.3'}) 
   data:
1;Лев Толстой;Война и мир

Прежде чем реализовать следующие методы web-сервиса и написать для них клиентские запросы, приведу полный список ресурсов и методов web-сервиса:

/books        GET       получить список книг
/books        POST      создать новую книгу
/books/<id>   GET       получить данные книги <id>
/books/<id>   PUT       изменить данные книги <id>
/books/<id>   DELETE    удалить книгу <id>

Вот код, реализующий перечисленные методы web-сервиса, а также обработчик для случая, когда запрошенный ресурс отсутствует:

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

from flask import Flask, request, abort, url_for

app = Flask(__name__)

books = {1 : [u'Лев Толстой', u'Война и мир']}
HEADERS = {'Content-Type' : 'text/csv; charset=utf-8'}

def csvbook(id):
    return u"%s;%s;%s;%s\n" % \
        (id, books[id][0], books[id][1], url_for('show', id=id, _external = True))

@app.route('/')
@app.route('/books')
def index():
    text = ''
    for key in books.keys():
        text += csvbook(key)
    return text, 200, HEADERS

@app.route('/books', methods=['POST'])
def create():
    new_id = len(books) + 1
    books[new_id] = [request.form['author'], request.form['title']]
    return csvbook(new_id), 201, HEADERS

@app.route('/books/<int:id>')
def show(id):
    if books.get(id):
        return csvbook(id), 200, HEADERS
    else:
        abort(404)

@app.route('/books/<int:id>', methods=['PUT'])
def update(id):
    if books.get(id):
        books[id] = [request.form['author'], request.form['title']]
    else:
        abort(404)
    return csvbook(id), 200, HEADERS

@app.route('/books/<int:id>', methods=['DELETE'])
def delete(id):
    if books.get(id):
        del books[id]
    return u'OK', 200, HEADERS

@app.errorhandler(404)
def not_found(error):
    return u'404: not found', 404, HEADERS


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

Ниже код клиента, тестирующий все методы нашего web-сервиса:

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

import requests

def print_response(resp):
    print "    url: %s" % resp.url
    print " status: %s %s" % (resp.status_code, resp.reason)
    print "headers: %s " % resp.headers
    print "   data:\n%s" % resp.text

def list_books():
    print("\n### GET http://localhost:5000/books\n")
    print_response(requests.get("http://localhost:5000/books"))


list_books

print("\n### POST http://localhost:5000/books\n")
payload = {'author' : u'Александр Пушкин', 'title' : u'Пиковая дама'}
resp = requests.post("http://localhost:5000/books", data=payload)
print_response(resp)

list_books

print("\n### PUT http://localhost:5000/books/2\n")
payload = {'author' : u'Лев Толстой', 'title' : u'Анна Каренина'}
resp = requests.put("http://localhost:5000/books/2", data=payload)
print_response(resp)

list_books

print("\n### DELETE http://localhost:5000/books/2\n")
print_response(requests.delete("http://localhost:5000/books/2"))

list_books

print("\n### GET http://localhost:5000/books/1\n")
print_response(requests.get("http://localhost:5000/books/1"))

print("\n### GET http://localhost:5000/books/2\n")
print_response(requests.get("http://localhost:5000/books/2"))

Запустив web-сервис, выполните код клиента, чтобы убедиться в его работоспособности. Изучите выведенную клиентом информацию. Обратите внимание на ответы, которые возвращает каждый из методов web-сервиса клиенту.

Теперь перейдем к браузерному RESTful приложению. Оно поддерживает следующие ресурсы и операции:


/books             GET           получить представление списка книг
/books/new         GET           получить форму для ввода данных новой книги
/books             POST          создать новую книгу
/books/<id>        GET           получить представление книги <id>
/books/<id>/edit   GET           получить форму для изменения данных книги <id>
/books/<id>        POST          изменить данные книги <id>
                   _method='PUT'
/books/<id>/delete POST          удалить книгу <id>
                   _method='DELETE'

Здесь адреса ресурсов следуют соглашениям фреймворка Ruby on Rails - законодателя мод в области RESTful web-приложений. Так как язык HTML не поддерживает запросы к серверу с методами PUT и DELETE, то эти методы имитируются при помощи скрытых полей форм с именем _method.

Ниже приведен код браузерного приложения:

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

from flask import Flask, request, redirect, abort

# GET and HEAD are safe
# GET, HEAD, PUT and DELETE are idempotent
# (RFC 2616 http://www.w3.org/Protocols/rfc2616/rfc2616-sec9.html#sec9)


app = Flask(__name__)
books = {1 : [u'Лев Толстой', u'Война и мир']}

list_books_template = u"""
<!DOCTYPE html>
<html>
<head>
    <title>Список книг</title>
    <style type="text/css">th {background-color:#cceecc;}</style>
</head>
<body>
    <h2>Список книг</h2>
    <table>
    <tr><th></th><th>id</th><th>Автор</th><th>Название</th></tr>
    %s
    </table>
    <p></p>
    <form action="/books/new" method="GET">
        <input type="submit" value="Добавить книгу" />
    </form>
</body>
</html>
"""

show_book_template = u"""
<!DOCTYPE html>
<html>
<head>
    <title>Книга</title>
    <style type="text/css">.header {font-weight:bold;background-color:#cceecc;}</style>
</head>
<body>
    <h2>Книга</h2>
    <table>
    <tr><td class="header">id</td><td>%s</td></tr>
    <tr><td class="header">Автор</td><td>%s</td></tr>
    <tr><td class="header">Название</td><td>%s</td></tr>
    </table>
    <p></p>
    <a href="/books">К списку книг</a>
</body>
</html>
"""

edit_book_template = u"""
<!DOCTYPE html>
<html>
<head>
    <title>Книга - Изменить</title>
    <style type="text/css">.header {font-weight:bold;background-color:#cceecc;}</style>
</head>
<body>
    <h2>Книга - Изменить</h2>
    <form action="/books/%s" method="POST">
        <input type="hidden" name="_method" value="PUT" />
        <table>
        <tr><td class="header">id</td><td>%s</td></tr>
        <tr><td class="header">Автор</td><td><input type="text" name="author" value="%s" /></td></tr>
        <tr><td class="header">Название</td><td><input type="text" name="title" value="%s" /></td></tr>
        </table>
        <input type="submit" value="Сохранить" />
    </form>
    <form action="/books/%s" method="POST">
        <input type="hidden" name="_method" value="DELETE" />
        <input type="submit" value="Удалить" />
    </form>
    <p></p>
    <a href="/books">К списку книг</a>
</body>
</html>
"""

new_book_template = u"""
<!DOCTYPE html>
<html>
<head>
    <title>Книга - Добавить</title>
    <style type="text/css">.header {font-weight:bold;background-color:#cceecc;}</style>
</head>
<body>
    <h2>Книга - Добавить</h2>
    <form action="/books" method="POST">
    <table>
    <tr><td class="header">Автор</td><td><input type="text" name="author" /></td></tr>
    <tr><td class="header">Название</td><td><input type="text" name="title" /></td></tr>
    </table>
    <input type="submit" value="Сохранить" />
    </form>
    <p></p>
    <a href="/books">К списку книг</a>
</body>
</html>
"""

error404_template = u"""
<!DOCTYPE html>
<html>
<head>
    <title>Список книг</title>
</head>
<body>
    <h2>Такой страницы нет :(</h2>
</body>
</html>
"""

@app.route('/')
@app.route('/books')
def index():
    text = ''
    for key, val in books.items():
        text += u'<tr><td><a href="/books/%s/edit">Edit</a></td><td><a href="/books/%s">%s</a></td><td>%s</td><td>%s</td></tr>' % (key, key, key, val[0], val[1])
    return list_books_template % text

@app.route('/books/new')
def new():
    return new_book_template

@app.route('/books', methods=['POST'])
def create():
    new_id = len(books) + 1
    books[new_id] = [request.form['author'], request.form['title']]
    return show_book_template % (new_id, books[new_id][0], books[new_id][1])


@app.route('/books/<int:id>/edit')
def edit(id):
    if books.get(id):
        return edit_book_template % (id, id, books[id][0], books[id][1], id)
    else:
        abort(404)

@app.route('/books/<int:id>', methods=['GET', 'POST']) # PUT and DELETE
def show(id):
    if request.method == 'GET':

        # /books/<int:id> GET

        if books.get(id):
            return show_book_template % (id, books[id][0], books[id][1])
        else:
            abort(404)
    elif request.method == 'POST' and request.form['_method'] == 'PUT':

        # /books/<int:id> PUT

        if books.get(id):
            books[id] = [request.form['author'], request.form['title']]
        else:
            abort(404)
        return show_book_template % (id, books[id][0], books[id][1])
    elif request.method == 'POST' and request.form['_method'] == 'DELETE':

        # /books/<int:id> DELETE

        if books.get(id):
            del books[id]
        return redirect('/books')


@app.errorhandler(404)
def not_found(error):
    return error404_template, 404


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

Запустив приложение

C:\> python restful-server.py
 * Running on http://127.0.0.1:5000/
 * Restarting with reloader

и введя в браузере адрес http://localhost:5000/books, попробуйте просматривать, изменять, добавлять и удалять книги.

В отличие от web-сервиса, в браузерном RESTful приложении большую роль играют гиперссылки, содержащиеся в представлениях (HTML-страницах), возвращаемых сервером клиенту (браузеру). Гиперссылки раскрывают перед пользователем структуру приложения, предлагая ему на выбор возможные варианты действий.

Пока всё. Let's have a rest!

Комментариев нет:

Отправить комментарий