Python实现的Flask一个RESTful API 服务器端测试
REST已经成为web services和APIs的标准架构,很多APP的架构基本上是使用RESTful的形式了。
首先介绍:
REST的六个特性:
- Client-Server:服务器端与客户端分离。
- Stateless(无状态):每次客户端请求必需包含完整的信息,换句话说,每一次请求都是独立的。
- Cacheable(可缓存):服务器端必需指定哪些请求是可以缓存的。
- Layered System(分层结构):服务器端与客户端通讯必需标准化,服务器的变更并不会影响客户端。
- Uniform Interface(统一接口):客户端与服务器端的通讯方法必需是统一的。
- Code on demand(按需执行代码?):服务器端可以在上下文中执行代码或者脚本?
RESTful web service的样子
REST架构就是为了HTTP协议设计的。RESTful web services的核心概念是管理资源。资源是由URIs来表示,客户端使用HTTP当中的'POST, OPTIONS, GET, PUT, DELETE'等方法发送请求到服务器,改变相应的资源状态。
HTTP请求方法通常也十分合适去描述操作资源的动作:
HTTP方法 动作 例子 GET 获取资源信息 http://example.com/api/orders
(检索订单清单)
GET 获取资源信息 http://example.com/api/orders/123
(检索订单 #123)
POST 创建一个次的资源 http://example.com/api/orders
(使用带数据的请求,创建一个新的订单)
PUT 更新一个资源 http://example.com/api/orders/123
(使用带数据的请求,更新#123订单)
DELETE 删除一个资源 http://example.com/api/orders/123
删除订单#123
REST请求并不需要特定的数据格式,通常使用JSON作为请求体,或者URL的查询参数的一部份。
设计一个简单的web service
下面的任务将会练习设计以REST准则为指引,通过不同的请求方法操作资源,标识资源的例子。
我们将写一个To Do List 应用,并且设计一个web service。
第一步,规划一个根URL,例如:
http://[hostname]/todo/api/v1.0/
上面的URL包括了应用程序的名称、API版本,这是十分有用的,既提供了命名空间的划分,同时又与其它系统区分开来。版本号在升级新特性时十分有用,当一个新功能特性增加在新版本下面时,并不影响旧版本。
第二步,规划资源的URL,这个例子十分简单,只有任务清单。
规划如下:
HTTP方法 URI 动作 GET http://[hostname]/todo/api/v1.0/tasks 检索任务清单 GET http://[hostname]/todo/api/v1.0/tasks/[task_id] 检索一个任务 POST http://[hostname]/todo/api/v1.0/tasks 创建一个新任务 PUT http://[hostname]/todo/api/v1.0/tasks/[task_id] 更新一个已存在的任务 DELETE http://[hostname]/todo/api/v1.0/tasks/[task_id] 删除一个任务 我们定义任务清单有以下字段:
- id:唯一标识。整型。
- title:简短的任务描述。字符串型。
- description:完整的任务描述。文本型。
- done:任务完成状态。布尔值型。
以上基本完成了设计部份,接下来我们将会实现它!
使用Python 和 Flask实现RESTful services
使用Flask建立web services超级简单。
当然,也有很多Flask extensions可以帮助建立RESTful services,但是这个例实在太简单了,不需要使用任何扩展。
这个web service提供增加,删除、修改任务清单,所以我们需要将任务清单存储起来。最简单的做法就是使用小型的数据库,但是数据库并不是本文涉及太多的。可以参考原文作者的完整教程。Flask Mega-Tutorial series
在这里例子我们将任务清单存储在内存中,这样只能运行在单进程和单线程中,这样是不适合作为生产服务器的,若非就必需使用数据库了。
现在我们准备实现第一个web service的入口点:
#!flask/bin/python from flask import Flask, jsonify app = Flask(__name__) tasks = [ { 'id': 1, 'title': u'Buy groceries', 'description': u'Milk, Cheese, Pizza, Fruit, Tylenol', 'done': False }, { 'id': 2, 'title': u'Learn Python', 'description': u'Need to find a good Python tutorial on the web', 'done': False } ] @app.route('/todo/api/v1.0/tasks', methods=['GET']) def get_tasks(): return jsonify({'tasks': tasks}) if __name__ == '__main__': app.run(debug=True)
正如您所见,并没有改变太多代码。我们将任务清单存储在list内(内存),list存放两个非常简单的数组字典。每个实体就是我们上面定义的字段。
而 index 入口点有一个get_tasks函数与/todo/api/v1.0/tasksURI关联,只接受http的GET方法。
这个响应并非一般文本,是JSON格式的数据,是经过Flask框架的jsonify模块格式化过的数据。
使用浏览器去测试web service并不是一个好的办法,因为要创建不同类弄的HTTP请求,事实上,我们将使用curl命令行。如果没有安装curl,快点去安装一个。
像刚才一样运行app.py。
打开一个终端运行以下命令:
$ curl -i http://localhost:5000/todo/api/v1.0/tasks HTTP/1.0 200 OK Content-Type: application/json Content-Length: 294 Server: Werkzeug/0.8.3 Python/2.7.3 Date: Mon, 20 May 2013 04:53:53 GMT { "tasks": [ { "description": "Milk, Cheese, Pizza, Fruit, Tylenol", "done": false, "id": 1, "title": "Buy groceries" }, { "description": "Need to find a good Python tutorial on the web", "done": false, "id": 2, "title": "Learn Python" } ] }
这样就调用了一个RESTful service方法!
现在,我们写第二个版本的GET方法获取特定的任务。获取单个任务:
from flask import abort @app.route('/todo/api/v1.0/tasks/', methods=['GET']) def get_task(task_id): task = filter(lambda t: t['id'] == task_id, tasks) if len(task) == 0: abort(404) return jsonify({'task': task[0]})
第二个函数稍稍复杂了一些。任务的id包含在URL内,Flask将task_id参数传入了函数内。
通过参数,检索tasks数组。如果参数传过来的id不存在于数组内,我们需要返回错误代码404,按照HTTP的规定,404意味着是"Resource Not Found",资源未找到。
如果找到任务在内存数组内,我们通过jsonify模块将字典打包成JSON格式,并发送响应到客户端上。就像处理一个实体字典一样。
试试使用curl调用:
$ curl -i http://localhost:5000/todo/api/v1.0/tasks/2 HTTP/1.0 200 OK Content-Type: application/json Content-Length: 151 Server: Werkzeug/0.8.3 Python/2.7.3 Date: Mon, 20 May 2013 05:21:50 GMT { "task": { "description": "Need to find a good Python tutorial on the web", "done": false, "id": 2, "title": "Learn Python" } } $ curl -i http://localhost:5000/todo/api/v1.0/tasks/3 HTTP/1.0 404 NOT FOUND Content-Type: text/html Content-Length: 238 Server: Werkzeug/0.8.3 Python/2.7.3 Date: Mon, 20 May 2013 05:21:52 GMT
Not Found
The requested URL was not found on the server.
If you entered the URL manually please check your spelling and try again.
当我们请求#2 id的资源时,可以获取,但是当我们请求#3的资源时返回了404错误。并且返回了一段奇怪的HTML错误,而不是我们期望的JSON,这是因为Flask产生了默认的404响应。客户端需要收到的都是JSON的响应,因此我们需要改进404错误处理:
from flask import make_response @app.errorhandler(404) def not_found(error): return make_response(jsonify({'error': 'Not found'}), 404)
这样我们就得到了友好的API错误响应:
$ curl -i http://localhost:5000/todo/api/v1.0/tasks/3 HTTP/1.0 404 NOT FOUND Content-Type: application/json Content-Length: 26 Server: Werkzeug/0.8.3 Python/2.7.3 Date: Mon, 20 May 2013 05:36:54 GMT { "error": "Not found" }
接下来我们实现POST方法,插入一个新的任务到数组中:
from flask import request @app.route('/todo/api/v1.0/tasks', methods=['POST']) def create_task(): if not request.json or not 'title' in request.json: abort(400) task = { 'id': tasks[-1]['id'] + 1, 'title': request.json['title'], 'description': request.json.get('description', ""), 'done': False } tasks.append(task) return jsonify({'task': task}), 201
request.json里面包含请求数据,如果不是JSON或者里面没有包括title字段,将会返回400的错误代码。
当创建一个新的任务字典,使用最后一个任务id数值加1作为新的任务id(最简单的方法产生一个唯一字段)。这里允许不带description字段,默认将done字段值为False。
将新任务附加到tasks数组里面,并且返回客户端201状态码和刚刚添加的任务内容。HTTP定义了201状态码为“Created”。
测试上面的新功能:
$ curl -i -H "Content-Type: application/json" -X POST -d '{"title":"Read a book"}' http://localhost:5000/todo/api/v1.0/tasks HTTP/1.0 201 Created Content-Type: application/json Content-Length: 104 Server: Werkzeug/0.8.3 Python/2.7.3 Date: Mon, 20 May 2013 05:56:21 GMT { "task": { "description": "", "done": false, "id": 3, "title": "Read a book" } }
注意:如果使用原生版本的curl命令行提示符,上面的命令会正确执行。如果是在Windows下使用Cygwin bash版本的curl,需要将body部份添加双引号:
curl -i -H "Content-Type: application/json" -X POST -d "{"""title""":"""Read a book"""}" http://localhost:5000/todo/api/v1.0/tasks
基本上在Windows中需要使用双引号包括body部份在内,而且需要三个双引号转义序列。
完成上面的事情,就可以看到更新之后的list数组内容:
$ curl -i http://localhost:5000/todo/api/v1.0/tasks HTTP/1.0 200 OK Content-Type: application/json Content-Length: 423 Server: Werkzeug/0.8.3 Python/2.7.3 Date: Mon, 20 May 2013 05:57:44 GMT { "tasks": [ { "description": "Milk, Cheese, Pizza, Fruit, Tylenol", "done": false, "id": 1, "title": "Buy groceries" }, { "description": "Need to find a good Python tutorial on the web", "done": false, "id": 2, "title": "Learn Python" }, { "description": "", "done": false, "id": 3, "title": "Read a book" } ] }
剩余的两个函数如下:
@app.route('/todo/api/v1.0/tasks/', methods=['PUT']) def update_task(task_id): task = filter(lambda t: t['id'] == task_id, tasks) if len(task) == 0: abort(404) if not request.json: abort(400) if 'title' in request.json and type(request.json['title']) != unicode: abort(400) if 'description' in request.json and type(request.json['description']) is not unicode: abort(400) if 'done' in request.json and type(request.json['done']) is not bool: abort(400) task[0]['title'] = request.json.get('title', task[0]['title']) task[0]['description'] = request.json.get('description', task[0]['description']) task[0]['done'] = request.json.get('done', task[0]['done']) return jsonify({'task': task[0]}) @app.route('/todo/api/v1.0/tasks/', methods=['DELETE']) def delete_task(task_id): task = filter(lambda t: t['id'] == task_id, tasks) if len(task) == 0: abort(404) tasks.remove(task[0]) return jsonify({'result': True})
delete_task函数没什么太特别的。update_task函数需要检查所输入的参数,防止产生错误的bug。确保是预期的JSON格式写入数据库里面。
测试将任务#2的done字段变更为done状态:
$ curl -i -H "Content-Type: application/json" -X PUT -d '{"done":true}' http://localhost:5000/todo/api/v1.0/tasks/2 HTTP/1.0 200 OK Content-Type: application/json Content-Length: 170 Server: Werkzeug/0.8.3 Python/2.7.3 Date: Mon, 20 May 2013 07:10:16 GMT { "task": [ { "description": "Need to find a good Python tutorial on the web", "done": true, "id": 2, "title": "Learn Python" } ] }
改进Web Service接口
当前我们还有一个问题,客户端有可能需要从返回的JSON中重新构造URI,如果将来加入新的特性时,可能需要修改客户端。(例如新增版本。)
我们可以返回整个URI的路径给客户端,而不是任务的id。为了这个功能,创建一个小函数生成一个“public”版本的任务URI返回:
from flask import url_for def make_public_task(task): new_task = {} for field in task: if field == 'id': new_task['uri'] = url_for('get_task', task_id=task['id'], _external=True) else: new_task[field] = task[field] return new_task
通过Flask的url_for模块,获取任务时,将任务中的id字段替换成uri字段,并且把值改为uri值。
当我们返回包含任务的list时,通过这个函数处理后,返回完整的uri给客户端:
@app.route('/todo/api/v1.0/tasks', methods=['GET']) def get_tasks(): return jsonify({'tasks': map(make_public_task, tasks)})
现在看到的检索结果:
$ curl -i http://localhost:5000/todo/api/v1.0/tasks HTTP/1.0 200 OK Content-Type: application/json Content-Length: 406 Server: Werkzeug/0.8.3 Python/2.7.3 Date: Mon, 20 May 2013 18:16:28 GMT { "tasks": [ { "title": "Buy groceries", "done": false, "description": "Milk, Cheese, Pizza, Fruit, Tylenol", "uri": "http://localhost:5000/todo/api/v1.0/tasks/1" }, { "title": "Learn Python", "done": false, "description": "Need to find a good Python tutorial on the web", "uri": "http://localhost:5000/todo/api/v1.0/tasks/2" } ] }
这种办法避免了与其它功能的兼容,拿到的是完整uri而不是一个id。
RESTful web service的安全认证
我们已经完成了整个功能,但是我们还有一个问题。web service任何人都可以访问的,这不是一个好主意。
当前service是所有客户端都可以连接的,如果有别人知道了这个API就可以写个客户端随意修改数据了。 大多数教程没有与安全相关的内容,这是个十分严重的问题。
最简单的办法是在web service中,只允许用户名和密码验证通过的客户端连接。在一个常规的web应用中,应该有登录表单提交去认证,同时服务器会创建一个会话过程去进行通讯。这个会话过程id会被存储在客户端的cookie里面。不过这样就违返了我们REST中无状态的规则,因此,我们需求客户端每次都将他们的认证信息发送到服务器。
为此我们有两种方法表单认证方法去做,分别是 Basic 和 Digest。
这里有有个小Flask extension可以轻松做到。首先需要安装Flask-HTTPAuth:
$ flask/bin/pip install flask-httpauth
假设web service只有用户ok和密码为python的用户接入。下面就设置了一个Basic HTTP认证:
from flask.ext.httpauth import HTTPBasicAuth auth = HTTPBasicAuth() @auth.get_password def get_password(username): if username == 'ok': return 'python' return None @auth.error_handler def unauthorized(): return make_response(jsonify({'error': 'Unauthorized access'}), 401)
get_password函数是一个回调函数,获取一个已知用户的密码。在复杂的系统中,函数是需要到数据库中检查的,但是这里只是一个小示例。
当发生认证错误之后,error_handler回调函数会发送错误的代码给客户端。这里我们自定义一个错误代码401,返回JSON数据,而不是HTML。
将@auth.login_required装饰器添加到需要验证的函数上面:
@app.route('/todo/api/v1.0/tasks', methods=['GET']) @auth.login_required def get_tasks(): return jsonify({'tasks': tasks})
现在,试试使用curl调用这个函数:
$ curl -i http://localhost:5000/todo/api/v1.0/tasks HTTP/1.0 401 UNAUTHORIZED Content-Type: application/json Content-Length: 36 WWW-Authenticate: Basic realm="Authentication Required" Server: Werkzeug/0.8.3 Python/2.7.3 Date: Mon, 20 May 2013 06:41:14 GMT { "error": "Unauthorized access" }
这里表示了没通过验证,下面是带用户名与密码的验证:
$ curl -u ok:python -i http://localhost:5000/todo/api/v1.0/tasks HTTP/1.0 200 OK Content-Type: application/json Content-Length: 316 Server: Werkzeug/0.8.3 Python/2.7.3 Date: Mon, 20 May 2013 06:46:45 GMT { "tasks": [ { "title": "Buy groceries", "done": false, "description": "Milk, Cheese, Pizza, Fruit, Tylenol", "uri": "http://localhost:5000/todo/api/v1.0/tasks/1" }, { "title": "Learn Python", "done": false, "description": "Need to find a good Python tutorial on the web", "uri": "http://localhost:5000/todo/api/v1.0/tasks/2" } ] }
这个认证extension十分灵活,可以随指定需要验证的APIs。
为了确保登录信息的安全,最好的办法还是使用https加密的通讯方式,客户端与服务器端传输认证信息都是加密过的,防止第三方的人去看到。
当使用浏览器去访问这个接口,会弹出一个丑丑的登录对话框,如果密码错误就回返回401的错误代码。为了防止浏览器弹出验证对话框,客户端应该处理好这个登录请求。
有一个小技巧可以避免这个问题,就是修改返回的错误代码401。例如修改成403(”Forbidden“)就不会弹出验证对话框了。
@auth.error_handler def unauthorized(): return make_response(jsonify({'error': 'Unauthorized access'}), 403)
当然,同时也需要客户端知道这个403错误的意义。
最后
还有很多办法去改进这个web service。
事实上,一个真正的web service应该使用真正的数据库。使用内存数据结构有非常多的限制,不要用在实际应用上面。
另外一方面,处理多用户。如果系统支持多用户认证,则任务清单也是对应多用户的。同时我们需要有第二种资源,用户资源。当用户注册时使用POST请求。使用GET返回用户信息到客户端。使用PUT请求更新用户资料,或者邮件地址。使用DELETE删除用户账号等。
通过GET请求检索任务清单时,有很多办法可以进扩展。第一,可以添加分页参数,使客户端只请求一部份数据。第二,可以添加筛选关键字等。所有这些元素可以添加到URL上面的参数。
上一篇: ThinkPHP 二维码生成
推荐阅读
-
Python实现的Flask一个RESTful API 服务器端测试
-
实现一个完整的Node.js RESTful API的示例
-
python+flask实现API的方法
-
实现一个Python+Selenium的自动化测试框架
-
实现一个Python+Selenium的自动化测试框架就这么简单!
-
Python实现一个简单三层神经网络的搭建及测试 代码解析
-
Python实现Restful API的例子
-
python Flask实现restful api service
-
Python使用Flask实现RESTful API,使用Postman工具、requests库测试接口
-
用Python实现一个打字速度测试工具来测试你的手速