本文正在参加「Python主题月」,详情查看活动链接
1.前记
上次在掘进转了一篇自己之前写的文章:给Python Web框架接口加上类型检查,这篇文章是两年前写的,一年前的时候就想把这个想法转化成一个项目pait, 然后就开始去实践, 经过了一年的迭代,目前已经到了0.6版本了(虽然中间偷懒了几个月, 下图是第一个提交的commit记录)
到目前为止,
pait支持的功能有:
- 参数校验和自动转化(参数校验依赖于
Pydantic) - 参数关系依赖校验
- 自动生成openapi文件
- 支持swagger,redoc路由
- 返回mock响应
- TestClient支持, 支持响应结果校验
pait的参数校验方法和FastAPI的用法很像, 这是我在使用FastAPI一段时间后参考他的一些方法设计,但是pait对自己的定位是框架拓展,目前支持flask, starlette, sanic, tornado, 它不会去影响任何一个框架原来的用法。
2.一个简单的示例
以下是一个flask结合pait是使用示例, 使用例子非常简单,同时节省掉大量校验的代码:
from typing import Any, Dict
from flask import Flask
from pydantic import ValidationError
from pait.app.flask import pait
from pait.app.flask import add_doc_route
from pait.exceptions import PaitBaseException
from pait.field import Query
def api_exception(exc: Exception) -> Dict[str, Any]:
return {"code": -1, "msg": str(exc)}
@pait()
def test_get(
uid: int = Query.i(gt=100000, le=999999),
name: str = Query.i("", max_length=10),
) -> dict:
return {"code": 0, "data": {"uid": uid, "name": name}}
def create_app() -> Flask:
app: Flask = Flask(__name__)
app.add_url_rule("/api/test_get", view_func=test_get, methods=["GET"])
app.errorhandler(PaitBaseException)(api_exception)
app.errorhandler(ValidationError)(api_exception)
return app
if __name__ == "__main__":
create_app().run(port=8000, debug=True)
这里面只有一个叫/api/test_get的接口, 这个路由函数被@pait装饰器所装饰,该函数有两个参数,他们都是通过Query获取到url参数,其中uid被限定为大于100000小于999999的值,而name的最大长度被限定为10.
pait装饰器会抛出两种错误类型PaitBaseException和ValidationError, 一般情况下都是表明哪个参数校验错误,我们可以通过flask的app.errorhandler给捕获起来。
首先跑一跑请求看看参数校验结果如何:
# 正常请求
(.venv) ➜ ~ curl -s "http://127.0.0.1:8000/api/test_get?uid=666666&name=test_user"
{
"code": 0,
"data": {
"name": "test_user",
"uid": 666666
}
}
# uid范围不对
(.venv) ➜ ~ curl -s "http://127.0.0.1:8000/api/test_get?uid=66666&name=test_user"
{
"code": -1,
"msg": "File \"/home/so1n/github/pait/example/__init__.py\", line 14, in test_get. error:File \"/home/so1n/github/pait/example/__init__.py\", line 14, in test_get. error:1 validation error for DynamicModel\nuid\n ensure this value is greater than 100000 (type=value_error.number.not_gt; limit_value=100000)"
}
# name长度不对
(.venv) ➜ ~ curl -s "http://127.0.0.1:8000/api/test_get?uid=666666&name=test_useraaaaaaaaa"
{
"code": -1,
"msg": "File \"/home/so1n/github/pait/example/__init__.py\", line 14, in test_get. error:File \"/home/so1n/github/pait/example/__init__.py\", line 14, in test_get. error:1 validation error for DynamicModel\nname\n ensure this value has at most 10 characters (type=value_error.any_str.max_length; limit_value=10)"
}
由于返回结果被序列化了,错误的请求结果有点不太清晰,但可以明显的看得出第一个请求结果是正常返回的, 第二个响应提醒的是该文件下的第14行的test_get函数出错了, 这里要传的参数uid的值没有大于100000, 第三个参数name的值长度大于最大的限制10.
3.文档生成
除了参数校验外,pait还支持文档支持的功能,根据前面的代码进行一点点拓展,就可以支持文档生成了:
from typing import Any, Dict, Optional, Type
from flask import Flask
from pydantic import BaseModel, Field, ValidationError
from pait.app.flask import pait
from pait.app.flask import add_doc_route
from pait.exceptions import PaitBaseException
from pait.field import Query
from pait.model.status import PaitStatus
from pait.model.response import PaitResponseModel
class ResponseModel(BaseModel):
code: int = Field(0, description="api code")
msg: str = Field("success", description="api status msg")
class SuccessRespModel(PaitResponseModel):
class _ResponseModel(ResponseModel):
class DataModel(BaseModel):
uid: int = Field(description="user id")
name: str = Field(description="用户名")
data: DataModel
description: str = "success response"
response_data: Optional[Type[BaseModel]] = _ResponseModel
class FailRespModel(PaitResponseModel):
class ResponseFailModel(ResponseModel):
code: int = Field(1, description="api code")
msg: str = Field("fail", description="api status msg")
description: str = "fail response"
response_data: Optional[Type[BaseModel]] = ResponseFailModel
def api_exception(exc: Exception) -> Dict[str, Any]:
return {"code": -1, "msg": str(exc)}
@pait(
author=('So1n', ),
tag=("test", ),
status=PaitStatus.test,
response_model_list=[SuccessRespModel, FailRespModel]
)
def test_get(
uid: int = Query.i(description="用户id", gt=100000, le=999999),
name: str = Query.i("", description="用户名", max_length=10),
) -> dict:
"""测试接口"""
return {"code": 0, "data": {"uid": uid, "name": name}}
def create_app() -> Flask:
app: Flask = Flask(__name__)
add_doc_route(app)
app.add_url_rule("/api/test_get", view_func=test_get, methods=["GET"])
app.errorhandler(PaitBaseException)(api_exception)
app.errorhandler(ValidationError)(api_exception)
return app
if __name__ == "__main__":
create_app().run(port=8000, debug=True)
可以看到pait装饰器多了一些参数:
- author: api接口作者名称
- tag: api接口所属的标签
- status: api接口状态
- response_model_list api接口可能返回的响应类型
同时在create_app多了一段app.add_doc_route(app)的代码,这一句主要是提供在线文档支持,同时支持Swagger和Redoc两种文档类型,这里以我喜欢的Redoc为例子(使用Redoc文档时,字段的description一定不能为空)这时候浏览器请求http://127.0.0.1:8000/redoc即可看到文档界面:
可以看到已经自动的生成一个API文档, 请求信息和响应信息也都给了出来,不过flask框架会给用户添加的路由自动增加HEAD,OPIS等方法, 这是非常棒的,但是我们一般暴露给客户端的文档不需要这两个方法,pait也没法自动识别出来。所以pait通过全局变量pait.config的初始化方法来进行配置,决定哪些方法不显示,代码如下:
# 上面代码省略
if __name__ == "__main__":
from pait.g import config
config.init_config(block_http_method_set={"HEAD", "OPTIONS"})
create_app().run(port=8000, debug=True)
之后再次请求http://127.0.0.1:8000/redoc即可看到文档界面只剩下Get方法了:
4.mock响应
通常后端的开发流程是拿到了需求然后就进行需求分析,再输出一份接口文档给前端,然后前后端再一起开发,这时我们的接口代码是还没开始写的,但是前端可能需要进行测试,这时就可以利用pait的mock响应功能,示例代码如下:
from typing import Any, Dict, Optional, Type
from flask import Flask
from pydantic import BaseModel, Field, ValidationError
from pait.app.flask import pait
from pait.app.flask import add_doc_route
from pait.exceptions import PaitBaseException
from pait.field import Query
from pait.model.status import PaitStatus
from pait.model.response import PaitResponseModel
class ResponseModel(BaseModel):
code: int = Field(0, description="api code")
msg: str = Field("success", description="api status msg")
class SuccessRespModel(PaitResponseModel):
class _ResponseModel(ResponseModel):
class DataModel(BaseModel):
uid: int = Field(description="user id")
name: str = Field("example_name", description="用户名")
data: DataModel
description: str = "success response"
response_data: Optional[Type[BaseModel]] = _ResponseModel
class FailRespModel(PaitResponseModel):
class ResponseFailModel(ResponseModel):
code: int = Field(1, description="api code")
msg: str = Field("fail", description="api status msg")
description: str = "fail response"
response_data: Optional[Type[BaseModel]] = ResponseFailModel
def api_exception(exc: Exception) -> Dict[str, Any]:
return {"code": -1, "msg": str(exc)}
@pait(
author=('So1n', ),
tag=("test", ),
status=PaitStatus.test,
response_model_list=[SuccessRespModel, FailRespModel]
)
def test_get(
uid: int = Query.i(description="用户id", gt=100000, le=999999),
name: str = Query.i("", description="用户名", max_length=10),
) -> dict:
"""测试接口"""
# return {"code": 0, "data": {"uid": uid, "name": name}}
pass
def create_app() -> Flask:
app: Flask = Flask(__name__)
add_doc_route(app)
app.add_url_rule("/api/test_get", view_func=test_get, methods=["GET"])
app.errorhandler(PaitBaseException)(api_exception)
app.errorhandler(ValidationError)(api_exception)
return app
if __name__ == "__main__":
from pait.g import config
config.init_config(block_http_method_set={"HEAD", "OPTIONS"}, enable_mock_response=True)
create_app().run(port=8000, debug=True)
里面的主要变化是:
test_get路由的return被注释掉,并增加pass- 成功响应的
name: str = Field(description="用户名")被改为name: str = Field("example_name", description="用户名") config的初始化配置参数enable_mock_response为True,代表本次运行时的接口都返回mock响应,此外,pait默认会选取路由函数的response_model_list的第一个类,如果有其他需求则需要使用config.init_config的enable_mock_response_fn参数对response_model_list的类进行选取。
接下来就是进行请求测试, 可以发现uid参数是int类型,所以他的值为0,name参数由于配置了默认值,所以直接显示他的默认值example_name:
(.venv) ➜ ~ curl -s "http://127.0.0.1:8000/api/test_get?uid=666666&name=test_user"
{
"code": 0,
"data": {
"name": "example_name",
"uid": 0
},
"msg": "success"
}
5.Test client helper
由于pait的定位的Web框架的拓展,所以不会去修改框架的源码或者嵌入到框架之中, 所以并不会为每个请求的响应进行校验, 减少请求性能的影响, 但是可以在测试用例的时候进行校验。pait每个Web框架提供对应的Test client helper,每个helper的逻辑就是造出一个请求,并对响应结果进行校验,校验成功才返回给用户,这时候返回的响应类型是跟每个Web框架的TestClient响应是一致的,以上面的3.文档生成的代码为例子,首先是把test_get路由名更改为get_route防止pytest误判,然后添加一个is_error_resp参数, 如果该参数为1则会返回的数据结构缺少name字段,剩下的就是添加一个测试用例, 代码如下:
from typing import Any, Dict, Optional, Type
from flask import Flask
from pydantic import BaseModel, Field, ValidationError
from pait.app.flask import pait
from pait.app.flask import add_doc_route
from pait.exceptions import PaitBaseException
from pait.field import Query
from pait.model.status import PaitStatus
from pait.model.response import PaitResponseModel
class ResponseModel(BaseModel):
code: int = Field(0, description="api code")
msg: str = Field("success", description="api status msg")
class SuccessRespModel(PaitResponseModel):
class _ResponseModel(ResponseModel):
class DataModel(BaseModel):
uid: int = Field(description="user id")
name: str = Field(description="用户名")
data: DataModel
description: str = "success response"
response_data: Optional[Type[BaseModel]] = _ResponseModel
class FailRespModel(PaitResponseModel):
class ResponseFailModel(ResponseModel):
code: int = Field(1, description="api code")
msg: str = Field("fail", description="api status msg")
description: str = "fail response"
response_data: Optional[Type[BaseModel]] = ResponseFailModel
def api_exception(exc: Exception) -> Dict[str, Any]:
return {"code": -1, "msg": str(exc)}
@pait(
author=('So1n', ),
tag=("test", ),
status=PaitStatus.test,
response_model_list=[SuccessRespModel, FailRespModel]
)
def get_route(
uid: int = Query.i(description="用户id", gt=100000, le=999999),
name: str = Query.i("", description="用户名", max_length=10),
is_error_resp: int = Query.i(0, description="决定是否是不符合SuccessRespModel的响应")
) -> dict:
"""测试接口"""
return_dict: dict = {"code": 0, "data": {"uid": uid, "name": name}}
if is_error_resp:
return_dict = {"code": 0, "data": {"uid": uid}}
return return_dict
def create_app() -> Flask:
app: Flask = Flask(__name__)
add_doc_route(app)
app.add_url_rule("/api/test_get", view_func=get_route, methods=["GET"])
app.errorhandler(PaitBaseException)(api_exception)
app.errorhandler(ValidationError)(api_exception)
return app
# -------
# 测试代码
# -------
import pytest
from typing import Generator
from flask import Flask, Response
from flask.testing import FlaskClient
from flask.ctx import AppContext
from pait.app.flask import FlaskTestHelper
@pytest.fixture
def client() -> Generator[FlaskClient, None, None]:
# Flask provides a way to test your application by exposing the Werkzeug test Client
# and handling the context locals for you.
app: Flask = create_app()
client: FlaskClient = app.test_client()
# Establish an application context before running the tests.
ctx: AppContext = app.app_context()
ctx.push()
yield client # this is where the testing happens!
ctx.pop()
class TestFlask:
def test_get(self, client: FlaskClient) -> None:
test_helper: FlaskTestHelper[Response] = FlaskTestHelper(
client, get_route,
query_dict={"uid": 600000, "name": "test_user"}
)
test_helper.get().get_json()
test_helper = FlaskTestHelper(
client, get_route,
query_dict={"uid": 600000, "name": "test_user", "is_error_resp": 1}
)
test_helper.get().get_json()
主要关注的是TestFlask.test_get里面的test_helper,他会根据get_route检索出url和response_model_list, 再调用get的时候会拼接请求参数,然后通过client进行请求,把响应经过response_model_list进行对比,如果判断出响应的数据结构不符合response_model_list的所有响应类,则抛错。
下面的图是利用Pycharm对TestFlask执行测试的结果,他指明的哪个语句出错:
后面还有pait对响应结果出错的判断, 它指明了该响应最接近的respnose_model是哪个,且缺少了哪些字段:
E RuntimeError: response check error by:[<class 'tests.test.SuccessRespModel'>, <class 'tests.test.FailRespModel'>]. resp:<Response 33 bytes [200 OK]>, maybe error:1 validation error for _ResponseModel
E data -> name
E field required (type=value_error.missing)
6.总结
pait的目的就是在不嵌入Web框架的前提下,为每个Web框架都提供一个API良好的封装, 减轻一些写代码时繁琐的步骤。目前还有一些功能在陆续的迭代中, 目前的功能都是优先支持RESTurlAPI,其他HTTP API会等到功能迭代完成后才慢慢支持。以上只是一些简单的介绍,具体可以查看pait文档