Skip to content

Commit 92df527

Browse files
authoredFeb 7, 2025
添加schema响应模式 (#48)
1 parent 859c198 commit 92df527

File tree

10 files changed

+132
-83
lines changed

10 files changed

+132
-83
lines changed
 

‎.pre-commit-config.yaml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ repos:
88
- id: check-toml
99

1010
- repo: https://github.com/charliermarsh/ruff-pre-commit
11-
rev: v0.8.2
11+
rev: v0.9.5
1212
hooks:
1313
- id: ruff
1414
args:
@@ -19,7 +19,7 @@ repos:
1919
- id: ruff-format
2020

2121
- repo: https://github.com/pdm-project/pdm
22-
rev: 2.21.0
22+
rev: 2.22.3
2323
hooks:
2424
- id: pdm-export
2525
args:

‎.ruff.toml

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ select = [
1515
"RUF100",
1616
"I002",
1717
"F404",
18-
"TCH",
18+
"TC",
1919
"UP007"
2020
]
2121
preview = true
@@ -25,8 +25,8 @@ lines-between-types = 1
2525
order-by-type = true
2626

2727
[lint.per-file-ignores]
28-
"**/api/v1/*.py" = ["TCH"]
29-
"**/model/*.py" = ["TCH003"]
28+
"**/api/v1/*.py" = ["TC"]
29+
"**/model/*.py" = ["TC003"]
3030
"**/model/__init__.py" = ["F401"]
3131

3232
[format]

‎backend/app/admin/api/v1/auth/auth.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,8 @@
55

66
from backend.app.admin.service.auth_service import auth_service
77
from backend.common.security.jwt import DependsJwtAuth
8-
from backend.common.response.response_schema import response_base, ResponseModel
9-
from backend.app.admin.schema.token import GetSwaggerToken
8+
from backend.common.response.response_schema import response_base, ResponseModel, ResponseSchemaModel
9+
from backend.app.admin.schema.token import GetSwaggerToken, GetLoginToken
1010
from backend.app.admin.schema.user import Auth2
1111

1212
router = APIRouter()
@@ -19,7 +19,7 @@ async def swagger_login(form_data: OAuth2PasswordRequestForm = Depends()) -> Get
1919

2020

2121
@router.post('/login', summary='验证码登录')
22-
async def user_login(request: Request, obj: Auth2) -> ResponseModel:
22+
async def user_login(request: Request, obj: Auth2) -> ResponseSchemaModel[GetLoginToken]:
2323
data = await auth_service.login(request=request, obj=obj)
2424
return response_base.success(data=data)
2525

‎backend/app/admin/api/v1/auth/captcha.py

Lines changed: 9 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,8 @@
55
from fastapi_limiter.depends import RateLimiter
66
from starlette.concurrency import run_in_threadpool
77

8-
from backend.common.response.response_schema import ResponseModel, response_base
8+
from backend.app.admin.schema.captcha import GetCaptchaDetail
9+
from backend.common.response.response_schema import ResponseSchemaModel, response_base
910
from backend.core.conf import settings
1011
from backend.database.db import uuid4_str
1112
from backend.database.redis import redis_client
@@ -18,18 +19,18 @@
1819
summary='获取登录验证码',
1920
dependencies=[Depends(RateLimiter(times=5, seconds=10))],
2021
)
21-
async def get_captcha(request: Request) -> ResponseModel:
22+
async def get_captcha(request: Request) -> ResponseSchemaModel[GetCaptchaDetail]:
2223
"""
2324
此接口可能存在性能损耗,尽管是异步接口,但是验证码生成是IO密集型任务,使用线程池尽量减少性能损耗
2425
"""
2526
img_type: str = 'base64'
2627
img, code = await run_in_threadpool(img_captcha, img_byte=img_type)
2728
uuid = uuid4_str()
2829
request.app.state.captcha_uuid = uuid
29-
await redis_client.set(f'{settings.CAPTCHA_LOGIN_REDIS_PREFIX}:{uuid}', code, ex=settings.CAPTCHA_EXPIRATION_TIME)
30-
return response_base.success(
31-
data={
32-
'image_type': img_type,
33-
'image': img,
34-
}
30+
await redis_client.set(
31+
f'{settings.CAPTCHA_LOGIN_REDIS_PREFIX}:{uuid}',
32+
code,
33+
ex=settings.CAPTCHA_LOGIN_EXPIRE_SECONDS,
3534
)
35+
data = GetCaptchaDetail(image_type=img_type, image=img)
36+
return response_base.success(data=data)

‎backend/app/admin/api/v1/user.py

Lines changed: 13 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,8 @@
55
from fastapi import APIRouter, Query
66

77
from backend.common.security.jwt import CurrentUser, DependsJwtAuth
8-
from backend.common.pagination import paging_data, DependsPagination
9-
from backend.common.response.response_schema import response_base, ResponseModel
8+
from backend.common.pagination import paging_data, DependsPagination, PageData
9+
from backend.common.response.response_schema import response_base, ResponseModel, ResponseSchemaModel
1010
from backend.database.db import CurrentSession
1111
from backend.app.admin.schema.user import CreateUser, GetUserInfo, ResetPassword, UpdateUser, Avatar
1212
from backend.app.admin.service.user_service import UserService
@@ -30,7 +30,7 @@ async def password_reset(obj: ResetPassword) -> ResponseModel:
3030

3131

3232
@router.get('/{username}', summary='查看用户信息', dependencies=[DependsJwtAuth])
33-
async def get_user(username: str) -> ResponseModel:
33+
async def get_user(username: str) -> ResponseSchemaModel[GetUserInfo]:
3434
current_user = await UserService.get_userinfo(username=username)
3535
data = GetUserInfo(**select_as_dict(current_user))
3636
return response_base.success(data=data)
@@ -52,15 +52,22 @@ async def update_avatar(username: str, avatar: Avatar) -> ResponseModel:
5252
return response_base.fail()
5353

5454

55-
@router.get('', summary='(模糊条件)分页获取所有用户', dependencies=[DependsJwtAuth, DependsPagination])
55+
@router.get(
56+
'',
57+
summary='(模糊条件)分页获取所有用户',
58+
dependencies=[
59+
DependsJwtAuth,
60+
DependsPagination,
61+
],
62+
)
5663
async def get_all_users(
5764
db: CurrentSession,
5865
username: Annotated[str | None, Query()] = None,
5966
phone: Annotated[str | None, Query()] = None,
6067
status: Annotated[int | None, Query()] = None,
61-
) -> ResponseModel:
68+
) -> ResponseSchemaModel[PageData[GetUserInfo]]:
6269
user_select = await UserService.get_select(username=username, phone=phone, status=status)
63-
page_data = await paging_data(db, user_select, GetUserInfo)
70+
page_data = await paging_data(db, user_select)
6471
return response_base.success(data=page_data)
6572

6673

‎backend/app/admin/schema/captcha.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
#!/usr/bin/env python3
2+
# -*- coding: utf-8 -*-
3+
from pydantic import Field
4+
5+
from backend.common.schema import SchemaBase
6+
7+
8+
class GetCaptchaDetail(SchemaBase):
9+
image_type: str = Field(description='图片类型')
10+
image: str = Field(description='图片内容')

‎backend/common/pagination.py

Lines changed: 66 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -2,27 +2,25 @@
22
# -*- coding: utf-8 -*-
33
from __future__ import annotations
44

5-
import math
6-
5+
from math import ceil
76
from typing import TYPE_CHECKING, Generic, Sequence, TypeVar
87

98
from fastapi import Depends, Query
109
from fastapi_pagination import pagination_ctx
1110
from fastapi_pagination.bases import AbstractPage, AbstractParams, RawParams
1211
from fastapi_pagination.ext.sqlalchemy import paginate
1312
from fastapi_pagination.links.bases import create_links
14-
from pydantic import BaseModel
13+
from pydantic import BaseModel, Field
1514

1615
if TYPE_CHECKING:
1716
from sqlalchemy import Select
1817
from sqlalchemy.ext.asyncio import AsyncSession
1918

2019
T = TypeVar('T')
21-
DataT = TypeVar('DataT')
2220
SchemaT = TypeVar('SchemaT')
2321

2422

25-
class _Params(BaseModel, AbstractParams):
23+
class _CustomPageParams(BaseModel, AbstractParams):
2624
page: int = Query(1, ge=1, description='Page number')
2725
size: int = Query(20, gt=0, le=100, description='Page size') # 默认 20 条记录
2826

@@ -33,53 +31,90 @@ def to_raw_params(self) -> RawParams:
3331
)
3432

3533

36-
class _Page(AbstractPage[T], Generic[T]):
37-
items: Sequence[T] # 数据
38-
total: int # 总数据数
39-
page: int # 第n页
40-
size: int # 每页数量
41-
total_pages: int # 总页数
42-
links: dict[str, str | None] # 跳转链接
34+
class _Links(BaseModel):
35+
first: str = Field(..., description='首页链接')
36+
last: str = Field(..., description='尾页链接')
37+
self: str = Field(..., description='当前页链接')
38+
next: str | None = Field(None, description='下一页链接')
39+
prev: str | None = Field(None, description='上一页链接')
40+
41+
42+
class _PageDetails(BaseModel):
43+
items: list = Field([], description='当前页数据')
44+
total: int = Field(..., description='总条数')
45+
page: int = Field(..., description='当前页')
46+
size: int = Field(..., description='每页数量')
47+
total_pages: int = Field(..., description='总页数')
48+
links: _Links
49+
4350

44-
__params_type__ = _Params # 使用自定义的Params
51+
class _CustomPage(_PageDetails, AbstractPage[T], Generic[T]):
52+
__params_type__ = _CustomPageParams
4553

4654
@classmethod
4755
def create(
4856
cls,
49-
items: Sequence[T],
57+
items: list,
5058
total: int,
51-
params: _Params,
52-
) -> _Page[T]:
59+
params: _CustomPageParams,
60+
) -> _CustomPage[T]:
5361
page = params.page
5462
size = params.size
55-
total_pages = math.ceil(total / params.size)
56-
links = create_links(**{
57-
'first': {'page': 1, 'size': f'{size}'},
58-
'last': {'page': f'{math.ceil(total / params.size)}', 'size': f'{size}'} if total > 0 else None,
59-
'next': {'page': f'{page + 1}', 'size': f'{size}'} if (page + 1) <= total_pages else None,
60-
'prev': {'page': f'{page - 1}', 'size': f'{size}'} if (page - 1) >= 1 else None,
61-
}).model_dump()
63+
total_pages = ceil(total / params.size)
64+
links = create_links(
65+
first={'page': 1, 'size': size},
66+
last={'page': f'{ceil(total / params.size)}', 'size': size} if total > 0 else {'page': 1, 'size': size},
67+
next={'page': f'{page + 1}', 'size': size} if (page + 1) <= total_pages else None,
68+
prev={'page': f'{page - 1}', 'size': size} if (page - 1) >= 1 else None,
69+
).model_dump()
70+
71+
return cls(
72+
items=items,
73+
total=total,
74+
page=params.page,
75+
size=params.size,
76+
total_pages=total_pages,
77+
links=links, # type: ignore
78+
)
79+
6280

63-
return cls(items=items, total=total, page=params.page, size=params.size, total_pages=total_pages, links=links)
81+
class PageData(_PageDetails, Generic[SchemaT]):
82+
"""
83+
包含 data schema 的统一返回模型,适用于分页接口
6484
85+
E.g. ::
86+
87+
@router.get('/test', response_model=ResponseSchemaModel[PageData[GetApiDetail]])
88+
def test():
89+
return ResponseSchemaModel[PageData[GetApiDetail]](data=GetApiDetail(...))
90+
91+
92+
@router.get('/test')
93+
def test() -> ResponseSchemaModel[PageData[GetApiDetail]]:
94+
return ResponseSchemaModel[PageData[GetApiDetail]](data=GetApiDetail(...))
95+
96+
97+
@router.get('/test')
98+
def test() -> ResponseSchemaModel[PageData[GetApiDetail]]:
99+
res = CustomResponseCode.HTTP_200
100+
return ResponseSchemaModel[PageData[GetApiDetail]](code=res.code, msg=res.msg, data=GetApiDetail(...))
101+
"""
65102

66-
class _PageData(BaseModel, Generic[DataT]):
67-
page_data: DataT | None = None
103+
items: Sequence[SchemaT]
68104

69105

70-
async def paging_data(db: AsyncSession, select: Select, page_data_schema: SchemaT) -> dict:
106+
async def paging_data(db: AsyncSession, select: Select) -> dict:
71107
"""
72108
基于 SQLAlchemy 创建分页数据
73109
74110
:param db:
75111
:param select:
76-
:param page_data_schema:
77112
:return:
78113
"""
79-
_paginate = await paginate(db, select)
80-
page_data = _PageData[_Page[page_data_schema]](page_data=_paginate).model_dump()['page_data']
114+
paginated_data: _CustomPage = await paginate(db, select)
115+
page_data = paginated_data.model_dump()
81116
return page_data
82117

83118

84119
# 分页依赖注入
85-
DependsPagination = Depends(pagination_ctx(_Page))
120+
DependsPagination = Depends(pagination_ctx(_CustomPage))

‎backend/common/response/response_schema.py

Lines changed: 17 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -1,23 +1,19 @@
11
#!/usr/bin/env python3
22
# -*- coding: utf-8 -*-
3-
from datetime import datetime
4-
from typing import Any
3+
from typing import Any, Generic, TypeVar
54

65
from fastapi import Response
7-
from pydantic import BaseModel, ConfigDict
6+
from pydantic import BaseModel
87

98
from backend.common.response.response_code import CustomResponse, CustomResponseCode
10-
from backend.core.conf import settings
119
from backend.utils.serializers import MsgSpecJSONResponse
1210

13-
_ExcludeData = set[int | str] | dict[int | str, Any]
14-
15-
__all__ = ['ResponseModel', 'response_base']
11+
SchemaT = TypeVar('SchemaT')
1612

1713

1814
class ResponseModel(BaseModel):
1915
"""
20-
统一返回模型
16+
通用型统一返回模型,不包含 data schema
2117
2218
E.g. ::
2319
@@ -37,33 +33,26 @@ def test() -> ResponseModel:
3733
return ResponseModel(code=res.code, msg=res.msg, data={'test': 'test'})
3834
"""
3935

40-
# TODO: json_encoders 配置失效: https://github.com/tiangolo/fastapi/discussions/10252
41-
model_config = ConfigDict(json_encoders={datetime: lambda x: x.strftime(settings.DATETIME_FORMAT)})
42-
4336
code: int = CustomResponseCode.HTTP_200.code
4437
msg: str = CustomResponseCode.HTTP_200.msg
4538
data: Any | None = None
4639

4740

48-
class ResponseBase:
49-
"""
50-
统一返回方法
51-
52-
.. tip::
41+
class ResponseSchemaModel(ResponseModel, Generic[SchemaT]):
42+
"""包含 data schema 的统一返回模型,适用于非分页接口"""
5343

54-
此类中的方法将返回 ResponseModel 模型,作为一种编码风格而存在;
44+
data: SchemaT
5545

56-
E.g. ::
5746

58-
@router.get('/test')
59-
def test() -> ResponseModel:
60-
return response_base.success(data={'test': 'test'})
61-
"""
47+
class ResponseBase:
48+
"""统一返回方法"""
6249

6350
@staticmethod
64-
def __response(*, res: CustomResponseCode | CustomResponse = None, data: Any | None = None) -> ResponseModel:
51+
def __response(
52+
*, res: CustomResponseCode | CustomResponse = None, data: Any | None = None
53+
) -> ResponseModel | ResponseSchemaModel:
6554
"""
66-
请求成功返回通用方法
55+
请求返回通用方法
6756
6857
:param res: 返回信息
6958
:param data: 返回数据
@@ -76,15 +65,15 @@ def success(
7665
*,
7766
res: CustomResponseCode | CustomResponse = CustomResponseCode.HTTP_200,
7867
data: Any | None = None,
79-
) -> ResponseModel:
68+
) -> ResponseModel | ResponseSchemaModel:
8069
return self.__response(res=res, data=data)
8170

8271
def fail(
8372
self,
8473
*,
8574
res: CustomResponseCode | CustomResponse = CustomResponseCode.HTTP_400,
8675
data: Any = None,
87-
) -> ResponseModel:
76+
) -> ResponseModel | ResponseSchemaModel:
8877
return self.__response(res=res, data=data)
8978

9079
@staticmethod
@@ -94,11 +83,11 @@ def fast_success(
9483
data: Any | None = None,
9584
) -> Response:
9685
"""
97-
此方法是为了提高接口响应速度而创建的,如果返回数据无需进行 pydantic 解析和验证,则推荐使用,相反,请不要使用!
86+
此方法是为了提高接口响应速度而创建的,在解析较大 json 时有显著性能提升,但将丢失 pydantic 解析和验证
9887
9988
.. warning::
10089
101-
使用此返回方法时,不要指定接口参数 response_model,也不要在接口函数后添加箭头返回类型
90+
使用此返回方法时,不能指定接口参数 response_model 和箭头返回类型
10291
10392
:param res:
10493
:param data:

‎backend/common/schema.py

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,12 @@
11
#!/usr/bin/env python3
22
# -*- coding: utf-8 -*-
3+
from datetime import datetime
4+
35
from pydantic import BaseModel, ConfigDict, EmailStr, validate_email
46
from pydantic_extra_types.phone_numbers import PhoneNumber
57

8+
from backend.core.conf import settings
9+
610
# 自定义验证错误信息不包含验证预期内容(也就是输入内容),受支持的预期内容字段参考以下链接
711
# https://github.com/pydantic/pydantic-core/blob/a5cb7382643415b716b1a7a5392914e50f726528/tests/test_errors.py#L266
812
# 替换预期内容字段方式,参考以下链接
@@ -149,4 +153,7 @@ def _validate(cls, __input_value: str) -> str:
149153

150154

151155
class SchemaBase(BaseModel):
152-
model_config = ConfigDict(use_enum_values=True)
156+
model_config = ConfigDict(
157+
use_enum_values=True,
158+
json_encoders={datetime: lambda x: x.strftime(settings.DATETIME_FORMAT)},
159+
)

‎backend/core/conf.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -58,7 +58,7 @@ def validator_api_url(cls, values):
5858

5959
# Captcha
6060
CAPTCHA_LOGIN_REDIS_PREFIX: str = 'fba:login:captcha'
61-
CAPTCHA_EXPIRATION_TIME: int = 60 * 5 # 过期时间,单位:秒
61+
CAPTCHA_LOGIN_EXPIRE_SECONDS: int = 60 * 5 # 过期时间,单位:秒
6262

6363
# Token
6464
TOKEN_ALGORITHM: str = 'HS256' # 算法

0 commit comments

Comments
 (0)
Please sign in to comment.