isanic-e 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (37) hide show
  1. isanic_e-0.1.0/PKG-INFO +83 -0
  2. isanic_e-0.1.0/README.md +63 -0
  3. isanic_e-0.1.0/isanic/__init__.py +19 -0
  4. isanic_e-0.1.0/isanic/cli.py +103 -0
  5. isanic_e-0.1.0/isanic/controller.py +13 -0
  6. isanic_e-0.1.0/isanic/exceptions.py +31 -0
  7. isanic_e-0.1.0/isanic/handlers.py +39 -0
  8. isanic_e-0.1.0/isanic/response.py +67 -0
  9. isanic_e-0.1.0/isanic/routing.py +141 -0
  10. isanic_e-0.1.0/isanic/templates/full/README.md +32 -0
  11. isanic_e-0.1.0/isanic/templates/full/_gitignore +9 -0
  12. isanic_e-0.1.0/isanic/templates/full/app/__init__.py +1 -0
  13. isanic_e-0.1.0/isanic/templates/full/app/api/__init__.py +1 -0
  14. isanic_e-0.1.0/isanic/templates/full/app/api/controllers/__init__.py +1 -0
  15. isanic_e-0.1.0/isanic/templates/full/app/api/controllers/hello.py +9 -0
  16. isanic_e-0.1.0/isanic/templates/full/app/api/controllers/login.py +28 -0
  17. isanic_e-0.1.0/isanic/templates/full/app/api/schemas/__init__.py +1 -0
  18. isanic_e-0.1.0/isanic/templates/full/app/api/schemas/login.py +15 -0
  19. isanic_e-0.1.0/isanic/templates/full/app/common/__init__.py +1 -0
  20. isanic_e-0.1.0/isanic/templates/full/app/core/__init__.py +1 -0
  21. isanic_e-0.1.0/isanic/templates/full/app/core/app.py +17 -0
  22. isanic_e-0.1.0/isanic/templates/full/app/core/config.py +36 -0
  23. isanic_e-0.1.0/isanic/templates/full/app/core/logging.py +11 -0
  24. isanic_e-0.1.0/isanic/templates/full/app/main.py +14 -0
  25. isanic_e-0.1.0/isanic/templates/full/pyproject.toml +22 -0
  26. isanic_e-0.1.0/isanic/templates/full/tests/test_framework.py +98 -0
  27. isanic_e-0.1.0/isanic/validation.py +109 -0
  28. isanic_e-0.1.0/isanic_e.egg-info/PKG-INFO +83 -0
  29. isanic_e-0.1.0/isanic_e.egg-info/SOURCES.txt +35 -0
  30. isanic_e-0.1.0/isanic_e.egg-info/dependency_links.txt +1 -0
  31. isanic_e-0.1.0/isanic_e.egg-info/entry_points.txt +2 -0
  32. isanic_e-0.1.0/isanic_e.egg-info/requires.txt +7 -0
  33. isanic_e-0.1.0/isanic_e.egg-info/top_level.txt +1 -0
  34. isanic_e-0.1.0/pyproject.toml +51 -0
  35. isanic_e-0.1.0/setup.cfg +4 -0
  36. isanic_e-0.1.0/tests/test_cli.py +74 -0
  37. isanic_e-0.1.0/tests/test_framework.py +102 -0
@@ -0,0 +1,83 @@
1
+ Metadata-Version: 2.4
2
+ Name: isanic-e
3
+ Version: 0.1.0
4
+ Summary: 基于 Sanic 的类型化表单验证后端脚手架
5
+ Author: iSanic-e contributors
6
+ License-Expression: MIT
7
+ Keywords: sanic,scaffold,backend,pydantic,api
8
+ Classifier: Development Status :: 3 - Alpha
9
+ Classifier: Intended Audience :: Developers
10
+ Classifier: Programming Language :: Python :: 3.14
11
+ Classifier: Typing :: Typed
12
+ Requires-Python: <3.15,>=3.14
13
+ Description-Content-Type: text/markdown
14
+ Requires-Dist: sanic<26,>=25.12
15
+ Requires-Dist: pydantic<3,>=2.12
16
+ Provides-Extra: dev
17
+ Requires-Dist: build<2,>=1.3; extra == "dev"
18
+ Requires-Dist: pytest<10,>=8; extra == "dev"
19
+ Requires-Dist: sanic-testing<26,>=24; extra == "dev"
20
+
21
+ # iSanic-e
22
+
23
+ 基于 Sanic 的类型化表单验证后端脚手架。
24
+
25
+ 安装后可以通过 `isanic init` 快速生成一个完整可运行的 Sanic 项目,内置:
26
+
27
+ - 控制器自动路由注册
28
+ - Pydantic v2 表单验证
29
+ - 统一成功和错误响应
30
+ - 全局异常处理
31
+ - 示例控制器、表单模型和测试
32
+
33
+ ## 安装
34
+
35
+ ```bash
36
+ python -m pip install isanic-e
37
+ ```
38
+
39
+ ## 初始化项目
40
+
41
+ ```bash
42
+ isanic init demo
43
+ cd demo
44
+ python -m pip install -e ".[dev]"
45
+ python -m pytest
46
+ python -m app.main
47
+ ```
48
+
49
+ ## 控制器示例
50
+
51
+ ```python
52
+ from app.api.schemas.login import LoginForm
53
+ from isanic import ApiController
54
+
55
+
56
+ class Login(ApiController):
57
+ async def post_submit(self, form: LoginForm) -> dict[str, str]:
58
+ return {"token": f"token-for-{form.username}"}
59
+ ```
60
+
61
+ 上面的代码会自动注册为:
62
+
63
+ ```text
64
+ POST /login/submit
65
+ ```
66
+
67
+ ## 业务异常
68
+
69
+ ```python
70
+ from isanic import AppError
71
+
72
+ raise AppError("用户已被禁用", code="USER_DISABLED", status_code=403)
73
+ ```
74
+
75
+ 返回:
76
+
77
+ ```json
78
+ {
79
+ "code": "USER_DISABLED",
80
+ "message": "用户已被禁用",
81
+ "data": null
82
+ }
83
+ ```
@@ -0,0 +1,63 @@
1
+ # iSanic-e
2
+
3
+ 基于 Sanic 的类型化表单验证后端脚手架。
4
+
5
+ 安装后可以通过 `isanic init` 快速生成一个完整可运行的 Sanic 项目,内置:
6
+
7
+ - 控制器自动路由注册
8
+ - Pydantic v2 表单验证
9
+ - 统一成功和错误响应
10
+ - 全局异常处理
11
+ - 示例控制器、表单模型和测试
12
+
13
+ ## 安装
14
+
15
+ ```bash
16
+ python -m pip install isanic-e
17
+ ```
18
+
19
+ ## 初始化项目
20
+
21
+ ```bash
22
+ isanic init demo
23
+ cd demo
24
+ python -m pip install -e ".[dev]"
25
+ python -m pytest
26
+ python -m app.main
27
+ ```
28
+
29
+ ## 控制器示例
30
+
31
+ ```python
32
+ from app.api.schemas.login import LoginForm
33
+ from isanic import ApiController
34
+
35
+
36
+ class Login(ApiController):
37
+ async def post_submit(self, form: LoginForm) -> dict[str, str]:
38
+ return {"token": f"token-for-{form.username}"}
39
+ ```
40
+
41
+ 上面的代码会自动注册为:
42
+
43
+ ```text
44
+ POST /login/submit
45
+ ```
46
+
47
+ ## 业务异常
48
+
49
+ ```python
50
+ from isanic import AppError
51
+
52
+ raise AppError("用户已被禁用", code="USER_DISABLED", status_code=403)
53
+ ```
54
+
55
+ 返回:
56
+
57
+ ```json
58
+ {
59
+ "code": "USER_DISABLED",
60
+ "message": "用户已被禁用",
61
+ "data": null
62
+ }
63
+ ```
@@ -0,0 +1,19 @@
1
+ """iSanic-e 框架公共接口。"""
2
+
3
+ from isanic.controller import ApiController
4
+ from isanic.exceptions import AppError, ValidationAppError
5
+ from isanic.handlers import setup_exception_handlers
6
+ from isanic.response import ApiResponse, fail, success, wrap_response
7
+ from isanic.routing import register_controllers
8
+
9
+ __all__ = [
10
+ "ApiController",
11
+ "ApiResponse",
12
+ "AppError",
13
+ "ValidationAppError",
14
+ "fail",
15
+ "register_controllers",
16
+ "setup_exception_handlers",
17
+ "success",
18
+ "wrap_response",
19
+ ]
@@ -0,0 +1,103 @@
1
+ import argparse
2
+ import re
3
+ import shutil
4
+ import sys
5
+ from pathlib import Path
6
+
7
+ TEMPLATE_ROOT = Path(__file__).parent / "templates" / "full"
8
+ PROJECT_NAME_PATTERN = re.compile(r"^[A-Za-z][A-Za-z0-9_-]*$")
9
+
10
+
11
+ def main(argv: list[str] | None = None) -> int:
12
+ """命令行入口。"""
13
+ parser = _build_parser()
14
+ args = parser.parse_args(argv)
15
+
16
+ if args.command == "init":
17
+ return init_project(args.project_name)
18
+
19
+ parser.print_help()
20
+ return 0
21
+
22
+
23
+ def init_project(project_name: str, base_dir: Path | None = None) -> int:
24
+ """初始化完整示例项目。"""
25
+ if not PROJECT_NAME_PATTERN.fullmatch(project_name):
26
+ print(
27
+ "项目名只能包含字母、数字、短横线和下划线,并且必须以字母开头。",
28
+ file=sys.stderr,
29
+ )
30
+ return 2
31
+
32
+ root_dir = base_dir or Path.cwd()
33
+ target_dir = root_dir / project_name
34
+ if target_dir.exists() and any(target_dir.iterdir()):
35
+ print(f"目标目录已存在且非空:{target_dir}", file=sys.stderr)
36
+ return 1
37
+
38
+ target_dir.mkdir(parents=True, exist_ok=True)
39
+ _copy_template(TEMPLATE_ROOT, target_dir, project_name)
40
+ _print_success(project_name)
41
+ return 0
42
+
43
+
44
+ def _build_parser() -> argparse.ArgumentParser:
45
+ """构建命令行参数解析器。"""
46
+ parser = argparse.ArgumentParser(
47
+ prog="isanic",
48
+ description="iSanic-e 项目脚手架",
49
+ )
50
+ subparsers = parser.add_subparsers(dest="command")
51
+
52
+ init_parser = subparsers.add_parser("init", help="初始化一个 Sanic 项目")
53
+ init_parser.add_argument("project_name", help="要创建的项目目录名")
54
+ return parser
55
+
56
+
57
+ def _copy_template(source_dir: Path, target_dir: Path, project_name: str) -> None:
58
+ """递归复制模板文件到目标目录。"""
59
+ for source_path in source_dir.rglob("*"):
60
+ if _should_skip_template_path(source_path):
61
+ continue
62
+
63
+ relative_path = source_path.relative_to(source_dir)
64
+ target_path = target_dir / _render_text(str(relative_path), project_name)
65
+
66
+ if source_path.is_dir():
67
+ target_path.mkdir(parents=True, exist_ok=True)
68
+ continue
69
+
70
+ target_path.parent.mkdir(parents=True, exist_ok=True)
71
+ text = source_path.read_text(encoding="utf-8")
72
+ target_path.write_text(_render_text(text, project_name), encoding="utf-8")
73
+
74
+ gitignore_template = target_dir / "_gitignore"
75
+ if gitignore_template.exists():
76
+ shutil.move(str(gitignore_template), str(target_dir / ".gitignore"))
77
+
78
+
79
+ def _should_skip_template_path(path: Path) -> bool:
80
+ """跳过安装后自动生成的缓存文件。"""
81
+ if "__pycache__" in path.parts:
82
+ return True
83
+ return path.suffix in {".pyc", ".pyo"}
84
+
85
+
86
+ def _render_text(value: str, project_name: str) -> str:
87
+ """替换模板中的占位符。"""
88
+ return value.replace("{{ project_name }}", project_name)
89
+
90
+
91
+ def _print_success(project_name: str) -> None:
92
+ """输出初始化成功提示。"""
93
+ print(f"项目已创建:{project_name}")
94
+ print("")
95
+ print("下一步:")
96
+ print(f" cd {project_name}")
97
+ print(' python -m pip install -e ".[dev]"')
98
+ print(" python -m pytest")
99
+ print(" python -m app.main")
100
+
101
+
102
+ if __name__ == "__main__":
103
+ raise SystemExit(main())
@@ -0,0 +1,13 @@
1
+ from typing import ClassVar
2
+
3
+ from sanic import Request
4
+
5
+
6
+ class ApiController:
7
+ """控制器基类。"""
8
+
9
+ prefix: ClassVar[str | None] = None
10
+ request: Request
11
+
12
+ def __init__(self, request: Request) -> None:
13
+ self.request = request
@@ -0,0 +1,31 @@
1
+ from typing import Any
2
+
3
+
4
+ class AppError(Exception):
5
+ """业务异常基类。"""
6
+
7
+ def __init__(
8
+ self,
9
+ message: str,
10
+ *,
11
+ code: int | str = "APP_ERROR",
12
+ status_code: int = 400,
13
+ data: Any = None,
14
+ ) -> None:
15
+ self.code = code
16
+ self.message = message
17
+ self.status_code = status_code
18
+ self.data = data
19
+ super().__init__(message)
20
+
21
+
22
+ class ValidationAppError(AppError):
23
+ """请求参数校验失败。"""
24
+
25
+ def __init__(self, errors: list[dict[str, Any]]) -> None:
26
+ super().__init__(
27
+ "请求参数校验失败",
28
+ code="VALIDATION_ERROR",
29
+ status_code=422,
30
+ data=errors,
31
+ )
@@ -0,0 +1,39 @@
1
+ from sanic import Sanic
2
+ from sanic.exceptions import MethodNotAllowed, NotFound, SanicException
3
+
4
+ from isanic.exceptions import AppError
5
+ from isanic.response import fail
6
+
7
+
8
+ def setup_exception_handlers(app: Sanic) -> None:
9
+ """注册全局异常处理器。"""
10
+
11
+ @app.exception(AppError)
12
+ async def handle_app_error(request, exception: AppError):
13
+ return fail(
14
+ exception.code,
15
+ exception.message,
16
+ exception.data,
17
+ status=exception.status_code,
18
+ )
19
+
20
+ @app.exception(NotFound)
21
+ async def handle_not_found(request, exception: NotFound):
22
+ return fail("NOT_FOUND", "接口不存在", status=404)
23
+
24
+ @app.exception(MethodNotAllowed)
25
+ async def handle_method_not_allowed(request, exception: MethodNotAllowed):
26
+ return fail("METHOD_NOT_ALLOWED", "请求方法不允许", status=405)
27
+
28
+ @app.exception(Exception)
29
+ async def handle_unknown_error(request, exception: Exception):
30
+ if isinstance(exception, SanicException):
31
+ status_code = exception.status_code or 500
32
+ return fail(
33
+ "HTTP_ERROR",
34
+ str(exception) or "请求处理失败",
35
+ status=status_code,
36
+ )
37
+
38
+ request.app.ctx.logger.exception("未捕获异常", exc_info=exception)
39
+ return fail("INTERNAL_SERVER_ERROR", "服务器内部错误", status=500)
@@ -0,0 +1,67 @@
1
+ from dataclasses import asdict, is_dataclass
2
+ from typing import Any, Generic, TypeAlias, TypeVar
3
+
4
+ from pydantic import BaseModel
5
+ from sanic.response import HTTPResponse, json
6
+
7
+ T = TypeVar("T")
8
+
9
+ JsonValue: TypeAlias = (
10
+ dict[str, Any] | list[Any] | str | int | float | bool | None
11
+ )
12
+
13
+
14
+ class ApiResponse(Generic[T]):
15
+ """统一响应结构的类型标记。"""
16
+
17
+ code: int | str
18
+ message: str
19
+ data: T | None
20
+
21
+
22
+ def success(data: Any = None, message: str = "success", code: int = 0) -> HTTPResponse:
23
+ """返回成功响应。"""
24
+ return json(
25
+ {
26
+ "code": code,
27
+ "message": message,
28
+ "data": _to_jsonable(data),
29
+ }
30
+ )
31
+
32
+
33
+ def fail(
34
+ code: int | str,
35
+ message: str,
36
+ data: Any = None,
37
+ status: int = 400,
38
+ ) -> HTTPResponse:
39
+ """返回失败响应。"""
40
+ return json(
41
+ {
42
+ "code": code,
43
+ "message": message,
44
+ "data": _to_jsonable(data),
45
+ },
46
+ status=status,
47
+ )
48
+
49
+
50
+ def wrap_response(value: Any) -> HTTPResponse:
51
+ """把业务返回值包装为统一响应。"""
52
+ if isinstance(value, HTTPResponse):
53
+ return value
54
+ return success(value)
55
+
56
+
57
+ def _to_jsonable(value: Any) -> Any:
58
+ """转换常见 Python 对象为可 JSON 序列化的数据。"""
59
+ if isinstance(value, BaseModel):
60
+ return value.model_dump(mode="json")
61
+ if is_dataclass(value) and not isinstance(value, type):
62
+ return asdict(value)
63
+ if isinstance(value, dict):
64
+ return {key: _to_jsonable(item) for key, item in value.items()}
65
+ if isinstance(value, list | tuple | set):
66
+ return [_to_jsonable(item) for item in value]
67
+ return value
@@ -0,0 +1,141 @@
1
+ import inspect
2
+ import pkgutil
3
+ import re
4
+ from collections.abc import Awaitable, Callable
5
+ from importlib import import_module
6
+ from types import ModuleType
7
+ from typing import Any, TypeAlias
8
+
9
+ from sanic import Request, Sanic
10
+ from sanic.response import HTTPResponse
11
+
12
+ from isanic.controller import ApiController
13
+ from isanic.response import wrap_response
14
+ from isanic.validation import build_form_model, resolve_form_binding
15
+
16
+ ControllerClass: TypeAlias = type[ApiController]
17
+ HandlerResult: TypeAlias = HTTPResponse | dict[str, Any] | list[Any] | str | int | float | bool | None
18
+ ControllerMethod: TypeAlias = Callable[..., Awaitable[HandlerResult]]
19
+
20
+ HTTP_METHODS = {"get", "post", "put", "patch", "delete"}
21
+ CONTROLLER_SUFFIX = "Controller"
22
+
23
+
24
+ def register_controllers(app: Sanic, package: str) -> None:
25
+ """扫描指定包并注册所有控制器路由。"""
26
+ root_module = import_module(package)
27
+ for module in _walk_modules(root_module):
28
+ for controller_class in _iter_controller_classes(module):
29
+ _register_controller(app, controller_class)
30
+
31
+
32
+ def _walk_modules(root_module: ModuleType) -> list[ModuleType]:
33
+ """递归加载包下所有模块。"""
34
+ modules = [root_module]
35
+ if not hasattr(root_module, "__path__"):
36
+ return modules
37
+
38
+ for module_info in pkgutil.walk_packages(
39
+ root_module.__path__,
40
+ prefix=f"{root_module.__name__}.",
41
+ ):
42
+ modules.append(import_module(module_info.name))
43
+ return modules
44
+
45
+
46
+ def _iter_controller_classes(module: ModuleType) -> list[ControllerClass]:
47
+ """查找模块中定义的控制器类。"""
48
+ controllers: list[ControllerClass] = []
49
+ for _, item in inspect.getmembers(module, inspect.isclass):
50
+ if item is ApiController:
51
+ continue
52
+ if item.__module__ != module.__name__:
53
+ continue
54
+ if issubclass(item, ApiController):
55
+ controllers.append(item)
56
+ return controllers
57
+
58
+
59
+ def _register_controller(app: Sanic, controller_class: ControllerClass) -> None:
60
+ """注册单个控制器上的所有 HTTP 方法。"""
61
+ controller_prefix = _controller_prefix(controller_class)
62
+ for route in _iter_routes(controller_class):
63
+ uri = f"/{controller_prefix}/{route.path}".rstrip("/")
64
+ app.add_route(
65
+ _build_route_handler(controller_class, route.method_name),
66
+ uri,
67
+ methods=[route.http_method],
68
+ name=f"{controller_class.__name__}.{route.method_name}",
69
+ )
70
+
71
+
72
+ class RouteInfo:
73
+ """控制器方法对应的路由信息。"""
74
+
75
+ def __init__(self, http_method: str, method_name: str, path: str) -> None:
76
+ self.http_method = http_method
77
+ self.method_name = method_name
78
+ self.path = path
79
+
80
+
81
+ def _iter_routes(controller_class: ControllerClass) -> list[RouteInfo]:
82
+ """解析控制器类中的路由方法。"""
83
+ routes: list[RouteInfo] = []
84
+ for name, member in inspect.getmembers(controller_class, inspect.isfunction):
85
+ http_method, path = _parse_route_method(name)
86
+ if http_method is None or path is None:
87
+ continue
88
+ routes.append(RouteInfo(http_method, name, path))
89
+ return routes
90
+
91
+
92
+ def _parse_route_method(name: str) -> tuple[str | None, str | None]:
93
+ """把方法名解析为 HTTP 方法和路径。"""
94
+ parts = name.split("_", 1)
95
+ method = parts[0].lower()
96
+ if method not in HTTP_METHODS:
97
+ return None, None
98
+
99
+ action = parts[1] if len(parts) > 1 else "index"
100
+ return method.upper(), _to_kebab_case(action)
101
+
102
+
103
+ def _build_route_handler(
104
+ controller_class: ControllerClass,
105
+ method_name: str,
106
+ ) -> Callable[[Request], Awaitable[HTTPResponse]]:
107
+ """创建 Sanic 可识别的路由处理函数。"""
108
+ controller_method = getattr(controller_class, method_name)
109
+ form_binding = resolve_form_binding(controller_method)
110
+
111
+ async def route_handler(request: Request) -> HTTPResponse:
112
+ controller = controller_class(request)
113
+ method = getattr(controller, method_name)
114
+ if form_binding is None:
115
+ result = await method()
116
+ else:
117
+ form = build_form_model(request, form_binding)
118
+ result = await method(form)
119
+ return wrap_response(result)
120
+
121
+ route_handler.__name__ = f"{controller_class.__name__}_{method_name}"
122
+ return route_handler
123
+
124
+
125
+ def _controller_prefix(controller_class: ControllerClass) -> str:
126
+ """生成控制器路径前缀。"""
127
+ if controller_class.prefix:
128
+ return controller_class.prefix.strip("/")
129
+
130
+ name = controller_class.__name__
131
+ if name.endswith(CONTROLLER_SUFFIX):
132
+ name = name[: -len(CONTROLLER_SUFFIX)]
133
+ return _to_kebab_case(name)
134
+
135
+
136
+ def _to_kebab_case(value: str) -> str:
137
+ """把类名或方法名片段转换为短横线命名。"""
138
+ value = value.replace("_", "-")
139
+ value = re.sub(r"(?<=[a-z0-9])(?=[A-Z])", "-", value)
140
+ value = re.sub(r"(?<=[A-Z])(?=[A-Z][a-z])", "-", value)
141
+ return value.lower()
@@ -0,0 +1,32 @@
1
+ # {{ project_name }}
2
+
3
+ 这是通过 `isanic init` 生成的 Sanic 后端项目。
4
+
5
+ ## 快速开始
6
+
7
+ 安装依赖:
8
+
9
+ ```bash
10
+ python -m pip install -e ".[dev]"
11
+ ```
12
+
13
+ 运行测试:
14
+
15
+ ```bash
16
+ python -m pytest
17
+ ```
18
+
19
+ 启动服务:
20
+
21
+ ```bash
22
+ python -m app.main
23
+ ```
24
+
25
+ 示例接口:
26
+
27
+ ```text
28
+ GET /hello/test
29
+ POST /login/submit
30
+ GET /users/list?page=1&size=10
31
+ GET /users/raw
32
+ ```
@@ -0,0 +1,9 @@
1
+ __pycache__/
2
+ *.py[cod]
3
+ .pytest_cache/
4
+ .mypy_cache/
5
+ .ruff_cache/
6
+ .venv/
7
+ dist/
8
+ build/
9
+ *.egg-info/
@@ -0,0 +1 @@
1
+ """应用包。"""
@@ -0,0 +1 @@
1
+ """业务接口包。"""
@@ -0,0 +1 @@
1
+ """控制器包。"""
@@ -0,0 +1,9 @@
1
+ from isanic import ApiController
2
+
3
+
4
+ class Hello(ApiController):
5
+ """示例控制器。"""
6
+
7
+ async def get_test(self) -> dict[str, str]:
8
+ """测试接口。"""
9
+ return {"message": "hello"}
@@ -0,0 +1,28 @@
1
+ from sanic.response import text
2
+
3
+ from app.api.schemas.login import LoginForm, UserQueryForm
4
+ from isanic import ApiController, AppError
5
+
6
+
7
+ class Login(ApiController):
8
+ """登录控制器。"""
9
+
10
+ async def post_submit(self, form: LoginForm) -> dict[str, str]:
11
+ """提交登录表单。"""
12
+ if form.username == "disabled":
13
+ raise AppError("用户已被禁用", code="USER_DISABLED", status_code=403)
14
+ return {"token": f"token-for-{form.username}"}
15
+
16
+
17
+ class UserProfile(ApiController):
18
+ """用户资料控制器。"""
19
+
20
+ prefix = "users"
21
+
22
+ async def get_list(self, form: UserQueryForm) -> dict[str, int]:
23
+ """查询用户列表。"""
24
+ return {"page": form.page, "size": form.size}
25
+
26
+ async def get_raw(self):
27
+ """直接返回 Sanic 原生响应。"""
28
+ return text("raw response")
@@ -0,0 +1 @@
1
+ """请求和响应模型包。"""
@@ -0,0 +1,15 @@
1
+ from pydantic import BaseModel, Field
2
+
3
+
4
+ class LoginForm(BaseModel):
5
+ """登录表单。"""
6
+
7
+ username: str = Field(min_length=3, max_length=32)
8
+ password: str = Field(min_length=6, max_length=128)
9
+
10
+
11
+ class UserQueryForm(BaseModel):
12
+ """用户列表查询表单。"""
13
+
14
+ page: int = Field(default=1, ge=1)
15
+ size: int = Field(default=10, ge=1, le=100)
@@ -0,0 +1 @@
1
+ """通用工具包。"""
@@ -0,0 +1 @@
1
+ """核心配置和应用工厂包。"""