FastApi-Admin开源模板

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来进行测试

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值