hello,最近也没怎么写文章,仔细的研究了一下fastapi这款框架,再加上最近工作也不是很顺心,自从上次裁员之后,也没找到特别合适的工作,说实话最近是真的难啊。先不闲聊了 聊一聊刚刚完工的fastapi-admin这款框架吧,也不是特别会写文档,大家就将就看看,有问题的话 也希望大家能提出来,毕竟第一次做这种,哈哈 不能说特别有经验吧
1、技术选取
技术这一款主要使用的是python3.11,fastapi 0.111.1 uvicorn 0.30.1 tortoise-orm 0.21.5
Gitee仓库 gitee
GitHub仓库 GitHub
特性
- python3.11
- fastapi 0.111.1
- uvicorn 0.30.1
- oss2 2.18.3
- 路由级的权限控制
- 集成JWT令牌验证:PyJWT 2.8.0
- 便捷的分页模式:fastapi-pagination 0.12.29
- 异步的orm类库:tortoise-orm 0.21.5
- 增加了异常拦截以及响应体拦截
- 集成了docker,无需担心环境问题
- 对整体路由进行了请求速率的限制
- 集成了pytest
- 异步的redis连接池
项目架构
|——admin
├─api
├─v1 #接口版本
│─admin_user #admin-user模块
│─models #数据库模型
│─route #路由模块
│─schemas #响应模块
│─services #业务模块
│─ main #初始化路由
├─auth #权限模块
├─authorization
|——interceptors #拦截器
|——http_intercept #http异常拦截器
|——middleware #中间件
|——logger_middleware #日志中间件
|——response_intercept #响应体异常拦截中间件
├─sql_app #sql配置模块
├─mysqlServe #mysql
├─redisServe #redis
├─static #静态目录
├─tests #测试模块
├─utils #工具函数
├─scopes #权限实例化工具
├─password #密码加密函数
├─toolkit #时间格式化函数
├─config.py #环境配置文件
├─.env #环境文件
├─main #入口文件
├─requirements #模块包文件
项目介绍
权限
先介绍一下基于路由的权限控制fastapi本身在路由上就有着不错的权限集成,这里我把他封装了一下,在utils.scopes里面,
import json
class Permissions:
code: str | int
class Scopes:
roles: list[str]
permissions: list[Permissions]
def __init__(self, roles, permissions) -> None:
self.roles = roles
self.permissions = permissions
def set_scopes(scopes: Scopes) -> list[str]:
if type(scopes) is not Scopes:
raise Exception("类型不为Scopes")
scopes = scopes.__dict__
data = json.dumps(scopes)
return [data]
这里会通过路由的dependencies 传入一个Security,通过Security进行权限的控制,路由这里是这样的:
@user.delete(
"/permissions/{id}",
summary="删除权限",
dependencies=[
Security(auth, scopes=set_scopes(Scopes(roles=["admin"], permissions=["1"])))
],
)
async def del_permissions(id: int):
return await delete_permissions(id)
这里通过依赖注入的方式注入auth函数来进行权限的判断,通过实例化Scopes这个类,来对路由进行权限控制,
auto函数这里是这样的:
# 权限控制
def auth(securityScopes: SecurityScopes, Authorization: str = Header()):
scopes = securityScopes.scopes[0]
scopes = json.loads(scopes)
# 判断登录状态
if Authorization == None:
raise HTTPException(status_code=401, detail="Not Log In")
Authorization = verify_token(Authorization)
if Authorization["is_frozen"] is True:
raise HTTPException(status_code=404, detail="NOT FOUND")
# 判断角色身份
roles = json.loads(Authorization["roles"])
for k in roles:
if k not in scopes["roles"]:
raise HTTPException(status_code=404, detail="NOT FOUND")
# 判断角色权限
permissions = json.loads(Authorization["permissions"])
for k in permissions:
if str(k["code"]) not in scopes["permissions"]:
raise HTTPException(status_code=404, detail="NOT FOUND")
这里会先判断用户是否被冻结,然后依次判断权限->角色身份->角色权限
因为基于rbac权限 考虑到会有多个角色,多种权限的可能性,所以采用了这种方式。
响应拦截中间件
然后再讲一讲相应拦截中间件ResponseInterceptor,这里的ResponseInterceptor是一个类,通过继承BaseHTTPMiddleware,并且重写dispatch方法来实现拦截的:
import json
from fastapi import Request, Response
from fastapi.responses import JSONResponse
from starlette.middleware.base import BaseHTTPMiddleware
from api.v1.admin_user.services import creat_admin
from starlette.responses import StreamingResponse
# 响应拦截中间件
class ResponseInterceptor(BaseHTTPMiddleware):
async def dispatch(self, request: Request, call_next) -> StreamingResponse:
response: Response = None
try:
response: StreamingResponse = await call_next(request)
except Exception as e:
return JSONResponse(
status_code=500,
content={"code": 500, "data": None, "message": "Internal Server Error"},
)
await creat_admin()
if request.url.path in ["/docs", "/redoc", "/openapi.json"]:
return response
code = response.status_code
response_body = b""
global data
global cleaned_data
cleaned_data = {}
async for chunk in response.body_iterator:
response_body += chunk
if response_body:
cleaned_data = json.loads(response_body.decode("utf-8"))
if code == 200:
data = JSONResponse(
status_code=code,
content={"code": code, "message": "success", "data": cleaned_data},
)
else:
error_message = cleaned_data.get("detail")
data = JSONResponse(
status_code=code,
content={"code": code, "message": error_message, "data": None},
)
return data
这里重写了相应类,并且需要判断一下swagger的路径 并返回原始的response,这里只是重写了相应结构,并且对发送request的时候做了异常拦截,并返回服务器异常,因为fastapi底层是基于starlette的,starlette在这里对相应类进行了重写,并且以流的形式传递,这里又将他转换了一下,响应结构就变成了:
{
'code':200,
'message':'success',
'data':null
}
这样对于前端来说也是比较友好的,比较好处理后续的响应,但是这样的话在后面做响应验证的时候就只能去验证data里面的内容,对于code和message就无法做到验证了。
异常拦截中间件
对于异常拦截这里:
from fastapi import FastAPI, HTTPException, Request
from starlette.exceptions import HTTPException as StarletteHTTPException
from jwt import PyJWTError
from fastapi.exceptions import RequestValidationError
from tortoise.exceptions import DoesNotExist, IntegrityError
class ApiExceptionInterception:
def __init__(self, app: FastAPI, *args, **kwargs) -> None:
super().__init__(*args, **kwargs)
if app is not None:
self.init_app(app)
def init_app(self, app: FastAPI):
app.add_exception_handler(RequestValidationError, handler=self.all_verify)
app.add_exception_handler(PyJWTError, handler=self.all_jwterror)
app.add_exception_handler(DoesNotExist, handler=self.all_doesnotexist)
app.add_exception_handler(IntegrityError, handler=self.integrity_error)
async def all_jwterror(self, reqiest: Request, exc: PyJWTError):
raise HTTPException(
status_code=401,
detail=str(exc),
)
async def all_doesnotexist(self, reqiest: Request, exc: DoesNotExist):
raise HTTPException(
status_code=404,
detail=str(exc),
)
async def all_verify(self, reqiest: Request, exc: RequestValidationError):
raise HTTPException(
status_code=422,
detail="Validation Error",
)
async def all_http_error(self, reqiest: Request, exc: StarletteHTTPException):
raise HTTPException(
status_code=exc.status_code,
detail=str(exc.detail),
)
async def all_exception_handler(self, reqiest: Request, exc: HTTPException):
raise HTTPException(
status_code=exc.status_code,
detail=str(exc.detail),
)
async def integrity_error(self, reqiest: Request, exc: IntegrityError):
raise HTTPException(
status_code=400,
detail=str(exc),
)
做了基本的拦截处理
阿里云oss上传
项目本身集成了阿里云的oss文件上传,因为是demo 并没有做过多的限制
import os
import oss2
import secrets
import string
from config import settings
from fastapi import UploadFile, HTTPException
async def upload_file(file: UploadFile):
_, extension = os.path.splitext(file.filename)
content = await file.read()
file_name = (
"".join(secrets.choice(string.ascii_letters + string.digits) for i in range(16))
+ extension
)
auth = oss2.AuthV4(settings.ACCESS_KEY_ID, settings.ACCESSKEY_SECRET)
bucket = oss2.Bucket(
auth,
"https://oss-cn-beijing.aliyuncs.com",
"fastapi-admin1",
region="cn-beijing",
)
try:
result = bucket.put_object(f"static/{file_name}", content)
if result.status != 200:
raise HTTPException(status_code=400, detail="文件上传失败")
else:
url = (
"你自己的ossurl"
+ f"static/{file_name}"
)
return {"url": url}
except Exception as e:
raise HTTPException(status_code=400, detail="文件上传失败")
请求速率限制
项目集成了slowapi,对请求速率进行了限制在main.py里面
limiter = Limiter(key_func=get_remote_address, default_limits=["1/1 seconds"])
app.state.limiter = limiter
app.add_exception_handler(RateLimitExceeded, _rate_limit_exceeded_handler)
app.add_middleware(SlowAPIMiddleware)
这里限制了1s一次请求
测试
测试这里集成了pytest,在运行的时候请保证关闭请求速率限制的中间件,不然测试不会通过
运行环境
项目本身集成了docker和docker compose 作为一个小的应用架构来使用,所以不用担心自己本地没有环境运行,只需要有docker 就行,
本身启动了mysql的3306以及redis的 6379 还有 3000端口 运行的时候请保证本地端口无占用,或者自行更改端口
对于环境文件.env 请自行配置
代码规范
集成了blcak和pylint以及commitizen,commitizen我自己不怎么用,但还是加上吧,为了统一代码规范,在提交的时候可以自行跑一次 black . pylint . 来格式化代码保证代码风格一致,并且python对于格式来说是比较严格的。
最后说一下测试的问题,测试这里目前是不清楚什么原因无法通过setting进行读取数据库的配置,只能通过手动进行设置,然后可能是fastapi本身集成swagger的问题,这里通过swagger验证Authorization会不通过,建议先使用apifox或者postman来进行测试