├── MANIFEST.in ├── fastapi_builder ├── __init__.py ├── templates │ ├── __init__.py │ ├── app │ │ ├── __init__.py │ │ ├── hooks │ │ │ ├── __init__.py │ │ │ └── post_gen_app.py │ │ ├── app_{{ cookiecutter.folder_name }} │ │ │ ├── __init__.py │ │ │ ├── model.py │ │ │ ├── field.py │ │ │ ├── schema.py │ │ │ ├── doc.py │ │ │ └── api.py │ │ └── cookiecutter.json │ └── project │ │ ├── __init__.py │ │ ├── hooks │ │ ├── __init__.py │ │ └── post_gen_project.py │ │ ├── {{ cookiecutter.folder_name }} │ │ ├── __init__.py │ │ ├── apps │ │ │ ├── .gitkeep │ │ │ └── app_user │ │ │ │ ├── field.py │ │ │ │ ├── model.py │ │ │ │ ├── schema.py │ │ │ │ ├── api.py │ │ │ │ └── doc.py │ │ ├── alembic │ │ │ ├── versions │ │ │ │ ├── .gitkeep │ │ │ │ └── b09d03e54aaf_autocreate_migration.py │ │ │ ├── README │ │ │ ├── script.py.mako │ │ │ └── env.py │ │ ├── .flake8 │ │ ├── db │ │ │ ├── errors.py │ │ │ ├── database.py │ │ │ └── base.py │ │ ├── core │ │ │ ├── e │ │ │ │ ├── __init__.py │ │ │ │ ├── codes.py │ │ │ │ └── messages.py │ │ │ ├── .env │ │ │ ├── events.py │ │ │ ├── logger.py │ │ │ └── config.py │ │ ├── schemas │ │ │ ├── jwt.py │ │ │ ├── response.py │ │ │ └── base.py │ │ ├── requirements.txt │ │ ├── setup.cfg │ │ ├── api │ │ │ ├── routes │ │ │ │ ├── api.py │ │ │ │ └── authentication.py │ │ │ └── errors │ │ │ │ ├── http_error.py │ │ │ │ └── validation_error.py │ │ ├── middleware │ │ │ └── logger.py │ │ ├── fastapi-builder.ini │ │ ├── Dockerfile │ │ ├── .pre-commit-config.yaml │ │ ├── utils │ │ │ ├── docs.py │ │ │ └── dbmanager.py │ │ ├── pyproject.toml │ │ ├── lib │ │ │ ├── security.py │ │ │ └── jwt.py │ │ ├── models │ │ │ ├── mixins.py │ │ │ └── base.py │ │ ├── docker-compose.yaml │ │ ├── main.py │ │ ├── alembic.ini │ │ ├── README.md │ │ └── README_EN.md │ │ └── cookiecutter.json ├── __main__.py ├── config.py ├── helpers.py ├── constants.py ├── context.py ├── generator.py ├── main.py └── utils.py ├── .flake8 ├── requirements.txt ├── tests ├── utils.py ├── reinstall.py ├── test_startapp.py └── test_startproject.py ├── .gitignore ├── LICENSE ├── docs ├── develop.md └── tutorial.md ├── .github └── workflows │ └── python-publish.yml ├── setup.py └── README.md /MANIFEST.in: -------------------------------------------------------------------------------- 1 | global-include * -------------------------------------------------------------------------------- /fastapi_builder/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /fastapi_builder/templates/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /fastapi_builder/templates/app/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.flake8: -------------------------------------------------------------------------------- 1 | [flake8] 2 | max-line-length = 100 3 | -------------------------------------------------------------------------------- /fastapi_builder/templates/app/hooks/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /fastapi_builder/templates/project/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /fastapi_builder/templates/app/hooks/post_gen_app.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /fastapi_builder/templates/project/hooks/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /fastapi_builder/templates/app/app_{{ cookiecutter.folder_name }}/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /fastapi_builder/templates/project/{{ cookiecutter.folder_name }}/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /fastapi_builder/templates/project/{{ cookiecutter.folder_name }}/apps/.gitkeep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /fastapi_builder/templates/project/{{ cookiecutter.folder_name }}/alembic/versions/.gitkeep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /fastapi_builder/templates/project/{{ cookiecutter.folder_name }}/.flake8: -------------------------------------------------------------------------------- 1 | [flake8] 2 | max-line-length = 100 3 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | click==8.0.3 2 | cookiecutter==1.7.3 3 | email-validator==1.1.3 4 | pydantic==1.8.2 5 | pymysql==1.0.2 6 | questionary==1.10.0 7 | typer==0.4.0 8 | -------------------------------------------------------------------------------- /fastapi_builder/templates/project/{{ cookiecutter.folder_name }}/db/errors.py: -------------------------------------------------------------------------------- 1 | class EntityDoesNotExist(Exception): 2 | """Raised when entity was not found in database.""" 3 | -------------------------------------------------------------------------------- /fastapi_builder/__main__.py: -------------------------------------------------------------------------------- 1 | from fastapi_builder.main import app 2 | 3 | 4 | def main(): 5 | app(prog_name="fastapi") 6 | 7 | 8 | if __name__ == "__main__": 9 | main() 10 | -------------------------------------------------------------------------------- /fastapi_builder/templates/project/{{ cookiecutter.folder_name }}/core/e/__init__.py: -------------------------------------------------------------------------------- 1 | from .codes import ErrorCode 2 | from .messages import ErrorMessage 3 | 4 | __all__ = ["ErrorCode", "ErrorMessage"] 5 | -------------------------------------------------------------------------------- /fastapi_builder/templates/project/{{ cookiecutter.folder_name }}/alembic/README: -------------------------------------------------------------------------------- 1 | Generic single-database configuration. 2 | 3 | alembic revision --autogenerate -m "create migration" 4 | alembic upgrade head 5 | -------------------------------------------------------------------------------- /tests/utils.py: -------------------------------------------------------------------------------- 1 | import os 2 | import shutil 3 | 4 | 5 | # 删除 CliRunner isolated_filesystem 生成的随机目录 6 | def rm_tmp_dir(path: str): 7 | for fname in os.listdir(path): 8 | if len(fname) == 11 and fname.startswith("tmp"): 9 | shutil.rmtree(fname) 10 | -------------------------------------------------------------------------------- /fastapi_builder/config.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | TEMPLATES_DIR = os.path.join(os.path.dirname(os.path.realpath(__file__)), "templates") 4 | 5 | try: 6 | import fastapi 7 | 8 | FASTAPI_VERSION = fastapi.__version__ 9 | except ModuleNotFoundError: 10 | FASTAPI_VERSION = "0.111.1" 11 | -------------------------------------------------------------------------------- /fastapi_builder/templates/project/{{ cookiecutter.folder_name }}/schemas/jwt.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime 2 | 3 | from pydantic import BaseModel 4 | 5 | 6 | class JWTMeta(BaseModel): 7 | exp: datetime 8 | sub: str 9 | 10 | 11 | class JWTUser(BaseModel): 12 | username: str 13 | -------------------------------------------------------------------------------- /fastapi_builder/templates/project/{{ cookiecutter.folder_name }}/core/.env: -------------------------------------------------------------------------------- 1 | REDIS_URL=redis://redis:6379/1 2 | ASYNC_DB_CONNECTION=mysql+aiomysql://root:123456@127.0.0.1:3306/{{ cookiecutter.database_name }} 3 | DB_CONNECTION=mysql+pymysql://root:123456@127.0.0.1:3306/{{ cookiecutter.database_name }} 4 | SECRET_KEY=OauIrgmfnwCdxMBWpzPF7vfNzga1JVoiJi0hqz3fzkY -------------------------------------------------------------------------------- /fastapi_builder/templates/app/cookiecutter.json: -------------------------------------------------------------------------------- 1 | { 2 | "folder_name": "{{ cookiecutter.folder_name }}", 3 | "name": "{{ cookiecutter.name }}", 4 | "snake_name": "{{ cookiecutter.snake_name }}", 5 | "camel_name": "{{ cookiecutter.camel_name }}", 6 | "pascal_name": "{{ cookiecutter.pascal_name }}", 7 | "language": "{{ cookiecutter.language }}" 8 | } 9 | -------------------------------------------------------------------------------- /fastapi_builder/templates/project/{{ cookiecutter.folder_name }}/requirements.txt: -------------------------------------------------------------------------------- 1 | aiomysql==0.2.0 2 | alembic==1.13.2 3 | bcrypt==4.2.0 4 | databases==0.9.0 5 | email-validator==2.2.0 6 | fastapi==^{{ cookiecutter.fastapi }} 7 | loguru==0.7.2 8 | passlib==1.7.4 9 | pydantic==2.8.2 10 | pymysql==1.1.1 11 | python-jose==3.3.0 12 | python-multipart==0.0.9 13 | redis==5.0.8 14 | sqlalchemy==2.0.32 15 | uvicorn==0.30.6 16 | -------------------------------------------------------------------------------- /fastapi_builder/templates/project/{{ cookiecutter.folder_name }}/setup.cfg: -------------------------------------------------------------------------------- 1 | [isort] 2 | profile = black 3 | known_first_party = app 4 | 5 | [flake8] 6 | max-complexity = 7 7 | statistics = True 8 | max-line-length = 88 9 | ignore = W503,E203 10 | per-file-ignores = 11 | __init__.py: F401 12 | 13 | [mypy] 14 | plugins = pydantic.mypy 15 | ignore_missing_imports = True 16 | follow_imports = skip 17 | strict_optional = True 18 | -------------------------------------------------------------------------------- /fastapi_builder/templates/app/app_{{ cookiecutter.folder_name }}/model.py: -------------------------------------------------------------------------------- 1 | from sqlalchemy import Column, String 2 | 3 | from models.base import Base 4 | from models.mixins import DateTimeModelMixin, SoftDeleteModelMixin 5 | 6 | 7 | class {{ cookiecutter.pascal_name }}(Base["{{ cookiecutter.pascal_name }}"], DateTimeModelMixin, SoftDeleteModelMixin): 8 | __tablename__ = "{{ cookiecutter.snake_name }}" 9 | 10 | name = Column(String(255)) 11 | -------------------------------------------------------------------------------- /fastapi_builder/templates/app/app_{{ cookiecutter.folder_name }}/field.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | from dataclasses import dataclass 3 | 4 | from pydantic import Field 5 | 6 | 7 | @dataclass 8 | class {{ cookiecutter.pascal_name }}Fields: 9 | id: int = Field(..., description="id", example=1) 10 | name: str = Field(..., min_length=1, max_length=255, description="名字", example="fmw666") 11 | created_at: datetime.datetime = Field(..., description="创建时间", example="2023-01-01 00:00:00") 12 | updated_at: datetime.datetime = Field(..., description="更新时间", example="2023-01-01 00:00:00") 13 | -------------------------------------------------------------------------------- /fastapi_builder/templates/project/{{ cookiecutter.folder_name }}/api/routes/api.py: -------------------------------------------------------------------------------- 1 | from fastapi import APIRouter 2 | 3 | router = APIRouter() 4 | 5 | 6 | # 路由配置列表 7 | routes_config = [ 8 | {"module": "api.routes.authentication", "tag": "用户认证", "prefix": "/auth"}, 9 | {"module": "apps.app_user.api", "tag": "用户类", "prefix": "/users"}, 10 | ] 11 | 12 | # 通过循环动态注册路由 13 | for route in routes_config: 14 | module_path = route["module"] 15 | api_router = __import__(f"{module_path}", fromlist=["api"]).router 16 | router.include_router(api_router, tags=[route["tag"]], prefix=route["prefix"]) 17 | -------------------------------------------------------------------------------- /fastapi_builder/templates/project/{{ cookiecutter.folder_name }}/middleware/logger.py: -------------------------------------------------------------------------------- 1 | from starlette.middleware.base import BaseHTTPMiddleware 2 | from core.logger import app_logger as logger 3 | 4 | 5 | class RequestLoggerMiddleware(BaseHTTPMiddleware): 6 | """自定义访问日志中间件,基于BaseHTTPMiddleware的中间件实例""" 7 | 8 | async def dispatch(self, request, call_next): 9 | """必须重载 dispatch 方法""" 10 | logger.info( 11 | f"{request.method} url:{request.url}\nheaders: {request.headers.get('user-agent')}" 12 | f"\nIP:{request.client.host}" 13 | ) 14 | response = await call_next(request) 15 | return response 16 | -------------------------------------------------------------------------------- /fastapi_builder/templates/project/{{ cookiecutter.folder_name }}/alembic/script.py.mako: -------------------------------------------------------------------------------- 1 | """${message} 2 | 3 | Revision ID: ${up_revision} 4 | Revises: ${down_revision | comma,n} 5 | Create Date: ${create_date} 6 | 7 | """ 8 | from alembic import op 9 | import sqlalchemy as sa 10 | ${imports if imports else ""} 11 | 12 | # revision identifiers, used by Alembic. 13 | revision = ${repr(up_revision)} 14 | down_revision = ${repr(down_revision)} 15 | branch_labels = ${repr(branch_labels)} 16 | depends_on = ${repr(depends_on)} 17 | 18 | 19 | def upgrade(): 20 | ${upgrades if upgrades else "pass"} 21 | 22 | 23 | def downgrade(): 24 | ${downgrades if downgrades else "pass"} 25 | -------------------------------------------------------------------------------- /fastapi_builder/templates/project/{{ cookiecutter.folder_name }}/apps/app_user/field.py: -------------------------------------------------------------------------------- 1 | from dataclasses import dataclass 2 | 3 | from pydantic import EmailStr, Field 4 | 5 | 6 | @dataclass 7 | class UserFields: 8 | id: int = Field(..., description="用户ID", example=1) 9 | username: str = Field(..., min_length=6, max_length=12, description="用户名", example="fmw666") 10 | password: str = Field(..., min_length=6, max_length=12, description="密码", example="123456") 11 | email: EmailStr = Field(..., max_length=32, description="邮箱", example="fmw19990718@gmail.com") 12 | token: str = Field(..., description="token", example="eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJleHAiOjE3MjU1MzIzOTMsInN1YiI6IjYifQ.MXJutcQ2e7HHUC0FVkeqRtHyn6fT1fclPugo-qpy8e4") # noqa 13 | -------------------------------------------------------------------------------- /fastapi_builder/templates/project/{{ cookiecutter.folder_name }}/fastapi-builder.ini: -------------------------------------------------------------------------------- 1 | # [Attention] 2 | # This file is automatically generated when 'startproject' is called. 3 | # The purpose is to limit the line commands 'startapp' and 'run' only applies to the project folder. 4 | # Once the delete, 'startapp' and 'run' commands cannot be used for this project. 5 | 6 | [config] 7 | database = {{ cookiecutter.database }} 8 | database_name = {{ cookiecutter.database_name }} 9 | docker = {{ cookiecutter.docker }} 10 | license = {{ cookiecutter.license }} 11 | packaging = {{ cookiecutter.packaging }} 12 | pre_commit = {{ cookiecutter.pre_commit }} 13 | python = {{ cookiecutter.python }} 14 | 15 | [fastapi_builder] 16 | first_launch = true 17 | language = {{ cookiecutter.language }} 18 | -------------------------------------------------------------------------------- /fastapi_builder/templates/project/cookiecutter.json: -------------------------------------------------------------------------------- 1 | { 2 | "database": "{{ cookiecutter.database }}", 3 | "database_name": "{{ cookiecutter.database_name }}", 4 | "docker": "{{ cookiecutter.docker }}", 5 | "email": "{{ cookiecutter.email }}", 6 | "env": ".env", 7 | "fastapi": "{{ cookiecutter.fastapi }}", 8 | "folder_name": "{{ cookiecutter.folder_name }}", 9 | "gitignore": ".gitignore", 10 | "language": "{{ cookiecutter.language }}", 11 | "license": "{{ cookiecutter.license }}", 12 | "name": "{{ cookiecutter.name }}", 13 | "packaging": "{{ cookiecutter.packaging }}", 14 | "pre_commit": "{{ cookiecutter.pre_commit }}", 15 | "python": "{{ cookiecutter.python }}", 16 | "username": "{{ cookiecutter.username }}", 17 | "year": "{{ cookiecutter.year }}" 18 | } 19 | -------------------------------------------------------------------------------- /tests/reinstall.py: -------------------------------------------------------------------------------- 1 | # 仅在测试包时使用 2 | # 在 fastapi-builder 根目录(即该文件上一级目录)运行 3 | 4 | import os 5 | 6 | 7 | if __name__ == "__main__": 8 | # 卸载 9 | os.system("pip uninstall fastapi-builder") 10 | 11 | # 打包文件所在路径 12 | dist_path = "dist/" 13 | 14 | # 删除 dist 目录下打包文件 15 | try: 16 | for fname in os.listdir(dist_path): 17 | if fname.endswith(".whl"): 18 | os.remove(os.path.join(dist_path, fname)) 19 | break 20 | except FileNotFoundError: 21 | pass 22 | 23 | # 打包并安装 24 | os.system("python ./setup.py bdist_wheel") 25 | for fname in os.listdir(dist_path): 26 | if fname.endswith(".whl"): 27 | file_path = os.path.join(dist_path, fname) 28 | os.system(f"pip install {file_path}") 29 | break 30 | -------------------------------------------------------------------------------- /fastapi_builder/templates/project/{{ cookiecutter.folder_name }}/core/e/codes.py: -------------------------------------------------------------------------------- 1 | class ErrorCode: 2 | """错误码定义 3 | 命名规则: 4 | 1. 前三位为 http 状态码 5 | 2. 中间两位为模块 id 6 | 3. 最后两位为序号 7 | 8 | 注意:模块为 00 代表通用,序号为 00 代表模块中通用 9 | """ 10 | 11 | # 通用错误码 12 | INTERNAL_SERVER_ERROR = 5000000 13 | BAD_REQUEST = 4000000 14 | UNAUTHORIZED = 4010000 15 | FORBIDDEN = 4030000 16 | NOT_FOUND = 4040000 17 | 18 | # 用户类 01 19 | USER_ERROR = 4000100 20 | USER_EXIST = 4000101 21 | USER_NOT_FOUND = 4040102 22 | USER_NAME_EXIST = 4000103 23 | USER_PASSWORD_ERROR = 4000104 24 | USER_EMAIL_EXIST = 4000105 25 | USER_SMS_CODE_ERROR = 4000106 26 | USER_PHONE_INVALID = 4000107 27 | USER_SMS_CODE_REQUEST_TOO_OFTEN = 4290108 28 | USER_UNAUTHORIZED = 4010109 29 | 30 | # ... 31 | -------------------------------------------------------------------------------- /fastapi_builder/templates/project/{{ cookiecutter.folder_name }}/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM tiangolo/uvicorn-gunicorn-fastapi:python{{ cookiecutter.python }} 2 | 3 | ENV PYTHONPATH "${PYTHONPATH}:/" 4 | ENV PORT=8000 5 | {% if cookiecutter.packaging == "poetry" %} 6 | # Install Poetry 7 | RUN curl -sSL https://raw.githubusercontent.com/python-poetry/poetry/master/get-poetry.py | POETRY_HOME=/opt/poetry python && \ 8 | cd /usr/local/bin && \ 9 | ln -s /opt/poetry/bin/poetry && \ 10 | poetry config virtualenvs.create false 11 | 12 | # Copy using poetry.lock* in case it doesn't exist yet 13 | COPY ./pyproject.toml ./poetry.lock* /app/ 14 | 15 | RUN poetry install --no-root --no-dev 16 | {% else %} 17 | RUN pip install --upgrade pip 18 | 19 | COPY ./requirements.txt /app/ 20 | 21 | RUN pip install -r requirements.txt 22 | {% endif %} 23 | COPY ./app /app 24 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # editor 2 | .vscode/ 3 | .idea/ 4 | 5 | # vagrant 6 | .vagrant/ 7 | 8 | # os 9 | .DS_Store 10 | .thumbs.db 11 | 12 | # virtalenv 13 | venv/ 14 | env/ 15 | ENV/ 16 | 17 | # byte-compiled / optimized / dll file 18 | __pycache__/ 19 | *.py[cod] 20 | *$py.class 21 | 22 | # log file 23 | *.log 24 | 25 | # tmp file 26 | tmp* 27 | 28 | # Distribution / packaging 29 | .Python 30 | .pypirc 31 | build/ 32 | develop-eggs/ 33 | dist/ 34 | downloads/ 35 | eggs/ 36 | .eggs/ 37 | lib64/ 38 | parts/ 39 | sdist/ 40 | var/ 41 | wheels/ 42 | *.egg-info/ 43 | .installed.cfg 44 | *.egg 45 | fastapi-builder-*/ 46 | 47 | # PyInstaller 48 | # Usually these files are written by a python script from a template 49 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 50 | *.manifest 51 | *.spec 52 | 53 | # Installer logs 54 | pip-log.txt 55 | pip-delete-this-directory.txt 56 | -------------------------------------------------------------------------------- /fastapi_builder/templates/project/{{ cookiecutter.folder_name }}/api/errors/http_error.py: -------------------------------------------------------------------------------- 1 | from fastapi import HTTPException 2 | from starlette.requests import Request 3 | from starlette.responses import JSONResponse 4 | 5 | from schemas.response import StandardResponse 6 | 7 | 8 | async def http_error_handler(_: Request, exc: HTTPException) -> JSONResponse: 9 | """ 10 | fastapi http 异常处理句柄. 包含返回内容、状态码、响应头 11 | 12 | Args: 13 | _ (Request): starlette.requests.Request 14 | exc (HTTPException): 响应异常 15 | 16 | Returns: 17 | JSONResponse: 返回内容、状态码、响应头 18 | 19 | Raises: 20 | ValueError: 消息处理失败,抛出 ValueError 异常 21 | """ 22 | try: 23 | code, message = exc.detail.split("_", 1) 24 | code = int(code) 25 | except ValueError: 26 | code, message = exc.status_code, "Unknown error" 27 | 28 | return StandardResponse( 29 | code=code, 30 | message=message, 31 | data=None, 32 | ).to_json(status_code=exc.status_code, headers=getattr(exc, "headers")) 33 | -------------------------------------------------------------------------------- /fastapi_builder/templates/project/{{ cookiecutter.folder_name }}/schemas/response.py: -------------------------------------------------------------------------------- 1 | from typing import Generic, List, Mapping, TypeVar 2 | from pydantic import BaseModel 3 | 4 | from starlette.responses import JSONResponse 5 | from starlette.status import HTTP_200_OK 6 | 7 | T = TypeVar("T") 8 | 9 | 10 | class StandardResponse(BaseModel): 11 | data: T | None = None 12 | code: int = 0 13 | message: str = "" 14 | 15 | def to_json( 16 | self, 17 | status_code: int = HTTP_200_OK, 18 | headers: Mapping[str, str] | None = None, 19 | **kwargs 20 | ): 21 | # Convert to JSON string using the custom encoder 22 | 23 | return JSONResponse( 24 | status_code=status_code, 25 | content=self.model_dump(mode="json"), 26 | headers=headers, 27 | **kwargs 28 | ) 29 | 30 | 31 | class PaginationResponse(BaseModel, Generic[T]): 32 | list: List[T] 33 | count: int 34 | total: int 35 | page: int = 1 36 | size: int | None = None 37 | -------------------------------------------------------------------------------- /fastapi_builder/templates/project/{{ cookiecutter.folder_name }}/.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | repos: 2 | - repo: https://github.com/myint/autoflake 3 | rev: v1.4 4 | hooks: 5 | - id: autoflake 6 | exclude: .*/__init__.py 7 | args: 8 | - --in-place 9 | - --remove-all-unused-imports 10 | - --expand-star-imports 11 | - --remove-duplicate-keys 12 | - --remove-unused-variables 13 | - repo: local 14 | hooks: 15 | - id: flake8 16 | name: flake8 17 | entry: flake8 18 | language: system 19 | types: [python] 20 | - repo: https://github.com/pre-commit/mirrors-isort 21 | rev: v5.4.2 22 | hooks: 23 | - id: isort 24 | args: ["--profile", "black"] 25 | - repo: local 26 | hooks: 27 | - id: mypy 28 | name: mypy 29 | entry: mypy 30 | language: system 31 | types: [python] 32 | - repo: https://github.com/pre-commit/pre-commit-hooks 33 | rev: v3.3.0 34 | hooks: 35 | - id: trailing-whitespace 36 | - id: end-of-file-fixer 37 | -------------------------------------------------------------------------------- /fastapi_builder/templates/project/{{ cookiecutter.folder_name }}/utils/docs.py: -------------------------------------------------------------------------------- 1 | from typing import Any, Dict 2 | from fastapi import FastAPI 3 | from fastapi.openapi.utils import get_openapi 4 | 5 | 6 | def get_custom_openapi(app: FastAPI, **kw) -> Dict[str, Any]: 7 | """ 8 | 自定义 OpenAPI 9 | 10 | Args: 11 | app (FastAPI): FastAPI 对象 12 | **kw: 其他参数 13 | 14 | Returns: 15 | Dict[str, Any]: OpenAPI 对象 16 | """ 17 | 18 | def custom_openapi() -> Dict[str, Any]: 19 | if app.openapi_schema: 20 | return app.openapi_schema 21 | openapi_schema = get_openapi( 22 | title=kw.get("title", "OpenAPI schema Docs"), 23 | version=kw.get("version", "1.0.0"), 24 | description=kw.get("description", "nice to meet you."), 25 | routes=app.routes, 26 | ) 27 | openapi_schema["info"]["x-logo"] = { 28 | "url": "https://fastapi.tiangolo.com/img/logo-margin/logo-teal.png" 29 | } 30 | app.openapi_schema = openapi_schema 31 | return app.openapi_schema 32 | 33 | return custom_openapi 34 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 fmw666 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /fastapi_builder/helpers.py: -------------------------------------------------------------------------------- 1 | import re 2 | from typing import TypeVar 3 | 4 | import questionary 5 | 6 | EnumType = TypeVar("EnumType") 7 | 8 | 9 | def camel_to_snake(text: str) -> str: 10 | """驼峰命名转蛇形命名""" 11 | return re.sub(r"(? str: 15 | """蛇形命名转驼峰命名""" 16 | return text.split("_")[0] + "".join(x.title() for x in text.split("_")[1:]) 17 | 18 | 19 | def camel_to_pascal(text: str) -> str: 20 | """驼峰命名转帕斯卡命名""" 21 | return text[0].upper() + text[1:] 22 | 23 | 24 | def question(choices: EnumType) -> questionary.Question: 25 | prompt = camel_to_snake(choices.__name__).replace("_", " ") 26 | return questionary.select( 27 | f"Select the {prompt}: ", choices=[c.value for c in choices] 28 | ) 29 | 30 | 31 | def binary_question(option: str) -> questionary.Question: 32 | return questionary.confirm(f"Do you want {option}?", default=False) 33 | 34 | 35 | def text_question(default: str) -> questionary.Question: 36 | return questionary.text( 37 | "The name of the database you want to create? ", default=default 38 | ) 39 | -------------------------------------------------------------------------------- /fastapi_builder/constants.py: -------------------------------------------------------------------------------- 1 | from enum import Enum, EnumMeta 2 | 3 | 4 | class BaseMetadataEnum(EnumMeta): 5 | def __contains__(self, other): 6 | try: 7 | self(other) 8 | except ValueError: 9 | return False 10 | else: 11 | return True 12 | 13 | 14 | class BaseEnum(str, Enum, metaclass=BaseMetadataEnum): 15 | """Base enum class.""" 16 | 17 | 18 | class Language(BaseEnum): 19 | CN = "cn" 20 | EN = "en" 21 | 22 | 23 | class PackageManager(BaseEnum): 24 | PIP = "pip" 25 | POETRY = "poetry" 26 | 27 | 28 | class PythonVersion(BaseEnum): 29 | THREE_DOT_EIGHT = "3.8" 30 | THREE_DOT_NINE = "3.9" 31 | THREE_DOT_TEN = "3.10" 32 | THREE_DOT_ELEVEN = "3.11" 33 | THREE_DOT_TWELVE = "3.12" 34 | 35 | 36 | class License(BaseEnum): 37 | MIT = "MIT" 38 | BSD = "BSD" 39 | GNU = "GNU" 40 | APACHE = "Apache" 41 | 42 | 43 | class Database(BaseEnum): 44 | # POSTGRES = "Postgres" 45 | MYSQL = "MySQL" 46 | 47 | 48 | class DBCmd(BaseEnum): 49 | MAKEMIGRATIONS = "makemigrations" 50 | MIGRATE = "migrate" 51 | 52 | 53 | class VenvCmd(BaseEnum): 54 | CREATE = "create" 55 | ON = "on" 56 | OFF = "off" 57 | -------------------------------------------------------------------------------- /docs/develop.md: -------------------------------------------------------------------------------- 1 | ### 本地调试及测试 2 | 3 | 本地安装: 4 | 5 | ```sh 6 | # OS-windows cmd 7 | $ pip install virtualenv # 您的 python 版本需要 ≥ 3.6 8 | $ virtualenv venv # 创建虚拟环境 9 | $ .\venv\Scripts\activate # 启动虚拟环境 10 | 11 | (venv)$ pip install wheel 12 | 13 | (venv)$ python .\setup.py bdist_wheel # 打包 14 | (venv)$ pip install .\dist\fastapi_builder-x.x.x-py3-none-any.whl 15 | 16 | # 注意,当该虚拟环境下已经存在 fastapi-builder 模块,需先卸载重新安装 17 | (venv)$ python tests/reinstall.py # 重装 fastapi-builder 快捷方式 18 | ``` 19 | 20 | 本地测试: 21 | 22 | ```sh 23 | $ python tests/test_startapp.py 24 | $ python tests/test_startproject.py 25 | ``` 26 | 27 | ### 命令详解 28 | 29 | **startproject** 30 | 31 | + 创建项目、定义数据库名 32 | + 将配置项信息写入到 fastapi-builder.ini 文件中,方便后续读取 33 | 34 | **startapp** 35 | 36 | + 创建 app 37 | 38 | **venv** 39 | 40 | + create:创建虚拟环境 41 | + on:开启虚拟环境 42 | + off:关闭虚拟环境 43 | 44 | **run** 45 | 46 | + 每次运行读取 fastapi-builder.ini 检查是否是第一次运行 47 | + 若第一次运行,会自动 运行 --config 进行配置 48 | + --check:检查 module、数据库 49 | + --config:配置 50 | + 0)读取 fastapi-builder.ini,获取虚拟环境、打包方式、数据库等信息 51 | + 1)检查是否在虚拟环境下,没有的话会检查是否存在虚拟环境,若不存在,询问用户是否创建 52 | + 2)进入虚拟环境 53 | + 3)安装 requirements.txt 54 | + 4)检查数据库连接,若失败,让用户填写数据库地址、用户名、端口。重复检查直到连接 55 | + 5)创建数据库并运行迁移文件,创建相应的表 56 | -------------------------------------------------------------------------------- /fastapi_builder/templates/project/{{ cookiecutter.folder_name }}/pyproject.toml: -------------------------------------------------------------------------------- 1 | [tool.black] 2 | line-length = 100 3 | skip-string-normalization = true 4 | 5 | [tool.poetry] 6 | name = "{{ cookiecutter.name }}" 7 | version = "0.1.0" 8 | description = "" 9 | readme = "README.md" 10 | {% if cookiecutter.username != None %}authors = ["{{ cookiecutter.username }} <{{ cookiecutter.email }}>"]{% endif %} 11 | 12 | [tool.poetry.dependencies] 13 | python = "^{{ cookiecutter.python }}" 14 | fastapi = "{{ cookiecutter.fastapi }}" 15 | aiomysql = "^0.2.0" 16 | alembic = "^1.13.2" 17 | bcrypt = "^4.2.0" 18 | databases = "^0.9.0" 19 | email-validator = "^2.2.0" 20 | loguru = "^0.7.2" 21 | passlib = "^1.7.4" 22 | pymysql = "^1.1.1" 23 | python-jose = "^3.3.0" 24 | python-multipart = "^0.0.9" 25 | redis = "^5.0.8" 26 | sqlalchemy = "^2.0.32" 27 | uvicorn = "^0.30.6" 28 | 29 | [tool.poetry.dev-dependencies] 30 | pytest = "^5.2" 31 | pytest-cov = "^2.10.1" 32 | {%- if cookiecutter.pre_commit == "True" %} 33 | autoflake = "^1.4" 34 | flake8 = "^3.8.4" 35 | mypy = "^0.790" 36 | isort = "^5.0" 37 | pre-commit = "^2.8.2" 38 | black = "^20.8b1" 39 | {%- endif %} 40 | 41 | [build-system] 42 | requires = ["poetry-core>=1.0.0"] 43 | build-backend = "poetry.core.masonry.api" 44 | -------------------------------------------------------------------------------- /tests/test_startapp.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | 3 | from typer.testing import CliRunner 4 | 5 | from fastapi_builder.main import app 6 | from utils import rm_tmp_dir 7 | 8 | 9 | runner = CliRunner() 10 | 11 | CREATED_SUCCESSFULLY = "\nFastAPI app created successfully! 🎉\n" 12 | ALREADY_EXISTS = "\nFolder 'demo' already exists. 😞\n" 13 | 14 | 15 | def test_startapp_default(tmp_path: Path): 16 | with runner.isolated_filesystem(temp_dir=tmp_path): 17 | result = runner.invoke(app, ["startapp", "demo", "--force"]) 18 | assert result.output == CREATED_SUCCESSFULLY 19 | assert result.exit_code == 0 20 | 21 | 22 | def test_startapp_already_exists(tmp_path: Path): 23 | with runner.isolated_filesystem(temp_dir=tmp_path): 24 | result = runner.invoke(app, ["startapp", "demo", "--force"]) 25 | assert result.output == CREATED_SUCCESSFULLY 26 | assert result.exit_code == 0 27 | 28 | result = runner.invoke(app, ["startapp", "demo", "--force"]) 29 | assert result.output == ALREADY_EXISTS 30 | assert result.exit_code == 0 31 | 32 | 33 | CURRENT_PATH = "." 34 | 35 | test_startapp_default(CURRENT_PATH) 36 | test_startapp_already_exists(CURRENT_PATH) 37 | 38 | rm_tmp_dir(CURRENT_PATH) 39 | -------------------------------------------------------------------------------- /tests/test_startproject.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | 3 | from typer.testing import CliRunner 4 | 5 | from fastapi_builder.main import app 6 | from utils import rm_tmp_dir 7 | 8 | 9 | runner = CliRunner() 10 | 11 | CREATED_SUCCESSFULLY = "\nFastAPI project created successfully! 🎉\n" 12 | ALREADY_EXISTS = "\nFolder 'demo' already exists. 😞\n" 13 | 14 | 15 | def test_startproject_default(tmp_path: Path): 16 | with runner.isolated_filesystem(temp_dir=tmp_path): 17 | result = runner.invoke(app, ["startproject", "demo"]) 18 | assert result.output == CREATED_SUCCESSFULLY 19 | assert result.exit_code == 0 20 | 21 | 22 | def test_startproject_already_exists(tmp_path: Path): 23 | with runner.isolated_filesystem(temp_dir=tmp_path): 24 | result = runner.invoke(app, ["startproject", "demo"]) 25 | assert result.output == CREATED_SUCCESSFULLY 26 | assert result.exit_code == 0 27 | 28 | result = runner.invoke(app, ["startproject", "demo"]) 29 | assert result.output == ALREADY_EXISTS 30 | assert result.exit_code == 0 31 | 32 | 33 | CURRENT_PATH = "." 34 | 35 | test_startproject_default(CURRENT_PATH) 36 | test_startproject_already_exists(CURRENT_PATH) 37 | 38 | rm_tmp_dir(CURRENT_PATH) 39 | -------------------------------------------------------------------------------- /.github/workflows/python-publish.yml: -------------------------------------------------------------------------------- 1 | # This workflow will upload a Python Package using Twine when a release is created 2 | # For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-python#publishing-to-package-registries 3 | 4 | # This workflow uses actions that are not certified by GitHub. 5 | # They are provided by a third-party and are governed by 6 | # separate terms of service, privacy policy, and support 7 | # documentation. 8 | 9 | name: Upload Python Package 10 | 11 | on: 12 | release: 13 | types: [published] 14 | 15 | permissions: 16 | contents: read 17 | 18 | jobs: 19 | deploy: 20 | 21 | runs-on: ubuntu-latest 22 | 23 | steps: 24 | - uses: actions/checkout@v4 25 | - name: Set up Python 26 | uses: actions/setup-python@v3 27 | with: 28 | python-version: '3.x' 29 | - name: Install dependencies 30 | run: | 31 | python -m pip install --upgrade pip 32 | pip install build 33 | - name: Build package 34 | run: python -m build 35 | - name: Publish package 36 | uses: pypa/gh-action-pypi-publish@27b31702a0e7fc50959f5ad993c78deac1bdfc29 37 | with: 38 | user: __token__ 39 | password: ${{ secrets.PYPI_API_TOKEN }} 40 | -------------------------------------------------------------------------------- /fastapi_builder/templates/project/{{ cookiecutter.folder_name }}/db/database.py: -------------------------------------------------------------------------------- 1 | from typing import AsyncGenerator 2 | 3 | from sqlalchemy import create_engine 4 | from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession 5 | from sqlalchemy.orm import sessionmaker, Session 6 | 7 | from core.config import settings 8 | 9 | 10 | # 同步数据库(依赖:greenlet) 11 | engine = create_engine(settings.DATABASE_URL._url, echo=True, pool_size=10, max_overflow=20) 12 | 13 | SessionLocal: Session = sessionmaker( 14 | bind=engine, autocommit=False, autoflush=False, expire_on_commit=False 15 | ) 16 | 17 | 18 | # 异步数据库 19 | async_engine = create_async_engine( 20 | settings.ASYNC_DATABASE_URL._url, 21 | echo=True, 22 | pool_size=10, # 连接池大小 23 | max_overflow=20, # 连接池溢出时最大创建的连接数 24 | ) 25 | 26 | AsyncSessionLocal: AsyncSession = sessionmaker( 27 | bind=async_engine, 28 | class_=AsyncSession, 29 | autocommit=False, 30 | autoflush=False, 31 | expire_on_commit=False, 32 | ) 33 | 34 | # 自定义在了 models.base 下 35 | # Base = declarative_base() 36 | 37 | 38 | # Dependency 39 | async def get_async_db() -> AsyncGenerator[AsyncSession, None]: 40 | """ 41 | 每一个请求处理完毕后会关闭当前连接,不同的请求使用不同的连接 42 | """ 43 | async with AsyncSessionLocal() as session: 44 | yield session 45 | -------------------------------------------------------------------------------- /fastapi_builder/templates/project/{{ cookiecutter.folder_name }}/lib/security.py: -------------------------------------------------------------------------------- 1 | import bcrypt 2 | from passlib.context import CryptContext 3 | from fastapi.security import OAuth2PasswordBearer 4 | 5 | from core.config import settings 6 | 7 | 8 | oauth2_scheme = OAuth2PasswordBearer( 9 | tokenUrl=settings.API_PREFIX + "/auth/token", auto_error=False 10 | ) 11 | 12 | pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto") 13 | 14 | 15 | def generate_salt() -> str: 16 | """生成盐,用于对密码加密时安全""" 17 | return bcrypt.gensalt().decode() 18 | 19 | 20 | def verify_password(plain_password: str, hashed_password: str) -> bool: 21 | """ 22 | 验证用户输入密码与加密过后的密码是否相等,使用 passlib 库完成 23 | 24 | Args: 25 | plain_password (str): 用户输入的明文密码 26 | hashed_password (str): 加密过后的密码 27 | 28 | Returns: 29 | bool: 验证结果 30 | """ 31 | return pwd_context.verify(plain_password, hashed_password) 32 | 33 | 34 | def get_password_hash(password: str) -> str: 35 | """ 36 | 将用户输入的明文密码,使用 hash 算法加密,每次加密的算法都不一样 37 | 38 | Args: 39 | password (str): 用户输入的明文密码 40 | 41 | Returns: 42 | str: 加密过后的密码 43 | """ 44 | return pwd_context.hash(password) 45 | 46 | 47 | # Bearer eyJhbGc... 48 | # Authorization 49 | # http://127.0.0.1:8000/api/auth/login 50 | -------------------------------------------------------------------------------- /fastapi_builder/templates/project/{{ cookiecutter.folder_name }}/models/mixins.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | from sqlalchemy import Column, DateTime, update 3 | from sqlalchemy.ext.asyncio import AsyncSession 4 | 5 | 6 | class DateTimeModelMixin(object): 7 | """创建默认时间""" 8 | 9 | created_at = Column(DateTime, default=datetime.datetime.now) 10 | updated_at = Column(DateTime, default=datetime.datetime.now, onupdate=datetime.datetime.now) 11 | 12 | 13 | class SoftDeleteModelMixin(object): 14 | """记录软删除""" 15 | 16 | deleted_at = Column(DateTime, nullable=True) 17 | 18 | async def remove(self, db: AsyncSession = None) -> "SoftDeleteModelMixin": 19 | """ 20 | 单个的实例删除 21 | 22 | Args: 23 | db (AsyncSession): db async session 24 | 25 | Returns: 26 | SoftDeleteModelMixin: self 27 | """ 28 | self.deleted_at = datetime.datetime.now() 29 | db.add(self) 30 | return self 31 | 32 | @classmethod 33 | async def remove_by(cls, db: AsyncSession, **kw) -> None: 34 | """ 35 | 批量删除 36 | 37 | Args: 38 | db (AsyncSession): db async session 39 | **kw: 查询条件 40 | """ 41 | stmt = update(cls).where(cls.deleted_at.is_(None)).filter_by(**kw).values( 42 | deleted_at=datetime.datetime.now() 43 | ) 44 | await db.execute(stmt) 45 | -------------------------------------------------------------------------------- /fastapi_builder/templates/project/{{ cookiecutter.folder_name }}/db/base.py: -------------------------------------------------------------------------------- 1 | """自动从 apps 目录下导入所有应用的 models""" 2 | import importlib 3 | import pkgutil 4 | 5 | from typing import List 6 | 7 | from models.base import Base 8 | 9 | 10 | # 指定模型所在的包路径 11 | models_package = "apps" 12 | 13 | 14 | def import_models(package_name: str) -> List[Base]: 15 | # 导入包 16 | package = importlib.import_module(package_name) 17 | # 遍历包中的所有模块 18 | discovered_models = [] 19 | for _, name, is_pkg in pkgutil.walk_packages( 20 | package.__path__, package.__name__ + "." 21 | ): 22 | # 只考虑模块,不考虑子包 23 | if not is_pkg and name.endswith((".model", ".models")): 24 | module = importlib.import_module(name) 25 | # 尝试从每个模块中导入 Model 类 26 | for attribute_name in dir(module): 27 | attribute = getattr(module, attribute_name) 28 | if ( 29 | isinstance(attribute, type) 30 | and issubclass(attribute, Base) 31 | and attribute.__module__ == module.__name__ 32 | ): 33 | discovered_models.append(attribute) 34 | return discovered_models 35 | 36 | 37 | all_models = import_models(models_package) 38 | 39 | # 动态创建 __all__ 并设置全局变量 40 | __all__ = [cls.__name__ for cls in all_models] + ["Base"] 41 | globals().update({cls.__name__: cls for cls in all_models}) 42 | -------------------------------------------------------------------------------- /fastapi_builder/templates/project/{{ cookiecutter.folder_name }}/core/events.py: -------------------------------------------------------------------------------- 1 | # https://github.com/aio-libs-abandoned/aioredis-py/issues/1443 2 | from redis import asyncio as aioredis 3 | from typing import Callable, AsyncIterator 4 | from fastapi import FastAPI 5 | 6 | from core.config import settings 7 | from core.logger import app_logger as logger 8 | 9 | 10 | async def init_redis_pool( 11 | url: str, password: str | None = None, db: int = 0, port: int = 6379 12 | ) -> AsyncIterator[aioredis.Redis]: 13 | session = await aioredis.from_url( 14 | url=url, 15 | port=port, 16 | password=password, 17 | db=db, 18 | encoding="utf-8", 19 | decode_responses=True, 20 | ) 21 | return session 22 | 23 | 24 | def create_start_app_handler(app: FastAPI) -> Callable: 25 | """fastapi 启动事件句柄""" 26 | 27 | async def start_app() -> None: 28 | logger.info("fastapi 项目启动.") 29 | # 连接 redis 30 | app.state.redis = await init_redis_pool(settings.REDIS_URL, password=None) 31 | logger.info("redis 连接成功.") 32 | 33 | return start_app 34 | 35 | 36 | def create_stop_app_handler(app: FastAPI) -> Callable: 37 | """fastapi 结束事件句柄""" 38 | 39 | @logger.catch 40 | async def stop_app() -> None: 41 | logger.info("fastapi 项目结束.") 42 | # 关闭 redis 43 | await app.state.redis.close() 44 | logger.info("redis 连接关闭成功.") 45 | 46 | return stop_app 47 | -------------------------------------------------------------------------------- /fastapi_builder/templates/project/{{ cookiecutter.folder_name }}/apps/app_user/model.py: -------------------------------------------------------------------------------- 1 | from sqlalchemy import Column, String 2 | 3 | from lib.security import verify_password, get_password_hash, generate_salt 4 | 5 | from models.base import Base 6 | from models.mixins import DateTimeModelMixin, SoftDeleteModelMixin 7 | 8 | 9 | DEFAULT_AVATAR_URL = "https://cdn.img.com/avatar.png" 10 | 11 | 12 | class User(Base["User"], DateTimeModelMixin, SoftDeleteModelMixin): 13 | __tablename__ = "user" 14 | 15 | email = Column(String(32), unique=False, index=True) 16 | username = Column(String(32), unique=False, nullable=False) 17 | avatar_url = Column( 18 | String(256), 19 | nullable=True, 20 | default=DEFAULT_AVATAR_URL, 21 | server_default=DEFAULT_AVATAR_URL, 22 | ) 23 | salt = Column(String(32)) 24 | password = Column(String(600)) 25 | 26 | def check_password(self, password: str) -> bool: 27 | """ 28 | 检查密码是否相等 29 | 30 | Args: 31 | password (str): 密码 32 | 33 | Returns: 34 | bool: 是否相等 35 | """ 36 | return verify_password(self.salt + password, self.password) 37 | 38 | def change_password(self, password: str) -> None: 39 | """ 40 | 更改密码 41 | 42 | Args: 43 | password (str): 新密码 44 | """ 45 | self.salt = generate_salt() 46 | self.password = get_password_hash(self.salt + password) 47 | -------------------------------------------------------------------------------- /fastapi_builder/templates/project/{{ cookiecutter.folder_name }}/docker-compose.yaml: -------------------------------------------------------------------------------- 1 | version: "3.8" 2 | 3 | networks: 4 | {{ cookiecutter.name }}-network: 5 | driver: bridge 6 | name: {{ cookiecutter.name }}-network 7 | 8 | services: 9 | app: 10 | build: . 11 | ports: 12 | - "8000:8000" 13 | command: python main.py -c 14 | depends_on: 15 | - database 16 | - redis 17 | 18 | {% if cookiecutter.database == "Postgres" %} 19 | database: 20 | image: postgres:12 21 | ports: 22 | - "5432:5432" 23 | {% elif cookiecutter.database == "MySQL" %} 24 | database: 25 | container_name: {{ cookiecutter.name }}_mysql 26 | image: mysql:8.0.19 27 | restart: always 28 | environment: 29 | MYSQL_ROOT_PASSWORD: 123456 30 | MYSQL_USER: root 31 | MYSQL_PASSWORD: 123456 32 | MYSQL_AUTH_PLUGIN: mysql_native_password 33 | MYSQL_DATABASE: {{ cookiecutter.database_name }} 34 | TZ: Asia/Shanghai 35 | command: --default-authentication-plugin=mysql_native_password 36 | ports: 37 | - 3306:3306 38 | volumes: 39 | - ./docker_data/mysql:/var/lib/mysql 40 | networks: 41 | - {{ cookiecutter.name }}-network 42 | {% endif %} 43 | 44 | redis: 45 | container_name: {{ cookiecutter.name }}_redis 46 | image: redis:6.0.9 47 | restart: always 48 | environment: 49 | - TZ=Asia/Shanghai 50 | ports: 51 | - 6379:6379 52 | volumes: 53 | - ./docker_data/redis:/data 54 | networks: 55 | - {{ cookiecutter.name }}-network 56 | -------------------------------------------------------------------------------- /fastapi_builder/templates/project/{{ cookiecutter.folder_name }}/core/logger.py: -------------------------------------------------------------------------------- 1 | import os 2 | import time 3 | 4 | from loguru import logger 5 | 6 | 7 | LOG_APP = "app" 8 | LOG_CELERY = "celery" 9 | 10 | basedir = os.path.dirname(os.path.abspath(__file__)) 11 | 12 | # 定位到 log 日志文件 13 | log_path = os.path.join(basedir, "../logs") 14 | 15 | os.makedirs(log_path, exist_ok=True) 16 | os.makedirs(os.path.join(log_path, LOG_APP), exist_ok=True) 17 | os.makedirs(os.path.join(log_path, LOG_CELERY), exist_ok=True) 18 | 19 | app_log_path_file = os.path.join(log_path, LOG_APP, f"{time.strftime('%Y-%m-%d')}.log") 20 | celery_log_path_file = os.path.join(log_path, LOG_CELERY, f"{time.strftime('%Y-%m-%d')}.log") 21 | 22 | 23 | # 日志简单配置 24 | logger.add( 25 | app_log_path_file, 26 | rotation="00:00", 27 | retention="5 days", 28 | encoding="utf-8", 29 | enqueue=True, 30 | filter=lambda record: record["extra"]["name"] == LOG_APP, 31 | format="{time:YYYY-MM-DD HH:mm:ss.SSS} | {level: <8} | " 32 | "{name}:{function}:{line} | {thread.name} | {message}", 33 | ) 34 | logger.add( 35 | celery_log_path_file, 36 | rotation="00:00", 37 | retention="5 days", 38 | encoding="utf-8", 39 | enqueue=True, 40 | filter=lambda record: record["extra"]["name"] == LOG_CELERY, 41 | format="{time:YYYY-MM-DD HH:mm:ss.SSS} | {level: <8} | " 42 | "{name}:{function}:{line} | {thread.name} | {message}", 43 | ) 44 | 45 | app_logger = logger.bind(name=LOG_APP) 46 | celery_logger = logger.bind(name=LOG_CELERY) 47 | 48 | 49 | __all__ = ["app_logger", "celery_logger"] 50 | -------------------------------------------------------------------------------- /fastapi_builder/templates/project/{{ cookiecutter.folder_name }}/core/e/messages.py: -------------------------------------------------------------------------------- 1 | from typing import Dict, Literal, TypeAlias 2 | from core.e.codes import ErrorCode 3 | 4 | 5 | LANGUAGE_TYPE: TypeAlias = Literal["en", "zh"] 6 | 7 | 8 | class ErrorMessage: 9 | _messages: Dict[LANGUAGE_TYPE, Dict[ErrorCode, str]] = { 10 | "en": { 11 | # 通用错误 12 | ErrorCode.INTERNAL_SERVER_ERROR: "Internal Server Error", 13 | ErrorCode.BAD_REQUEST: "Bad Request", 14 | ErrorCode.UNAUTHORIZED: "Unauthorized", 15 | ErrorCode.FORBIDDEN: "Forbidden", 16 | ErrorCode.NOT_FOUND: "Not Found", 17 | # 用户类 01 18 | ErrorCode.USER_ERROR: "User Error", 19 | ErrorCode.USER_EXIST: "User Exist", 20 | ErrorCode.USER_NOT_FOUND: "User Not Found", 21 | ErrorCode.USER_NAME_EXIST: "User Name Exist", 22 | ErrorCode.USER_PASSWORD_ERROR: "User Password Error", 23 | ErrorCode.USER_EMAIL_EXIST: "User Email Exist", 24 | ErrorCode.USER_SMS_CODE_ERROR: "User SMS Code Error", 25 | ErrorCode.USER_PHONE_INVALID: "User Phone Invalid", 26 | ErrorCode.USER_SMS_CODE_REQUEST_TOO_OFTEN: "User SMS Code Request Too Often", 27 | ErrorCode.USER_UNAUTHORIZED: "User Unauthorized", 28 | # ... 29 | }, 30 | "zh": { 31 | # "NOT_FOUND": "未找到", 32 | # "UNAUTHORIZED": "未认证", 33 | }, 34 | } 35 | 36 | @classmethod 37 | def get(cls, code: int, lang: LANGUAGE_TYPE = "en"): 38 | return cls._messages[lang].get(code, "Unknown error code.") 39 | -------------------------------------------------------------------------------- /fastapi_builder/templates/project/{{ cookiecutter.folder_name }}/alembic/versions/b09d03e54aaf_autocreate_migration.py: -------------------------------------------------------------------------------- 1 | """autocreate migration 2 | 3 | Revision ID: b09d03e54aaf 4 | Revises: 5 | Create Date: 2024-09-02 16:41:48.522753 6 | 7 | """ 8 | 9 | from alembic import op 10 | import sqlalchemy as sa 11 | 12 | 13 | # revision identifiers, used by Alembic. 14 | revision = "b09d03e54aaf" 15 | down_revision = None 16 | branch_labels = None 17 | depends_on = None 18 | 19 | 20 | def upgrade(): 21 | # ### commands auto generated by Alembic - please adjust! ### 22 | op.create_table( 23 | "user", 24 | sa.Column("email", sa.String(length=32), nullable=True), 25 | sa.Column("username", sa.String(length=32), nullable=False), 26 | sa.Column( 27 | "avatar_url", 28 | sa.String(length=256), 29 | server_default="https://cdn.img.com/avatar.png", 30 | nullable=True, 31 | ), 32 | sa.Column("salt", sa.String(length=32), nullable=True), 33 | sa.Column("password", sa.String(length=600), nullable=True), 34 | sa.Column("id", sa.Integer(), nullable=False), 35 | sa.Column("created_at", sa.DateTime(), nullable=True), 36 | sa.Column("updated_at", sa.DateTime(), nullable=True), 37 | sa.Column("deleted_at", sa.DateTime(), nullable=True), 38 | sa.PrimaryKeyConstraint("id"), 39 | ) 40 | op.create_index(op.f("ix_user_email"), "user", ["email"], unique=False) 41 | op.create_index(op.f("ix_user_id"), "user", ["id"], unique=False) 42 | # ### end Alembic commands ### 43 | 44 | 45 | def downgrade(): 46 | # ### commands auto generated by Alembic - please adjust! ### 47 | op.drop_index(op.f("ix_user_id"), table_name="user") 48 | op.drop_index(op.f("ix_user_email"), table_name="user") 49 | op.drop_table("user") 50 | # ### end Alembic commands ### 51 | -------------------------------------------------------------------------------- /fastapi_builder/templates/project/hooks/post_gen_project.py: -------------------------------------------------------------------------------- 1 | import os 2 | from typing import List 3 | 4 | 5 | def remove_paths(paths: List[str]) -> None: 6 | """ 7 | 删除指定文件或目录 8 | 9 | Args: 10 | paths (List[str]): 文件或目录路径列表 11 | """ 12 | base_dir = os.getcwd() 13 | 14 | for path in paths: 15 | path = os.path.join(base_dir, path) 16 | if path and os.path.exists(path): 17 | if os.path.isdir(path): 18 | os.rmdir(path) 19 | else: 20 | os.unlink(path) 21 | 22 | 23 | def del_redundant_file() -> None: 24 | """删除多余的文件""" 25 | remove_paths(["__init__.py"]) 26 | 27 | 28 | def set_packaging() -> None: 29 | """打包工具的选择""" 30 | packaging = "{{ cookiecutter.packaging }}" 31 | if packaging == "pip": 32 | remove_paths(["poetry.lock", "pyproject.toml"]) 33 | elif packaging == "poetry": 34 | remove_paths(["requirements.txt"]) 35 | 36 | 37 | def set_pre_commit(): 38 | """pre commit 的选择""" 39 | pre_commit: bool = eval("{{ cookiecutter.pre_commit }}") 40 | if pre_commit is False: 41 | remove_paths([".pre-commit-config.yaml", "setup.cfg"]) 42 | 43 | 44 | def set_docker(): 45 | """docker 的选择""" 46 | docker: bool = eval("{{ cookiecutter.docker }}") 47 | if docker is False: 48 | remove_paths(["Dockerfile", "docker-compose.yaml"]) 49 | 50 | 51 | def set_license(): 52 | """许可证的选择""" 53 | license_ = "{{ cookiecutter.license }}" 54 | if license_ == "None": 55 | remove_paths(["LICENSE"]) 56 | 57 | 58 | def set_readme(): 59 | """README 的选择""" 60 | language = "{{ cookiecutter.language }}" 61 | if language == "cn": 62 | remove_paths(["README_EN.md"]) 63 | else: 64 | remove_paths(["README.md"]) 65 | # 将 README_EN 重命名为 README 66 | os.rename("README_EN.md", "README.md") 67 | 68 | 69 | def main(): 70 | del_redundant_file() 71 | set_docker() 72 | set_license() 73 | set_pre_commit() 74 | set_packaging() 75 | set_readme() 76 | 77 | 78 | if __name__ == "__main__": 79 | main() 80 | -------------------------------------------------------------------------------- /fastapi_builder/templates/project/{{ cookiecutter.folder_name }}/schemas/base.py: -------------------------------------------------------------------------------- 1 | import re 2 | 3 | from datetime import datetime, timezone 4 | from enum import Enum 5 | from typing import Annotated 6 | 7 | from fastapi import Query 8 | from pydantic import BaseModel, ConfigDict, PlainSerializer 9 | 10 | 11 | def convert_datetime(dt: datetime.datetime) -> str: 12 | """ 13 | 转换日期格式 14 | 15 | Args: 16 | dt (datetime.datetime): 日期时间对象 17 | 18 | Returns: 19 | str: 日期时间字符串. eg: 2021-11-04 14:17:10 20 | """ 21 | return ( 22 | dt.replace(tzinfo=datetime.timezone.utc) 23 | .isoformat() 24 | .replace("+00:00", "") 25 | .replace("T", " ") 26 | ) 27 | 28 | 29 | def convert_field_to_snake_case(string: str) -> str: 30 | """ 31 | 将驼峰命名转换为蛇形命名 32 | 33 | Args: 34 | string (str): 驼峰命名字符串 35 | 36 | Returns: 37 | str: 蛇形命名字符串. eg: camel_case 38 | """ 39 | snake_case = re.sub(r"(?P[A-Z])", r"_\g", string) 40 | return snake_case.lower().strip("_") 41 | 42 | 43 | # 自定义 datetime 响应类型 44 | CustomDatetime = Annotated[datetime, PlainSerializer(convert_datetime)] 45 | 46 | 47 | class BaseSchema(BaseModel): 48 | """自定义基础 schema 类""" 49 | 50 | model_config = ConfigDict(populate_by_name=True, alias_generator=convert_field_to_snake_case) 51 | 52 | def model_dump_json(self, force_by_alias: bool = True, **kwargs): 53 | """重写 model_dump_json 方法, 默认不转换 alias""" 54 | if not force_by_alias: 55 | return super().model_dump_json(**kwargs) 56 | return super().model_dump_json(by_alias=True, **kwargs) 57 | 58 | 59 | class OrderType(str, Enum): 60 | """排序方式""" 61 | 62 | ASC = "asc" 63 | DESC = "desc" 64 | 65 | 66 | class QuerySchema(BaseModel): 67 | """自定义查询 schema 类""" 68 | 69 | page: int = Query(1, ge=1, example=1, description="页码. 如果不填则全查") 70 | size: int | None = Query(None, ge=1, description="每页数量. 如果不填则全查") 71 | order_by: str = Query("id", description="排序字段. eg: id updated_at") 72 | order_type: OrderType = Query(OrderType.ASC, description="排序方式. eg: desc asc") 73 | q: str | None = Query(None, description="模糊查询. eg: name=xxx") 74 | -------------------------------------------------------------------------------- /fastapi_builder/templates/project/{{ cookiecutter.folder_name }}/main.py: -------------------------------------------------------------------------------- 1 | from fastapi import FastAPI 2 | from fastapi.exceptions import RequestValidationError 3 | from starlette.exceptions import HTTPException 4 | from starlette.middleware.cors import CORSMiddleware 5 | 6 | from api.errors.http_error import http_error_handler 7 | from api.errors.validation_error import http422_error_handler 8 | from api.routes.api import router as api_router 9 | 10 | from core.config import settings 11 | from core.events import create_start_app_handler, create_stop_app_handler 12 | 13 | from utils.docs import get_custom_openapi 14 | 15 | from middleware.logger import RequestLoggerMiddleware 16 | 17 | 18 | def get_application() -> FastAPI: 19 | # 项目配置 20 | application = FastAPI( 21 | title=settings.PROJECT_NAME, version=settings.VERSION, debug=settings.DEBUG 22 | ) 23 | 24 | # 跨域中间件 25 | application.add_middleware( 26 | CORSMiddleware, 27 | allow_credentials=True, 28 | allow_origins=["*"], 29 | allow_methods=["*"], 30 | allow_headers=["*"], 31 | ) 32 | # 请求日志中间件 33 | application.add_middleware(RequestLoggerMiddleware) 34 | 35 | # 生成 OpenAPI 模式 36 | application.openapi = get_custom_openapi(application) 37 | 38 | # 事件处理句柄 39 | application.add_event_handler("startup", create_start_app_handler(application)) 40 | application.add_event_handler("shutdown", create_stop_app_handler(application)) 41 | 42 | # 异常处理句柄 43 | application.add_exception_handler(HTTPException, http_error_handler) 44 | application.add_exception_handler(ValueError, http422_error_handler) 45 | application.add_exception_handler(RequestValidationError, http422_error_handler) 46 | 47 | # 路由导入 48 | application.include_router(api_router, prefix=settings.API_PREFIX) 49 | 50 | return application 51 | 52 | 53 | app = get_application() 54 | 55 | 56 | if __name__ == "__main__": 57 | # 检查 mysql 相关内容 58 | import sys 59 | 60 | if "-c" in sys.argv: 61 | from utils.dbmanager import DBManager 62 | 63 | DBManager.check_and_autocreate() 64 | 65 | # 启动项目 66 | import uvicorn 67 | 68 | uvicorn.run(app="main:app", host="127.0.0.1", port=8000, reload=True) 69 | -------------------------------------------------------------------------------- /fastapi_builder/context.py: -------------------------------------------------------------------------------- 1 | import subprocess 2 | from datetime import datetime 3 | from typing import Optional 4 | 5 | from pydantic import BaseModel, EmailStr, root_validator 6 | 7 | from fastapi_builder.config import FASTAPI_VERSION 8 | from fastapi_builder.constants import ( 9 | Database, 10 | Language, 11 | License, 12 | PackageManager, 13 | PythonVersion, 14 | ) 15 | from fastapi_builder.helpers import camel_to_pascal, snake_to_camel 16 | 17 | 18 | class AppContext(BaseModel): 19 | name: str 20 | folder_name: str 21 | snake_name: str 22 | camel_name: str 23 | pascal_name: str 24 | language: Optional[Language] 25 | 26 | @root_validator(pre=True) 27 | def validate_app(cls, values: dict): 28 | # 生成 app 相关名字 29 | values["folder_name"] = values["name"].lower().replace(" ", "-").strip() 30 | values["snake_name"] = values["folder_name"].replace("-", "_") 31 | values["camel_name"] = snake_to_camel(values["snake_name"]) 32 | values["pascal_name"] = camel_to_pascal(values["camel_name"]) 33 | return values 34 | 35 | class Config: 36 | use_enum_values = True 37 | 38 | 39 | class ProjectContext(BaseModel): 40 | name: str 41 | folder_name: str 42 | 43 | language: Optional[Language] 44 | packaging: PackageManager 45 | 46 | username: Optional[str] = None 47 | email: Optional[EmailStr] = None 48 | 49 | python: PythonVersion 50 | fastapi: str = FASTAPI_VERSION 51 | 52 | license: Optional[License] 53 | year: int 54 | 55 | pre_commit: bool 56 | docker: bool 57 | 58 | database: Optional[Database] 59 | database_name: Optional[str] 60 | 61 | @root_validator(pre=True) 62 | def validate_project(cls, values: dict): 63 | try: 64 | values["username"] = subprocess.check_output( 65 | ["git", "config", "--get", "user.name"] 66 | ) 67 | values["email"] = subprocess.check_output( 68 | ["git", "config", "--get", "user.email"] 69 | ) 70 | except subprocess.CalledProcessError: 71 | ... 72 | values["folder_name"] = values["name"].lower().replace(" ", "-").strip() 73 | values["year"] = datetime.today().year 74 | return values 75 | 76 | class Config: 77 | use_enum_values = True 78 | -------------------------------------------------------------------------------- /fastapi_builder/generator.py: -------------------------------------------------------------------------------- 1 | import os 2 | import shutil 3 | from typing import TypeVar 4 | 5 | import typer 6 | from cookiecutter.exceptions import OutputDirExistsException 7 | from cookiecutter.main import cookiecutter 8 | from pydantic.main import BaseModel 9 | 10 | from fastapi_builder.config import TEMPLATES_DIR 11 | from fastapi_builder.context import AppContext, ProjectContext 12 | from fastapi_builder.utils import new_app_inject_into_project 13 | 14 | ContextType = TypeVar("ContextType", bound=BaseModel) 15 | 16 | 17 | def del_all_pycache(filepath: str): 18 | """清除 __pycache__ 文件夹""" 19 | for fname in os.listdir(filepath): 20 | cur_path = os.path.join(filepath, fname) 21 | if os.path.isdir(cur_path): 22 | if fname == "__pycache__": 23 | shutil.rmtree(cur_path) 24 | else: 25 | del_all_pycache(cur_path) 26 | 27 | 28 | def fill_template(template_name: str, context: ContextType, output_dir: str = "."): 29 | try: 30 | cookiecutter( 31 | os.path.join(TEMPLATES_DIR, template_name), 32 | extra_context=context.dict(), 33 | no_input=True, 34 | output_dir=output_dir, 35 | ) 36 | except OutputDirExistsException: 37 | typer.echo(f"\nFolder '{context.folder_name}' already exists. 😞") 38 | else: 39 | typer.echo(f"\nFastAPI {template_name} created successfully! 🎉") 40 | 41 | filepath = context.folder_name 42 | if template_name == "app": 43 | filepath = os.path.join(output_dir, f"app_{context.folder_name}") 44 | 45 | # 尝试路由自动注入: 46 | # ps: 就算是 force 情况也会尝试自动注入 47 | # 1. 修改 db/base.py 导入 models 48 | # 2. 修改 api/routes/api.py 创建路由 49 | try: 50 | new_app_inject_into_project( 51 | folder_name=context.folder_name, 52 | pascal_name=context.pascal_name, 53 | snake_name=context.snake_name, 54 | ) 55 | except Exception: 56 | pass 57 | 58 | # 清除 __pycache__ 文件夹 59 | del_all_pycache(filepath) 60 | 61 | 62 | def generate_app(context: AppContext, output_dir: str): 63 | fill_template("app", context, output_dir) 64 | 65 | 66 | def generate_project(context: ProjectContext): 67 | fill_template("project", context) 68 | -------------------------------------------------------------------------------- /fastapi_builder/templates/project/{{ cookiecutter.folder_name }}/api/errors/validation_error.py: -------------------------------------------------------------------------------- 1 | from typing import Union 2 | 3 | from fastapi.exceptions import RequestValidationError 4 | from fastapi.openapi.utils import validation_error_response_definition 5 | from pydantic import ValidationError 6 | from starlette.requests import Request 7 | from starlette.responses import JSONResponse 8 | from starlette.status import HTTP_422_UNPROCESSABLE_ENTITY 9 | 10 | from schemas.response import StandardResponse 11 | 12 | 13 | async def http422_error_handler( 14 | _: Request, 15 | exc: Union[RequestValidationError, ValidationError, ValueError], 16 | ) -> JSONResponse: 17 | """ 18 | Custom error handler for 422 status code 19 | 20 | Args: 21 | _ (Request): starlette.requests.Request 22 | exc (Union[RequestValidationError, ValidationError, ValueError]): 响应异常 23 | 24 | Returns: 25 | JSONResponse: 返回内容、状态码、响应头 26 | """ 27 | if isinstance(exc, ValueError): 28 | errors = [ 29 | { 30 | "type": "value_error", 31 | "loc": ["body"], 32 | "msg": str(exc), 33 | "input": None, 34 | "ctx": None, 35 | } 36 | ] 37 | else: 38 | errors = exc.errors() 39 | 40 | return StandardResponse( 41 | code=HTTP_422_UNPROCESSABLE_ENTITY, 42 | message="Validation error", 43 | data={"errors": errors}, 44 | ).to_json(status_code=HTTP_422_UNPROCESSABLE_ENTITY) 45 | 46 | 47 | # Add `errors` to the OpenAPI schema 48 | validation_error_response_definition["properties"] = { 49 | "data": { 50 | "type": "object", 51 | "properties": { 52 | "errors": { 53 | "type": "array", 54 | "items": { 55 | "type": "object", 56 | "properties": { 57 | "type": {"type": "string"}, 58 | "loc": {"type": "array", "items": {"type": "string"}}, 59 | "msg": {"type": "string"}, 60 | "input": {"type": "object"}, 61 | "ctx": {"type": "object"} 62 | } 63 | } 64 | } 65 | } 66 | }, 67 | "code": {"type": "integer", "example": 422}, 68 | "message": {"type": "string"} 69 | } 70 | -------------------------------------------------------------------------------- /fastapi_builder/templates/project/{{ cookiecutter.folder_name }}/alembic/env.py: -------------------------------------------------------------------------------- 1 | import os 2 | import sys 3 | 4 | from logging.config import fileConfig 5 | 6 | from sqlalchemy import engine_from_config 7 | from sqlalchemy import pool 8 | 9 | from alembic import context 10 | 11 | from db.base import Base 12 | 13 | 14 | # 把当前项目路径加入到path中 15 | sys.path.append(os.path.dirname(os.path.dirname(__file__))) 16 | 17 | # this is the Alembic Config object, which provides 18 | # access to the values within the .ini file in use. 19 | config = context.config 20 | 21 | # Interpret the config file for Python logging. 22 | # This line sets up loggers basically. 23 | fileConfig(config.config_file_name) 24 | 25 | # add your model's MetaData object here 26 | # for 'autogenerate' support 27 | # from myapp import mymodel 28 | # target_metadata = mymodel.Base.metadata 29 | target_metadata = Base.metadata 30 | 31 | # other values from the config, defined by the needs of env.py, 32 | # can be acquired: 33 | # my_important_option = config.get_main_option("my_important_option") 34 | # ... etc. 35 | 36 | 37 | def run_migrations_offline(): 38 | """Run migrations in 'offline' mode. 39 | 40 | This configures the context with just a URL 41 | and not an Engine, though an Engine is acceptable 42 | here as well. By skipping the Engine creation 43 | we don't even need a DBAPI to be available. 44 | 45 | Calls to context.execute() here emit the given string to the 46 | script output. 47 | 48 | """ 49 | url = config.get_main_option("sqlalchemy.url") 50 | context.configure( 51 | url=url, 52 | target_metadata=target_metadata, 53 | literal_binds=True, 54 | dialect_opts={"paramstyle": "named"}, 55 | ) 56 | 57 | with context.begin_transaction(): 58 | context.run_migrations() 59 | 60 | 61 | def run_migrations_online(): 62 | """Run migrations in 'online' mode. 63 | 64 | In this scenario we need to create an Engine 65 | and associate a connection with the context. 66 | 67 | """ 68 | connectable = engine_from_config( 69 | config.get_section(config.config_ini_section), 70 | prefix="sqlalchemy.", 71 | poolclass=pool.NullPool, 72 | ) 73 | 74 | with connectable.connect() as connection: 75 | context.configure( 76 | connection=connection, target_metadata=target_metadata 77 | ) 78 | 79 | with context.begin_transaction(): 80 | context.run_migrations() 81 | 82 | 83 | if context.is_offline_mode(): 84 | run_migrations_offline() 85 | else: 86 | run_migrations_online() 87 | -------------------------------------------------------------------------------- /fastapi_builder/templates/project/{{ cookiecutter.folder_name }}/core/config.py: -------------------------------------------------------------------------------- 1 | import os 2 | import secrets 3 | 4 | from typing import Dict, List, Union 5 | 6 | from pathlib import Path 7 | from databases import DatabaseURL 8 | 9 | from starlette.config import Config 10 | from starlette.datastructures import CommaSeparatedStrings, Secret 11 | 12 | 13 | class CommaSeparatedStrings(CommaSeparatedStrings): 14 | 15 | def __eq__(self, value: object) -> bool: 16 | """重载 eq 方法,基于 _items 作比较""" 17 | return self._items == value 18 | 19 | 20 | class ConfigParser(Config): 21 | """重载 Config _read_file 方法,使其支持文件中文问题""" 22 | 23 | def _read_file(self, file_name: Union[str, Path]) -> Dict[str, str]: 24 | file_values: Dict[str, str] = {} 25 | with open(file_name, encoding="utf-8") as input_file: 26 | for line in input_file.readlines(): 27 | line = line.strip() 28 | if "=" in line and not line.startswith("#"): 29 | key, value = line.split("=", 1) 30 | key = key.strip() 31 | value = value.strip().strip("\"'") 32 | file_values[key] = value 33 | return file_values 34 | 35 | 36 | class Settings(object): 37 | """Application settings.""" 38 | 39 | # Initialize the ConfigParser 40 | config = ConfigParser(os.path.join(os.path.dirname(__file__), ".env")) 41 | 42 | # databases 43 | REDIS_URL: str = config("REDIS_URL", cast=str, default="") 44 | DATABASE_URL: DatabaseURL = config("DB_CONNECTION", cast=DatabaseURL, default="") 45 | ASYNC_DATABASE_URL: DatabaseURL = config("ASYNC_DB_CONNECTION", cast=DatabaseURL, default="") 46 | DB_CHARSET: str = config("CHARSET", cast=str, default="utf8") 47 | 48 | # system 49 | DEBUG: bool = config("DEBUG", cast=bool, default=False) 50 | SECRET_KEY: Secret = config( 51 | "SECRET_KEY", cast=Secret, default=secrets.token_urlsafe(32) 52 | ) 53 | API_PREFIX: str = config("API_PREFIX", cast=str, default="/api") 54 | 55 | # application 56 | PROJECT_NAME: str = config( 57 | "PROJECT_NAME", cast=str, default="FastAPI example application" 58 | ) 59 | VERSION: str = config("VERSION", cast=str, default="1.0.0") 60 | 61 | # authentication 62 | USER_BLACKLIST: List[str] = config("USER_BLACKLIST", cast=CommaSeparatedStrings, default=[]) 63 | 64 | # token 65 | JWT_TOKEN_PREFIX: str = config( 66 | "JWT_TOKEN_PREFIX", cast=str, default="Token" 67 | ) 68 | JWT_ALGORITHM: str = "HS256" 69 | ACCESS_TOKEN_EXPIRE_MINUTES: int = 60 * 24 * 3 # 三天,单位为分钟 70 | 71 | 72 | settings = Settings() 73 | -------------------------------------------------------------------------------- /fastapi_builder/templates/project/{{ cookiecutter.folder_name }}/lib/jwt.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime, timedelta 2 | from fastapi import Depends, HTTPException 3 | 4 | from jose import JWTError, jwt 5 | from pydantic import ValidationError 6 | from sqlalchemy.ext.asyncio import AsyncSession 7 | from sqlalchemy.future import select 8 | from starlette.status import HTTP_401_UNAUTHORIZED 9 | 10 | from apps.app_user.model import User 11 | 12 | from core.config import settings 13 | from core.e import ErrorCode, ErrorMessage 14 | from db.database import get_async_db 15 | from lib.security import oauth2_scheme 16 | 17 | 18 | def create_access_token(subject: int, expires_delta: timedelta = None) -> str: 19 | """ 20 | 使用 python-jose 库生成用户 token 21 | 22 | Args: 23 | subject (int): 一般传递一个用户 id 24 | expires_delta (timedelta): token 有效时间 25 | 26 | Returns: 27 | str: 加密后的 token 字符串 28 | """ 29 | if expires_delta: 30 | expire = datetime.now() + expires_delta 31 | else: 32 | expire = datetime.now() + timedelta( 33 | minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES 34 | ) 35 | to_encode = {"exp": expire, "sub": str(subject)} 36 | encoded_jwt = jwt.encode( 37 | claims=to_encode, 38 | key=settings.SECRET_KEY._value, 39 | algorithm=settings.JWT_ALGORITHM, 40 | ) 41 | return encoded_jwt 42 | 43 | 44 | async def get_current_user( 45 | db: AsyncSession = Depends(get_async_db), token: str = Depends(oauth2_scheme) 46 | ) -> User: 47 | """ 48 | 获取当前登录用户 49 | 50 | Args: 51 | db (AsyncSession): 数据库连接 52 | token (str): 登录凭证 53 | 54 | Returns: 55 | User: 当前登录用户 56 | """ 57 | try: 58 | payload = jwt.decode( 59 | token, key=settings.SECRET_KEY._value, algorithms=settings.JWT_ALGORITHM 60 | ) 61 | except (ValidationError, AttributeError, JWTError): 62 | raise HTTPException( 63 | status_code=HTTP_401_UNAUTHORIZED, 64 | detail=f"{ErrorCode.USER_UNAUTHORIZED}_{ErrorMessage.get(ErrorCode.USER_UNAUTHORIZED)}", 65 | # 根据 OAuth2 规范,认证失败需要在响应头中添加如下键值对 66 | headers={"WWW-Authenticate": "Bearer"}, 67 | ) 68 | 69 | user_id = payload["sub"] 70 | async with db.begin(): 71 | result = await db.execute(select(User).filter(User.id == user_id)) 72 | db_user = result.scalars().first() 73 | 74 | if db_user is None: 75 | raise HTTPException( 76 | status_code=HTTP_401_UNAUTHORIZED, 77 | detail=f"{ErrorCode.USER_NOT_FOUND}_{ErrorMessage.get(ErrorCode.USER_NOT_FOUND)}", 78 | # 根据 OAuth2 规范,认证失败需要在响应头中添加如下键值对 79 | headers={"WWW-Authenticate": "Bearer"}, 80 | ) 81 | return db_user 82 | -------------------------------------------------------------------------------- /fastapi_builder/templates/project/{{ cookiecutter.folder_name }}/models/base.py: -------------------------------------------------------------------------------- 1 | from typing import Generic, List, Type, TypeVar, Union 2 | 3 | from sqlalchemy import Column, Integer, Select, delete, inspect, select 4 | from sqlalchemy.ext.asyncio import AsyncSession 5 | from sqlalchemy.ext.declarative import as_declarative, declared_attr 6 | from sqlalchemy.orm import ColumnProperty 7 | 8 | 9 | T = TypeVar("T", bound="Base") 10 | 11 | 12 | @as_declarative() 13 | class Base(Generic[T]): 14 | 15 | # 默认表名 16 | @declared_attr 17 | def __tablename__(cls) -> str: 18 | # 转换成小写 19 | return cls.__name__.lower() 20 | 21 | # 默认字段 22 | id = Column(Integer, primary_key=True, index=True) 23 | 24 | # 打印实例返回值 25 | def __repr__(self) -> str: 26 | values = ", ".join( 27 | f"{n}={repr(getattr(self, n))}" for n in self.__table__.c.keys() 28 | ) 29 | return f"{self.__class__.__name__}({values})" 30 | 31 | # 自定义方法 32 | @classmethod 33 | async def query(cls) -> Select[T]: 34 | return ( 35 | select(cls).where(cls.deleted_at.is_(None)) 36 | if hasattr(cls, "deleted_at") 37 | else select(cls) 38 | ) 39 | 40 | @classmethod 41 | async def create(cls, db: AsyncSession, **kw) -> T: 42 | obj = cls(**kw) 43 | db.add(obj) 44 | return obj 45 | 46 | async def save(self, db: AsyncSession) -> None: 47 | db.add(self) 48 | 49 | @classmethod 50 | async def get_by(cls, db: AsyncSession, **kw) -> T | None: 51 | stmt = await cls.query() 52 | result = await db.execute(stmt.filter_by(**kw)) 53 | return result.scalars().first() 54 | 55 | @classmethod 56 | async def get_or_create(cls, db: AsyncSession, **kw) -> T: 57 | obj = await cls.get_by(db, **kw) 58 | if not obj: 59 | obj = await cls.create(db, **kw) 60 | return obj 61 | 62 | @classmethod 63 | async def all(cls, db: AsyncSession, /) -> List[T]: 64 | stmt = await cls.query() 65 | return (await db.execute(stmt)).scalars().all() 66 | 67 | @classmethod 68 | async def delete_by(cls, db: AsyncSession, /, **kw) -> None: 69 | stmt = delete(cls).filter_by(**kw) 70 | await db.execute(stmt) 71 | 72 | async def delete(self, db: AsyncSession, /) -> None: 73 | await db.delete(self) 74 | await db.commit() 75 | 76 | 77 | def get_model_fields_from_objects( 78 | model: Type, fields: List[Union[ColumnProperty]] | None = None 79 | ) -> List[str]: 80 | """从 SQLAlchemy 模型字段对象中提取字段名""" 81 | mapper = inspect(model) 82 | if fields is None: 83 | field_names = [column.name for column in mapper.columns] 84 | else: 85 | field_names = [column.name for column in mapper.columns if column in fields] 86 | return field_names 87 | -------------------------------------------------------------------------------- /fastapi_builder/templates/project/{{ cookiecutter.folder_name }}/alembic.ini: -------------------------------------------------------------------------------- 1 | # A generic, single database configuration. 2 | 3 | [alembic] 4 | # path to migration scripts 5 | script_location = alembic 6 | 7 | # template used to generate migration files 8 | # file_template = %%(rev)s_%%(slug)s 9 | 10 | # sys.path path, will be prepended to sys.path if present. 11 | # defaults to the current working directory. 12 | prepend_sys_path = . 13 | 14 | # timezone to use when rendering the date within the migration file 15 | # as well as the filename. 16 | # If specified, requires the python-dateutil library that can be 17 | # installed by adding `alembic[tz]` to the pip requirements 18 | # string value is passed to dateutil.tz.gettz() 19 | # leave blank for localtime 20 | # timezone = 21 | 22 | # max length of characters to apply to the 23 | # "slug" field 24 | # truncate_slug_length = 40 25 | 26 | # set to 'true' to run the environment during 27 | # the 'revision' command, regardless of autogenerate 28 | # revision_environment = false 29 | 30 | # set to 'true' to allow .pyc and .pyo files without 31 | # a source .py file to be detected as revisions in the 32 | # versions/ directory 33 | # sourceless = false 34 | 35 | # version location specification; This defaults 36 | # to alembic/versions. When using multiple version 37 | # directories, initial revisions must be specified with --version-path. 38 | # The path separator used here should be the separator specified by "version_path_separator" 39 | # version_locations = %(here)s/bar:%(here)s/bat:alembic/versions 40 | 41 | # version path separator; As mentioned above, this is the character used to split 42 | # version_locations. Valid values are: 43 | # 44 | # version_path_separator = : 45 | # version_path_separator = ; 46 | # version_path_separator = space 47 | version_path_separator = os # default: use os.pathsep 48 | 49 | # the output encoding used when revision files 50 | # are written from script.py.mako 51 | # output_encoding = utf-8 52 | 53 | sqlalchemy.url = mysql+pymysql://root:123456@127.0.0.1/{{ cookiecutter.database_name }} 54 | 55 | 56 | [post_write_hooks] 57 | # post_write_hooks defines scripts or Python functions that are run 58 | # on newly generated revision scripts. See the documentation for further 59 | # detail and examples 60 | 61 | # format using "black" - use the console_scripts runner, against the "black" entrypoint 62 | # hooks = black 63 | # black.type = console_scripts 64 | # black.entrypoint = black 65 | # black.options = -l 79 REVISION_SCRIPT_FILENAME 66 | 67 | # Logging configuration 68 | [loggers] 69 | keys = root,sqlalchemy,alembic 70 | 71 | [handlers] 72 | keys = console 73 | 74 | [formatters] 75 | keys = generic 76 | 77 | [logger_root] 78 | level = WARN 79 | handlers = console 80 | qualname = 81 | 82 | [logger_sqlalchemy] 83 | level = WARN 84 | handlers = 85 | qualname = sqlalchemy.engine 86 | 87 | [logger_alembic] 88 | level = INFO 89 | handlers = 90 | qualname = alembic 91 | 92 | [handler_console] 93 | class = StreamHandler 94 | args = (sys.stderr,) 95 | level = NOTSET 96 | formatter = generic 97 | 98 | [formatter_generic] 99 | format = %(levelname)-5.5s [%(name)s] %(message)s 100 | datefmt = %H:%M:%S 101 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | # MIT License 2 | 3 | # Copyright (c) 2021 fmw666 4 | 5 | # Permission is hereby granted, free of charge, to any person obtaining a copy 6 | # of this software and associated documentation files (the "Software"), to deal 7 | # in the Software without restriction, including without limitation the rights 8 | # to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | # copies of the Software, and to permit persons to whom the Software is 10 | # furnished to do so, subject to the following conditions: 11 | 12 | # The above copyright notice and this permission notice shall be included in all 13 | # copies or substantial portions of the Software. 14 | 15 | # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | # SOFTWARE. 22 | 23 | from setuptools import setup, find_packages 24 | 25 | 26 | # 解析 readme.md 文件 27 | with open("README.md", "r", encoding="utf-8") as fp: 28 | long_description = fp.read() 29 | 30 | # 解析 requirements.txt 文件 31 | with open("requirements.txt", "r", encoding="utf-8") as fp: 32 | install_requires = fp.read().split("\n") 33 | 34 | 35 | setup( 36 | name="fastapi-builder", 37 | version="2.0.0", 38 | author="fmw666", 39 | author_email="fmw19990718@gmail.com", 40 | description="fastapi-builder Project generator and manager for FastAPI", 41 | long_description=long_description, 42 | long_description_content_type="text/markdown", 43 | keywords=["fastapi", "builder", "project", "python", "fastapi-builder"], 44 | # 项目主页 45 | url="https://github.com/fmw666/fastapi-builder", 46 | # 需要被打包的内容 47 | packages=find_packages(where=".", exclude=(), include=("*",)), 48 | include_package_data=True, 49 | package_data={"": ["*.*"]}, 50 | exclude_package_data={"": ["*.pyc"]}, 51 | # 许可证 52 | license="https://mit-license.org/", 53 | # 项目依赖 54 | install_requires=install_requires, 55 | # 支持自动生成脚本 56 | entry_points={ 57 | "console_scripts": [ 58 | "fastapi=fastapi_builder.__main__:main" 59 | ] 60 | }, 61 | classifiers=[ 62 | # 3-Alpha 4-Beta 5-Production/Stable 63 | "Development Status :: 3 - Alpha", 64 | # 目标用户: 开发者 65 | "Intended Audience :: Developers", 66 | # 类型: 软件 67 | "Topic :: Software Development :: Build Tools", 68 | # 许可证信息 69 | "License :: OSI Approved :: MIT License", 70 | # 目标 Python 版本 71 | "Programming Language :: Python :: 3", 72 | "Programming Language :: Python :: 3.8", 73 | "Programming Language :: Python :: 3.9", 74 | "Programming Language :: Python :: 3.10", 75 | "Programming Language :: Python :: 3.11", 76 | "Programming Language :: Python :: 3.12", 77 | "Programming Language :: Python :: Implementation :: CPython", 78 | # 操作系统 79 | "Operating System :: OS Independent" 80 | ] 81 | ) 82 | 83 | # how to upload to pypi? 84 | # pip install twine==3.8.0 85 | # pip install wheel==0.38.4 86 | # pip install packaging==24.1 87 | # 1. python .\setup.py sdist bdist_wheel 88 | # 2. twine upload dist/* -u "__token__" -p "" 89 | -------------------------------------------------------------------------------- /docs/tutorial.md: -------------------------------------------------------------------------------- 1 | 2 | ## 🚀 使用教程 3 | 4 |
5 | 6 | ### 安装模块 7 | 8 | ```sh 9 | $ pip install fastapi-builder 10 | ``` 11 | 12 | ### 查看版本 13 | 14 | ```sh 15 | $ fastapi --version 16 | ``` 17 | 18 | ### 查看帮助 19 | 20 | ```sh 21 | $ fastapi --help 22 | ``` 23 | 24 | ### 项目的创建 25 | 26 | 对于快速创建一个项目您可以使用如下命令(假设您的项目名称为 test): 27 | 28 | ```sh 29 | $ fastapi startproject test 30 | ``` 31 | 32 | 默认生成的项目配置如下: 33 | 34 | + 数据库:MySQL 35 | + 数据库名称:*同创建的项目名* 36 | + docker:不带有 37 | + license:不带有 38 | + 打包方式:pip 39 | + pre-commit:不带有 40 | + Python 版本:3.8 41 | 42 | 当然,也许你需要一些可自主配置的操作: 43 | 44 | ```sh 45 | $ fastapi startproject test --database=mysql # 数据库选择 mysql 46 | $ fastapi startproject test --dbname=db_test # 数据库名称定义 47 | $ fastapi startproject test --docker # docker 选择带有 48 | $ fastapi startproject test --no-docker # docker 选择不带有 49 | $ fastapi startproject test --license=mit # 协议选择 MIT 50 | $ fastapi startproject test --packaging=pip # 打包方式选择 pip 51 | $ fastapi startproject test --pre-commit # pre-commit 选择带有 52 | $ fastapi startproject test --python=3.6 # python 版本选择 3.6 53 | ``` 54 | 55 | 配置项可以任意搭配: 56 | 57 | ```sh 58 | $ fastapi startproject test --docker --license=mit 59 | ``` 60 | 61 | 配置项可以重复,均以最后一个为准(如下面命令依然创建了 dockerfile 文件) 62 | 63 | ```sh 64 | $ fastapi startproject test --no-docker --docker 65 | ``` 66 | 67 | 要查看帮助可以使用 `--help` 选项 68 | 69 | ```sh 70 | $ fastapi startproject --help 71 | ``` 72 | 73 | 当然,如果您要改的配置项较多,想要更灵活的方式,我们推荐您使用交互式的创建: 74 | 75 | ```sh 76 | $ fastapi startproject test --interactive 77 | ``` 78 | 79 | ### 应用的创建 80 | 81 | ❗ 您必须在创建好的项目根目录下执行该命令 82 | 83 | ```sh 84 | $ fastapi startapp blog 85 | ``` 86 | 87 | 我们也为您准备了强制命令,以便您能在任何地方创建应用(当然,我们并不推荐您这样做) 88 | 89 | ```sh 90 | $ fastapi startapp blog --force 91 | ``` 92 | 93 | 要查看帮助可以使用 `--help` 选项 94 | 95 | ```sh 96 | $ fastapi startapp --help 97 | ``` 98 | 99 | ### 数据库操作?? 100 | 101 | 102 | ### 虚拟环境管理 103 | 104 | > 注意,我们并不会为您提供包管理相关帮助,因为我们认为您可以使用 pip 或 poetry 去管理,并且我们认为在相关方面,这两个工具在它们能力范围内已经足够成熟 105 | 106 | 调用创建虚拟环境命令,我们将在当前路径下创建一个名为 `venv` 的虚拟环境 107 | 108 | ```sh 109 | $ fastapi venv create 110 | ``` 111 | 112 | 当然,您也可以自定义虚拟环境名称,只不过我们推荐这个名称为 `venv` 113 | 114 | > 注意,请在命名时带上 env 名称,否则管理器将不会搜寻到该虚拟环境 115 | 116 | ```sh 117 | $ fastapi venv create --name=my_env 118 | ``` 119 | 120 | ```sh 121 | $ fastapi venv on # 开启虚拟环境 122 | $ fastapi venv off # 关闭虚拟环境 123 | ``` 124 | 125 | ### 项目的运行 126 | 127 | ❗ 您必须在创建好的项目根目录下执行该命令 128 | 129 | ```sh 130 | $ fastapi run 131 | ``` 132 | 133 | > 所有项目当创建后首次运行时,fastapi-builder 会自动配置环境 134 | 135 | 当然,项目的运行可能出现异常情况,您可以通过 `--check` 参数检查运行环境 136 | 137 | ```sh 138 | $ fastapi run --check 139 | ``` 140 | 141 | 我们也提供针对错误环境的修正,您只需要通过 `--config` 来进行环境配置 142 | 143 | ```sh 144 | $ fastapi run --config 145 | ``` 146 | 147 | 要查看帮助可以使用 `--help` 选项 148 | 149 | ```sh 150 | $ fastapi run --help 151 | ``` 152 | 153 |
154 | 155 | **1. 启用虚拟环境** 156 | 157 | 项目中使用虚拟环境是必要的,我们也强烈建议您通过虚拟环境来开发您的项目: 158 | 159 | ```sh 160 | # OS-windows cmd 161 | $ pip install virtualenv # 您的 python 版本需要 ≥ 3.6 162 | $ virtualenv venv # 创建虚拟环境 163 | $ .\venv\Scripts\activate # 启动虚拟环境 164 | 165 | (venv)$ pip install fastapi-builder # 安装模块 166 | ``` 167 | 168 | ### 数据迁移管理 169 | 170 | fastapi-builder 内置 alembic 作为项目数据库迁移工具,我们提供了两条基础指令来帮助完成迁移文件的生成和执行: 171 | 172 | 生成迁移文件 173 | 174 | ```sh 175 | $ fastapi db makemigrations 176 | ``` 177 | 178 | 生成迁移文件时,往往用户要提供 message 信息(默认为 "create migration") 179 | 180 | ```sh 181 | $ fastapi db makemigrations -m="here is the message" 182 | ``` 183 | 184 | 执行迁移文件 185 | 186 | ```sh 187 | $ fastapi db migrate 188 | ``` 189 | -------------------------------------------------------------------------------- /fastapi_builder/templates/project/{{ cookiecutter.folder_name }}/api/routes/authentication.py: -------------------------------------------------------------------------------- 1 | from fastapi import APIRouter, Body, Depends, HTTPException 2 | from fastapi.security import OAuth2PasswordRequestForm 3 | from sqlalchemy.ext.asyncio import AsyncSession 4 | from starlette.status import HTTP_400_BAD_REQUEST, HTTP_404_NOT_FOUND 5 | 6 | from apps.app_user import doc, model, schema 7 | from core.e import ErrorCode, ErrorMessage 8 | from db.errors import EntityDoesNotExist 9 | from db.database import get_async_db 10 | from lib.jwt import create_access_token, get_current_user 11 | 12 | 13 | router = APIRouter() 14 | 15 | 16 | """ 17 | 接口:Authentication 用户认证 18 | 19 | POST /api/auth/login -> login -> 用户登录 20 | POST /api/auth/register -> register -> 用户注册 21 | GET /api/auth/test -> test -> token 测试 22 | POST /api/auth/token -> token -> token 认证 23 | """ 24 | 25 | 26 | @router.post( 27 | "/login", 28 | name="用户登录", 29 | response_model=schema.UserLoginResponseModel, 30 | responses=doc.login_responses, 31 | ) 32 | async def login( 33 | login_request: schema.UserLoginRequest = Body(..., openapi_examples=doc.login_request), 34 | db: AsyncSession = Depends(get_async_db), 35 | ): 36 | db_user: model.User | None = await model.User.get_by(db, username=login_request.username) 37 | if not db_user: 38 | return schema.UserLoginResponseModel( 39 | code=ErrorCode.USER_NOT_FOUND, 40 | message=ErrorMessage.get(ErrorCode.USER_NOT_FOUND), 41 | ).to_json(status_code=HTTP_404_NOT_FOUND) 42 | 43 | if not db_user.check_password(login_request.password): 44 | return schema.UserLoginResponseModel( 45 | code=ErrorCode.USER_PASSWORD_ERROR, 46 | message=ErrorMessage.get(ErrorCode.USER_PASSWORD_ERROR), 47 | ).to_json(HTTP_400_BAD_REQUEST) 48 | 49 | # 返回中必包含 "token_type": "bearer", "access_token": "xxxtokenxxx" 50 | return schema.UserLoginResponseModel( 51 | data=schema.UserLoginResponse( 52 | id=db_user.id, 53 | username=db_user.username, 54 | email=db_user.email, 55 | token_type="bearer", 56 | access_token=create_access_token(db_user.id), 57 | ) 58 | ) 59 | 60 | 61 | @router.post( 62 | "/register", 63 | name="用户注册", 64 | response_model=schema.UserRegisterResponseModel, 65 | responses=doc.register_responses, 66 | ) 67 | async def register( 68 | user: schema.UserRegisterRequest = Body(..., openapi_examples=doc.register_request), 69 | db: AsyncSession = Depends(get_async_db), 70 | ): 71 | async with db.begin(): 72 | db_user: model.User | None = await model.User.get_by(db, username=user.username) 73 | if db_user: 74 | return schema.UserRegisterResponseModel( 75 | code=ErrorCode.USER_NAME_EXIST, 76 | message=ErrorMessage.get(ErrorCode.USER_NAME_EXIST), 77 | ).to_json(status_code=HTTP_400_BAD_REQUEST) 78 | db_user: model.User | None = await model.User.get_by(db, email=user.email) 79 | if db_user: 80 | return schema.UserRegisterResponseModel( 81 | code=ErrorCode.USER_EMAIL_EXIST, 82 | message=ErrorMessage.get(ErrorCode.USER_EMAIL_EXIST), 83 | ).to_json(status_code=HTTP_400_BAD_REQUEST) 84 | 85 | db_user = await model.User.create(db, **user.model_dump()) 86 | db_user.change_password(user.password) 87 | await db_user.save(db) 88 | return schema.UserRegisterResponseModel( 89 | data=schema.UserRegisterResponse.model_validate(db_user, from_attributes=True) 90 | ) 91 | 92 | 93 | """以下接口只针对 Swagger UI 接口文档做测试,实际开发环境中不会存在""" 94 | 95 | 96 | @router.get("/test", name="(仅用作 Swagger UI 调试)基于 token 身份认证测试") 97 | async def test(current_user: model.User = Depends(get_current_user)): 98 | return current_user 99 | 100 | 101 | @router.post("/token", name="(仅用作 Swagger UI 调试)文档身份认证接口") 102 | async def token( 103 | form_data: OAuth2PasswordRequestForm = Depends(), 104 | db: AsyncSession = Depends(get_async_db), 105 | ): 106 | wrong_login_error = HTTPException( 107 | status_code=HTTP_400_BAD_REQUEST, 108 | detail=f"{HTTP_400_BAD_REQUEST}_token error", 109 | ) 110 | 111 | db_user: model.User | None = await model.User.get_by(db, username=form_data.username) 112 | if not db_user: 113 | raise wrong_login_error from EntityDoesNotExist 114 | 115 | if not db_user.check_password(form_data.password): 116 | raise wrong_login_error 117 | 118 | # 返回中必包含 "token_type": "bearer", "access_token": "xxxtokenxxx" 119 | return { 120 | "id": db_user.id, 121 | "name": db_user.username, 122 | "token_type": "bearer", 123 | "access_token": create_access_token(db_user.id), 124 | } 125 | -------------------------------------------------------------------------------- /fastapi_builder/templates/project/{{ cookiecutter.folder_name }}/README.md: -------------------------------------------------------------------------------- 1 | # 「 {{ cookiecutter.name }} 」 2 | 3 |
4 | fastapi-builder 项目网址 ➡ 5 |
6 | 7 |
8 | 9 | > 💡 **帮助您快速构建 fastapi 项目.** 10 | 11 | + ***[快速启用](#-快速启用)*** 12 | 13 | + ***[项目结构](#-项目结构)*** 14 | 15 | + ***[功能示例](#-功能示例)*** 16 | 17 |
18 | 19 |
20 | 21 |
22 | 23 | ## 🚀 快速启用 24 | 25 | > *我们更推荐您安装并使用 fastapi-builder 工具*
26 | > 项目启动后,在浏览器中输入地址:,访问 swagger-ui 文档. 27 | 28 | ### ⭐ 方式一:使用 fastapi-builder 工具 29 | 30 | 31 | + 快速启动项目:`fastapi run` 32 | + 检查项目配置:`fastapi run --check` 33 | + 快速配置项目:`fastapi run --config` 34 | 35 | *若未使用 fastapi-builder,请尝试手动完成方式二步骤。* 36 | 37 | ### 方式二:手动配置项目并启动 38 | 39 | **1. 修改项目配置** 40 | 41 | > 想要运行本项目,配置信息应该是您首先要关注的。 42 | 43 | ```js 44 | project 45 | ├── core/ 46 | │ ├── .env // 项目整体配置 47 | ├── alembic.ini // 数据迁移配置 48 | ``` 49 | 50 | ```s 51 | # core/.env 52 | DB_CONNECTION=mysql+pymysql://username:password@127.0.0.1:3306/dbname 53 | SECRET_KEY=OauIrgmfnwCdxMBWpzPF7vfNzga1JVoiJi0hqz3fzkY 54 | 55 | 56 | # alembic.ini 57 | ... 58 | # 第 53 行,值同 .env 文件中 DB_CONNECTION 59 | sqlalchemy.url = mysql+pymysql://root:admin@localhost/dbname 60 | ``` 61 | 62 | *(当您开始尝试阅读 [server/core/config.py](#no-reply) 文件后,您可以开始编写更多相关配置)* 63 | 64 | **2. 启用数据库** 65 | 66 | 最后,您需要在环境中正确启动 mysql 服务,创建一个数据库,并执行迁移文件完成数据库中表的建立.
67 | 幸运的是,这一点我们已经尽可能地为您考虑。您只需要正确启动 mysql 服务,并在 [app/utils/](#no-reply) 中执行: 68 | 69 | ```sh 70 | project\utils> python dbmanager.py 71 | ``` 72 | 73 | **3. 运行项目** 74 | 75 | ```sh 76 | project> python main.py 77 | ``` 78 | 79 |
80 | 81 | ## 📌 项目结构 82 | 83 | ```js 84 | project 85 | ├── alembic/ - 数据库迁移工具 86 | │ ├── versions/ 87 | │ ├── env.py 88 | │ ├── README 89 | │ ├── script.py.mako 90 | ├── api/ - web 相关(路由、认证、请求、响应). 91 | │ ├── errors/ - 定义错误处理方法. 92 | │ │ ├── http_error.py - http 错误处理方法 93 | │ │ │── validation_error.py - 验证错误处理方法 94 | │ ├── routes/ - web routes 路由. 95 | │ │ ├── api.py - 总路由接口 96 | │ │ └── authentication.py - 认证相关(登录、注册)路由 97 | ├── apps/ - 子应用. 98 | │ ├── app_user/ - user 应用. 99 | │ │ ├── api.py - 提供 user 接口方法 100 | │ │ ├── doc.py - 提供 user Swagger UI 文档 101 | │ │ ├── field.py - 提供 user pydantic 验证字段 102 | │ │ ├── model.py - 提供 user 表模型 103 | │ │ └── schema.py - 提供 user pydantic 结构模型 104 | ├── core/ - 项目核心配置, 如: 配置文件, 事件句柄, 日志. 105 | │ ├── e/ - 错误处理包. 106 | │ │ ├── __init__.py 107 | │ │ ├── codes.py - 错误码定义 108 | │ │ └── messages.py - 错误消息定义 109 | │ ├── .env - 配置文件. 110 | │ ├── config.py - 解析配置文件, 用于其他文件读取配置. 111 | │ ├── events.py - 定义 fastapi 事件句柄. 112 | │ ├── logger.py - 定义项目日志方法. 113 | ├── db/ - 数据库相关. 114 | │ ├── base.py - 导入所有应用 model. 115 | │ ├── database.py - sqlalchemy 方法应用. 116 | │ ├── errors.py - 数据库相关错误异常. 117 | ├── lib/ - 自定义库 118 | │ ├── jwt.py - 用户认证 jwt 方法. 119 | │ ├── security.py - 加密相关方法. 120 | ├── logs/ - 日志文件目录. 121 | ├── middleware/ - 项目中间件. 122 | │ ├── logger.py - 请求日志处理. 123 | ├── models/ - sqlalchemy 基础模型相关 124 | │ ├── base.py - sqlalchemy declarative Base 表模型. 125 | │ ├── mixins.py - mixin 抽象模型定义. 126 | ├── schemas/ - pydantic 结构模型相关. 127 | │ ├── base.py - pydantic 结构模型基础类. 128 | │ ├── jwt.py - jwt 相关结构模型. 129 | │ ├── response.py - 响应模型封装. 130 | ├── utils/ - 工具类. 131 | │ ├── dbmanager.py - 数据库管理服务. 132 | │ ├── docs.py - fastapi docs 文档自定义. 133 | {% if cookiecutter.pre_commit == "True" -%} 134 | ├── .pre-commit-config.yaml - pre-commit 配置文件. 135 | {% else -%} 136 | 137 | {%- endif -%} 138 | ├── alembic.ini - alembic 数据库迁移工具配置文件. 139 | {% if cookiecutter.docker == "True" -%} 140 | ├── docker-compose.yaml - docker 配置. 141 | ├── Dockerfile - dockfile 文件. 142 | {% else -%} 143 | 144 | {%- endif -%} 145 | ├── fastapi-builder.ini - fastapi-builder 配置. 146 | {% if cookiecutter.license -%} 147 | ├── LICENSE - 许可证信息. 148 | {% else -%} 149 | 150 | {%- endif -%} 151 | ├── main.py - fastapi application 创建和配置. 152 | {% if cookiecutter.packaging == "poetry" -%} 153 | ├── pyproject.toml - poetry 需求模块信息. 154 | {% else -%} 155 | 156 | {%- endif -%} 157 | ├── README.md - 项目说明文档. 158 | {% if cookiecutter.packaging == "pip" -%} 159 | ├── requirements.txt - pip 需求模块信息. 160 | {% else -%} 161 | 162 | {%- endif -%} 163 | {% if cookiecutter.pre_commit == "True" -%} 164 | ├── setup.cfg - pre-commit 配置文件. 165 | {% else -%} 166 | 167 | {%- endif -%} 168 | ``` 169 | 170 |
171 | 172 | ## 💬 功能示例 173 | 174 | 详情见项目启动后的 Swagger docs. 175 | 176 |
177 | 178 | ## License 179 | 180 | This project is licensed under the terms of the {{ cookiecutter.license }} license. 181 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # 「 FastAPI Builder V2 」 2 | 3 |
4 | fastapi 官方网站 ➡ 5 |
6 | 7 |
8 | 9 | > 💡 **fastapi 项目构建器. 一款帮助您快速构建 fastapi 项目的工具.** 10 | 11 |   fastapi-builder 是一个基于 FastAPI 框架的快速 Web 应用程序开发的工具箱。它提供了一组现成的工具和组件,可以帮助您快速构建具有良好结构和可维护性的 FastAPI Web 应用程序。其目的是提供一个一站式的解决方案,以加速快速原型开发和生产部署。 12 | 13 | + ***[特性](#-特性)*** 14 | 15 | + ***[TODO](#-todo)*** 16 | 17 | + ***[快速开始](#-快速开始)*** 18 | 19 | + ***[项目结构](#-项目结构)*** 20 | 21 | + ***[特别感谢](#-特别感谢)*** 22 | 23 | + ***[许可证](#-许可证)*** 24 | 25 |
26 | 27 |
28 | 29 |
30 | 31 | ## 💬 特性 32 | 33 | + 🔥 一键生成可定制的应用程序模块,支持热更新,并提供完整的 CRUD 接口。 34 | 35 | + 灵感来源于 Django 的项目基础命令。 36 | 37 | + 支持创建可自定义的完整项目结构。 38 | 39 | + 自动生成项目结构,简化初始化过程。 40 | 41 | + 对数据库操作进行封装,简化管理流程。 42 | 43 | + 支持多种数据库,包括 MySQL。 44 | 45 | + 提供 Dockerfile 和 pre-commit 钩子等可选配置。 46 | 47 | + 管理虚拟环境,简化环境搭建和依赖管理。 48 | 49 |
50 | 51 | ## 🎯 TODO 52 | 53 |
PS: 期待您对本项目做出贡献...
54 | 55 | + [ ] 持续完善项目框架代码 56 | 57 | + [ ] 持续完善项目文档 58 | 59 | + [x] 优化 requirements.txt 60 | 61 | + [ ] 提供英文文档版本 62 | 63 | + [ ] 提供 PostgreSQL、SQLite 数据库支持 64 | 65 | + [x] 提供完整的 run 方法 66 | 67 | + [x] 内置 Alembic 数据迁移管理 68 | 69 | + [x] 提供运行环境检查 70 | 71 | + [x] 提供 FastAPI venv 命令,管理虚拟环境 72 | 73 | + [x] 针对 Linux 和 Mac 环境提供支持 74 | 75 | + [x] 生成 app 时,自动注入到 project 中(路由管理分配) 76 | 77 |
78 | 79 | ## 🚀 快速开始 80 | 81 |
82 | => 依赖:Python 3.8+ 83 |
84 | => 详细教程:tutorial 85 |
86 |
87 | 88 | 安装 `fastapi-builder` 项目: 89 | 90 | ```sh 91 | pip install fastapi-builder 92 | ``` 93 | 94 | 查看项目版本: 95 | 96 | ```sh 97 | fastapi --version 98 | ``` 99 | 100 | 项目帮助: 101 | 102 | ```sh 103 | fastapi --help 104 | fastapi startproject --help 105 | ``` 106 | 107 | 创建 `fastapi` 项目: 108 | 109 | ```sh 110 | fastapi startproject [name] 111 | 112 | # or 带有交互选择 113 | 114 | fastapi startproject [name] --interactive 115 | ``` 116 | 117 | 创建 `fastapi` 应用: 118 | 119 | ```sh 120 | fastapi startapp [name] 121 | ``` 122 | 123 | 运行 `fastapi` 项目: 124 | 125 | ```sh 126 | fastapi run 127 | ``` 128 | 129 | 通过访问 `http://127.0.0.1:8000/docs` 以确保 fastapi 服务正常运行. 130 | 131 |
132 | 133 | ## 📁 项目结构 134 | 135 | ```c 136 | . 137 | ├── alembic/ - 数据库迁移工具 138 | ├── api/ - web 相关(路由、认证、请求、响应). 139 | │ ├── errors/ - 定义错误处理方法. 140 | │ │ ├── http_error.py - http 错误处理方法 141 | │ │ │── validation_error.py - 验证错误处理方法 142 | │ ├── routes/ - web routes 路由. 143 | │ │ ├── api.py - 总路由接口 144 | │ │ └── authentication.py - 认证相关(登录、注册)路由 145 | ├── apps/ - 子应用. 146 | │ ├── app_user/ - user 应用. 147 | │ │ ├── api.py - 提供 user 接口方法 148 | │ │ ├── doc.py - 提供 user Swagger UI 文档 149 | │ │ ├── field.py - 提供 user pydantic 验证字段 150 | │ │ ├── model.py - 提供 user 表模型 151 | │ │ └── schema.py - 提供 user pydantic 结构模型 152 | │ ├── ... - 其他应用. 153 | ├── core/ - 项目核心配置, 如: 配置文件, 事件句柄, 日志. 154 | │ ├── e/ - 错误处理包. 155 | │ │ ├── __init__.py 156 | │ │ ├── codes.py - 错误码定义 157 | │ │ └── messages.py - 错误消息定义 158 | │ ├── .env - 配置文件. 159 | │ ├── config.py - 解析配置文件, 用于其他文件读取配置. 160 | │ ├── events.py - 定义 fastapi 事件句柄. 161 | │ ├── logger.py - 定义项目日志方法. 162 | ├── db/ - 数据库相关. 163 | │ ├── base.py - 导入所有应用 model. 164 | │ ├── database.py - sqlalchemy 方法应用. 165 | │ ├── errors.py - 数据库相关错误异常. 166 | ├── lib/ - 自定义库 167 | │ ├── jwt.py - 用户认证 jwt 方法. 168 | │ ├── security.py - 加密相关方法. 169 | ├── logs/ - 日志文件目录. 170 | ├── middleware/ - 项目中间件. 171 | │ ├── logger.py - 请求日志处理. 172 | ├── models/ - sqlalchemy 基础模型相关 173 | │ ├── base.py - sqlalchemy declarative Base 表模型. 174 | │ └── mixins.py - mixin 抽象模型定义. 175 | ├── schemas/ - pydantic 结构模型相关. 176 | │ ├── base.py - pydantic 结构模型基础类. 177 | │ ├── jwt.py - jwt 相关结构模型. 178 | │ ├── response.py - 响应模型封装. 179 | ├── utils/ - 工具类. 180 | │ ├── dbmanager.py - 数据库管理服务. 181 | │ ├── docs.py - fastapi docs 文档自定义. 182 | ├── .flake8 - pep8 规范. 183 | ├── .pre-commit-config.yaml - pre-commit 配置文件. 184 | ├── alembic.ini - alembic 数据库迁移工具配置文件. 185 | ├── docker-compose.yaml - docker 配置. 186 | ├── Dockerfile - dockfile 文件. 187 | ├── .fastapi-builder.ini - fastapi-builder 配置文件. 188 | ├── LICENSE - 许可证信息. 189 | ├── main.py - fastapi application 创建和配置. 190 | ├── pyproject.toml - poetry 需求模块信息. 191 | ├── README.md - 项目说明文档. 192 | ├── requirements.txt - pip 需求模块信息. 193 | └── setup.cfg - pre-commit 配置文件. 194 | ``` 195 | 196 |
197 | 198 | ## ⚡ 特别感谢 199 | 200 | 项目配置生成及 questionary 内容基于项目: 201 | 202 | fastapi 项目基础框架参考: 203 | 204 |
205 | 206 | ## 🚩 许可证 207 | 208 | 项目根据麻省理工学院的许可条款授权. 209 | -------------------------------------------------------------------------------- /fastapi_builder/templates/project/{{ cookiecutter.folder_name }}/apps/app_user/schema.py: -------------------------------------------------------------------------------- 1 | from typing import List, Literal 2 | from fastapi import Query 3 | from pydantic import EmailStr, Field, field_validator 4 | 5 | from apps.app_user.field import UserFields 6 | from apps.app_user.model import User 7 | from models.base import get_model_fields_from_objects 8 | from schemas.base import BaseSchema, QuerySchema 9 | from schemas.response import PaginationResponse, StandardResponse 10 | 11 | 12 | # ======================>>>>>>>>>>>>>>>>>>>>>> user_register 13 | 14 | 15 | class UserRegisterRequest(BaseSchema): 16 | """用户注册 请求""" 17 | 18 | email: EmailStr = UserFields.email 19 | username: str = UserFields.username 20 | password: str = UserFields.password 21 | 22 | 23 | class UserRegisterResponse(BaseSchema): 24 | """用户注册 响应""" 25 | 26 | id: int = UserFields.id 27 | username: str = UserFields.username 28 | email: EmailStr = UserFields.email 29 | 30 | 31 | class UserRegisterResponseModel(StandardResponse): 32 | """用户注册 响应 Model""" 33 | 34 | data: UserRegisterResponse | None = None 35 | 36 | 37 | # ======================>>>>>>>>>>>>>>>>>>>>>> user_login 38 | 39 | 40 | class UserLoginRequest(BaseSchema): 41 | """登录 User 请求""" 42 | 43 | username: str = Field(..., min_length=6, max_length=12, description="用户名") 44 | password: str = Field(..., min_length=6, max_length=12, description="密码") 45 | 46 | 47 | class UserLoginResponse(BaseSchema): 48 | """登录 User 响应""" 49 | 50 | id: int = UserFields.id 51 | username: str = UserFields.username 52 | email: EmailStr = UserFields.email 53 | token_type: str = Field("bearer", description="token 类型") 54 | access_token: str = UserFields.token 55 | 56 | 57 | class UserLoginResponseModel(StandardResponse): 58 | """登录 User 响应 Model""" 59 | 60 | data: UserLoginResponse | None = None 61 | 62 | 63 | # ======================>>>>>>>>>>>>>>>>>>>>>> get_user_list 64 | 65 | 66 | class UserListQueryRequest(QuerySchema): 67 | """获取用户列表 查询 请求""" 68 | 69 | order_by: Literal["id", "created_at"] = Query( 70 | User.id.name, description="排序字段. eg: id created_at" 71 | ) 72 | 73 | @field_validator("order_by") 74 | def validate_order_by(cls, v: str) -> str: 75 | order_fields = get_model_fields_from_objects(User, [User.id, User.created_at]) 76 | if v not in order_fields: 77 | raise ValueError(f"order_by must be one of {order_fields}") 78 | return v 79 | 80 | 81 | class UserListResponse(BaseSchema): 82 | """获取用户列表 响应""" 83 | 84 | id: int = UserFields.id 85 | username: str = UserFields.username 86 | email: EmailStr = UserFields.email 87 | 88 | 89 | class UserListResponseModel(StandardResponse): 90 | """获取用户列表 响应 Model""" 91 | 92 | data: PaginationResponse[UserListResponse] 93 | 94 | 95 | # ======================>>>>>>>>>>>>>>>>>>>>>> create_user 96 | 97 | 98 | class UserCreateRequest(BaseSchema): 99 | """用户创建 请求""" 100 | 101 | email: EmailStr = UserFields.email 102 | username: str = UserFields.username 103 | password: str = UserFields.password 104 | 105 | 106 | class UserCreateResponse(BaseSchema): 107 | """用户创建 响应""" 108 | 109 | id: int = UserFields.id 110 | email: EmailStr = UserFields.email 111 | username: str = UserFields.username 112 | 113 | 114 | class UserCreateResponseModel(StandardResponse): 115 | """用户创建 响应 Model""" 116 | 117 | data: UserCreateResponse | None = None 118 | 119 | 120 | # ======================>>>>>>>>>>>>>>>>>>>>>> patch_users 121 | 122 | 123 | class UsersPatchRequest(BaseSchema): 124 | """更新用户信息 请求""" 125 | 126 | ids: List[int] = Field(..., description="用户 id 列表") 127 | avatar_url: str = Field(..., description="头像地址") 128 | 129 | 130 | class UsersPatchResponse(BaseSchema): 131 | """更新用户信息 响应""" 132 | 133 | ids: List[int] = Field(..., description="用户 id 列表") 134 | avatar_url: str = Field(..., description="头像地址") 135 | 136 | 137 | class UsersPatchResponseModel(StandardResponse): 138 | """更新用户信息 响应 Model""" 139 | 140 | data: UsersPatchResponse | None = None 141 | 142 | 143 | # ======================>>>>>>>>>>>>>>>>>>>>>> delete_users 144 | 145 | 146 | class UsersDeleteResponse(BaseSchema): 147 | """删除用户信息 响应""" 148 | 149 | ids: List[int] = Field(..., description="用户 id 列表") 150 | 151 | 152 | class UsersDeleteResponseModel(StandardResponse): 153 | """删除用户信息 响应 Model""" 154 | 155 | data: UsersDeleteResponse 156 | 157 | 158 | # ======================>>>>>>>>>>>>>>>>>>>>>> get_user_by_id 159 | 160 | 161 | class UserInfoResponse(BaseSchema): 162 | """获取用户信息 响应""" 163 | 164 | id: int = UserFields.id 165 | username: str = UserFields.username 166 | email: EmailStr = UserFields.email 167 | 168 | 169 | class UserInfoResponseModel(StandardResponse): 170 | """获取用户信息 响应 Model""" 171 | 172 | data: UserInfoResponse | None = None 173 | 174 | 175 | # ======================>>>>>>>>>>>>>>>>>>>>>> update_user_by_id 176 | 177 | 178 | class UserUpdateRequest(BaseSchema): 179 | """更新用户信息 请求""" 180 | 181 | username: str | None = Field(None, min_length=6, max_length=12, description="用户名") 182 | email: EmailStr | None = Field(None, max_length=32, description="邮箱") 183 | 184 | 185 | class UserUpdateResponse(BaseSchema): 186 | """更新用户信息 响应""" 187 | 188 | id: int = UserFields.id 189 | username: str = UserFields.username 190 | email: EmailStr = UserFields.email 191 | 192 | 193 | class UserUpdateResponseModel(StandardResponse): 194 | """更新用户信息 响应 Model""" 195 | 196 | data: UserUpdateResponse | None = None 197 | 198 | 199 | # ======================>>>>>>>>>>>>>>>>>>>>>> delete_user_by_id 200 | 201 | 202 | class UserDeleteResponse(BaseSchema): 203 | """删除用户信息 响应""" 204 | 205 | id: int = UserFields.id 206 | 207 | 208 | class UserDeleteResponseModel(StandardResponse): 209 | """删除用户信息 响应 Model""" 210 | 211 | data: UserDeleteResponse | None = None 212 | -------------------------------------------------------------------------------- /fastapi_builder/templates/app/app_{{ cookiecutter.folder_name }}/schema.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | from typing import List, Literal 3 | 4 | from fastapi import Query 5 | from pydantic import Field, field_validator 6 | from apps.app_{{ cookiecutter.snake_name }}.field import {{ cookiecutter.pascal_name }}Fields 7 | from apps.app_{{ cookiecutter.snake_name }}.model import {{ cookiecutter.pascal_name }} 8 | from models.base import get_model_fields_from_objects 9 | from schemas.base import BaseSchema, QuerySchema 10 | from schemas.response import PaginationResponse, StandardResponse 11 | 12 | 13 | # ======================>>>>>>>>>>>>>>>>>>>>>> get_it_demos 14 | 15 | 16 | class {{ cookiecutter.pascal_name }}ListQueryRequest(QuerySchema): 17 | """获取 {{ cookiecutter.snake_name }} 列表 查询 请求""" 18 | 19 | order_by: Literal["id", "created_at"] = Query( 20 | {{ cookiecutter.pascal_name }}.id.name, description="排序字段. eg: id created_at" 21 | ) 22 | 23 | @field_validator("order_by") 24 | def validate_order_by(cls, v: str) -> str: 25 | order_fields = get_model_fields_from_objects({{ cookiecutter.pascal_name }}, [{{ cookiecutter.pascal_name }}.id, {{ cookiecutter.pascal_name }}.created_at]) 26 | if v not in order_fields: 27 | raise ValueError(f"order_by: {v} not in {order_fields}") 28 | return v 29 | 30 | 31 | class {{ cookiecutter.pascal_name }}ListResponse(BaseSchema): 32 | """获取 {{ cookiecutter.snake_name }} 列表 响应""" 33 | 34 | id: int = {{ cookiecutter.pascal_name }}Fields.id 35 | name: str = {{ cookiecutter.pascal_name }}Fields.name 36 | 37 | 38 | class {{ cookiecutter.pascal_name }}ListResponseModel(StandardResponse): 39 | """获取 {{ cookiecutter.snake_name }} 列表 响应 Model""" 40 | 41 | data: PaginationResponse[{{ cookiecutter.pascal_name }}ListResponse] 42 | 43 | 44 | # ======================>>>>>>>>>>>>>>>>>>>>>> create_{{ cookiecutter.snake_name }} 45 | 46 | 47 | class {{ cookiecutter.pascal_name }}CreateRequest(BaseSchema): 48 | """创建 {{ cookiecutter.snake_name }} 请求""" 49 | 50 | name: str = {{ cookiecutter.pascal_name }}Fields.name 51 | 52 | 53 | class {{ cookiecutter.pascal_name }}CreateResponse(BaseSchema): 54 | """创建 {{ cookiecutter.snake_name }} 响应""" 55 | 56 | id: int = {{ cookiecutter.pascal_name }}Fields.id 57 | name: str = {{ cookiecutter.pascal_name }}Fields.name 58 | 59 | 60 | class {{ cookiecutter.pascal_name }}CreateResponseModel(StandardResponse): 61 | """创建 {{ cookiecutter.snake_name }} 响应 Model""" 62 | 63 | data: {{ cookiecutter.pascal_name }}CreateResponse | None = None 64 | 65 | 66 | # ======================>>>>>>>>>>>>>>>>>>>>>> patch_{{ cookiecutter.snake_name }}s 67 | 68 | 69 | class {{ cookiecutter.pascal_name }}sPatchRequest(BaseSchema): 70 | """批量更新 {{ cookiecutter.snake_name }} 请求""" 71 | 72 | ids: List[int] = Field(..., description="{{ cookiecutter.snake_name }} id 列表") 73 | name: str = {{ cookiecutter.pascal_name }}Fields.name 74 | 75 | 76 | class {{ cookiecutter.pascal_name }}sPatchResponse(BaseSchema): 77 | """批量更新 {{ cookiecutter.snake_name }} 响应""" 78 | 79 | ids: List[int] = Field(..., description="{{ cookiecutter.snake_name }} id 列表") 80 | name: str = {{ cookiecutter.pascal_name }}Fields.name 81 | 82 | 83 | class {{ cookiecutter.pascal_name }}sPatchResponseModel(StandardResponse): 84 | """批量更新 {{ cookiecutter.snake_name }} 响应 Model""" 85 | 86 | data: {{ cookiecutter.pascal_name }}sPatchResponse | None = None 87 | 88 | 89 | # ======================>>>>>>>>>>>>>>>>>>>>>> delete_{{ cookiecutter.snake_name }}s 90 | 91 | 92 | class {{ cookiecutter.pascal_name }}sDeleteResponse(BaseSchema): 93 | """批量删除 {{ cookiecutter.snake_name }} 响应""" 94 | 95 | ids: List[int] = Field(..., description="{{ cookiecutter.snake_name }} id 列表") 96 | 97 | 98 | class {{ cookiecutter.pascal_name }}sDeleteResponseModel(StandardResponse): 99 | """批量删除 {{ cookiecutter.snake_name }} 响应 Model""" 100 | 101 | data: {{ cookiecutter.pascal_name }}sDeleteResponse 102 | 103 | 104 | # ======================>>>>>>>>>>>>>>>>>>>>>> get_{{ cookiecutter.snake_name }}_by_id 105 | 106 | 107 | class {{ cookiecutter.pascal_name }}InfoResponse(BaseSchema): 108 | """获取 {{ cookiecutter.snake_name }} by id 响应""" 109 | 110 | id: int = {{ cookiecutter.pascal_name }}Fields.id 111 | name: str = {{ cookiecutter.pascal_name }}Fields.name 112 | created_at: datetime.datetime = {{ cookiecutter.pascal_name }}Fields.created_at 113 | updated_at: datetime.datetime = {{ cookiecutter.pascal_name }}Fields.updated_at 114 | 115 | 116 | class {{ cookiecutter.pascal_name }}InfoResponseModel(StandardResponse): 117 | """获取 {{ cookiecutter.snake_name }} by id 响应 Model""" 118 | 119 | data: {{ cookiecutter.pascal_name }}InfoResponse | None = None 120 | 121 | 122 | # ======================>>>>>>>>>>>>>>>>>>>>>> update_{{ cookiecutter.snake_name }}_by_id 123 | 124 | 125 | class {{ cookiecutter.pascal_name }}UpdateRequest(BaseSchema): 126 | """更新 {{ cookiecutter.snake_name }} by id 请求""" 127 | 128 | name: str = {{ cookiecutter.pascal_name }}Fields.name 129 | 130 | 131 | class {{ cookiecutter.pascal_name }}UpdateResponse(BaseSchema): 132 | """更新 {{ cookiecutter.snake_name }} by id 响应""" 133 | 134 | id: int = {{ cookiecutter.pascal_name }}Fields.id 135 | name: str = {{ cookiecutter.pascal_name }}Fields.name 136 | 137 | 138 | class {{ cookiecutter.pascal_name }}UpdateResponseModel(StandardResponse): 139 | """更新 {{ cookiecutter.snake_name }} by id 响应 Model""" 140 | 141 | data: {{ cookiecutter.pascal_name }}UpdateResponse | None = None 142 | 143 | 144 | # ======================>>>>>>>>>>>>>>>>>>>>>> delete_{{ cookiecutter.snake_name }}_by_id 145 | 146 | 147 | class {{ cookiecutter.pascal_name }}DeleteResponse(BaseSchema): 148 | """删除 {{ cookiecutter.snake_name }} by id 响应""" 149 | 150 | id: int = {{ cookiecutter.pascal_name }}Fields.id 151 | 152 | 153 | class {{ cookiecutter.pascal_name }}DeleteResponseModel(StandardResponse): 154 | """删除 {{ cookiecutter.snake_name }} by id 响应 Model""" 155 | 156 | data: {{ cookiecutter.pascal_name }}DeleteResponse | None = None 157 | -------------------------------------------------------------------------------- /fastapi_builder/templates/app/app_{{ cookiecutter.folder_name }}/doc.py: -------------------------------------------------------------------------------- 1 | from starlette.status import ( 2 | HTTP_200_OK, 3 | HTTP_404_NOT_FOUND, 4 | ) 5 | 6 | from core.e import ErrorCode, ErrorMessage 7 | from schemas.response import StandardResponse 8 | 9 | # common 10 | 11 | NOT_FOUND = { 12 | "description": "{{ cookiecutter.snake_name }} 不存在.", 13 | "model": StandardResponse, 14 | "content": { 15 | "application/json": { 16 | "example": { 17 | "code": ErrorCode.NOT_FOUND, 18 | "message": ErrorMessage.get(ErrorCode.NOT_FOUND), 19 | } 20 | } 21 | }, 22 | } 23 | 24 | # ======================>>>>>>>>>>>>>>>>>>>>>> get_{{ cookiecutter.snake_name }} 25 | 26 | get_{{ cookiecutter.snake_name }}s_responses = { 27 | HTTP_200_OK: { 28 | "description": "获取 {{ cookiecutter.snake_name }} 列表成功", 29 | "content": { 30 | "application/json": { 31 | "example": { 32 | "code": 0, 33 | "data": { 34 | "list": [ 35 | { 36 | "id": 1, 37 | "name": "test1", 38 | }, 39 | { 40 | "id": 2, 41 | "name": "test02", 42 | } 43 | ], 44 | "count": 2, 45 | "total": 5, 46 | "page": 1, 47 | "size": 2 48 | }, 49 | "message": "", 50 | } 51 | } 52 | }, 53 | } 54 | } 55 | 56 | # ======================>>>>>>>>>>>>>>>>>>>>>> create_{{ cookiecutter.snake_name }} 57 | 58 | create_{{ cookiecutter.snake_name }}_request = { 59 | "创建 {{ cookiecutter.snake_name }}": { 60 | "description": "创建时需要输入 **name**.", 61 | "value": { 62 | "name": "new_name", 63 | } 64 | } 65 | } 66 | 67 | create_{{ cookiecutter.snake_name }}_responses = { 68 | HTTP_200_OK: { 69 | "description": "创建 {{ cookiecutter.snake_name }} 成功", 70 | "content": { 71 | "application/json": { 72 | "example": { 73 | "code": 0, 74 | "data": { 75 | "id": 1, 76 | "name": "new_name", 77 | }, 78 | "message": "", 79 | } 80 | } 81 | } 82 | } 83 | } 84 | 85 | # ======================>>>>>>>>>>>>>>>>>>>>>> patch_{{ cookiecutter.snake_name }}s 86 | 87 | patch_{{ cookiecutter.snake_name }}s_responses = { 88 | HTTP_200_OK: { 89 | "description": "批量更新 {{ cookiecutter.snake_name }} 成功", 90 | "content": { 91 | "application/json": { 92 | "example": { 93 | "code": 0, 94 | "data": { 95 | "ids": [1, 2], 96 | "name": "new_name", 97 | }, 98 | "message": "", 99 | } 100 | } 101 | } 102 | } 103 | } 104 | 105 | patch_{{ cookiecutter.snake_name }}s_request = { 106 | "批量更新 {{ cookiecutter.snake_name }} name": { 107 | "description": "批量更新 {{ cookiecutter.snake_name }},返回更新成功的 {{ cookiecutter.snake_name }} id 和更新条目", 108 | "value": { 109 | "ids": [1, 2, 3], 110 | "name": "new_name", 111 | }, 112 | } 113 | } 114 | 115 | # ======================>>>>>>>>>>>>>>>>>>>>>> delete_{{ cookiecutter.snake_name }}s 116 | 117 | delete_{{ cookiecutter.snake_name }}s_responses = { 118 | HTTP_200_OK: { 119 | "description": "批量删除 {{ cookiecutter.snake_name }} 成功", 120 | "content": { 121 | "application/json": { 122 | "example": { 123 | "code": 0, 124 | "data": { 125 | "ids": [1, 2], 126 | }, 127 | "message": "", 128 | } 129 | } 130 | } 131 | } 132 | } 133 | 134 | delete_{{ cookiecutter.snake_name }}s_request = { 135 | "example": [1, 2, 3], 136 | } 137 | 138 | # ======================>>>>>>>>>>>>>>>>>>>>>> get_{{ cookiecutter.snake_name }}_by_id 139 | 140 | get_{{ cookiecutter.snake_name }}_by_id_responses = { 141 | HTTP_200_OK: { 142 | "description": "获取 {{ cookiecutter.snake_name }} 信息成功.", 143 | "content": { 144 | "application/json": { 145 | "example": { 146 | "code": 0, 147 | "message": "", 148 | "data": { 149 | "id": 1, 150 | "name": "{{ cookiecutter.snake_name }}", 151 | "created_at": "2023-07-03 08:03:03", 152 | "updated_at": "2023-07-03 08:03:03", 153 | }, 154 | } 155 | } 156 | }, 157 | }, 158 | HTTP_404_NOT_FOUND: NOT_FOUND, 159 | } 160 | 161 | # ======================>>>>>>>>>>>>>>>>>>>>>> update_{{ cookiecutter.snake_name }}_by_id 162 | 163 | update_{{ cookiecutter.snake_name }}_by_id_responses = { 164 | HTTP_200_OK: { 165 | "description": "更改 {{ cookiecutter.snake_name }} 成功", 166 | "content": { 167 | "application/json": { 168 | "example": { 169 | "code": 0, 170 | "data": { 171 | "id": 1, 172 | "name": "new_name", 173 | }, 174 | "message": "", 175 | }, 176 | }, 177 | }, 178 | }, 179 | HTTP_404_NOT_FOUND: NOT_FOUND, 180 | } 181 | 182 | update_{{ cookiecutter.snake_name }}_by_id_request = { 183 | "更新 name": { 184 | "description": "设置 `name` 为新值.", 185 | "value": { 186 | "name": "new_name", 187 | }, 188 | }, 189 | } 190 | 191 | # ======================>>>>>>>>>>>>>>>>>>>>>> delete_{{ cookiecutter.snake_name }}_by_id 192 | 193 | delete_{{ cookiecutter.snake_name }}_by_id_responses = { 194 | HTTP_200_OK: { 195 | "description": "注销 {{ cookiecutter.snake_name }} 成功", 196 | "content": { 197 | "application/json": { 198 | "example": { 199 | "code": 0, 200 | "data": { 201 | "id": 1, 202 | }, 203 | "message": "" 204 | }, 205 | }, 206 | }, 207 | }, 208 | HTTP_404_NOT_FOUND: NOT_FOUND, 209 | } 210 | -------------------------------------------------------------------------------- /fastapi_builder/templates/project/{{ cookiecutter.folder_name }}/utils/dbmanager.py: -------------------------------------------------------------------------------- 1 | import argparse 2 | import sys 3 | import os 4 | 5 | import pymysql 6 | 7 | from typing import Dict 8 | 9 | from pymysql import Connection 10 | from pymysql.cursors import Cursor 11 | 12 | file_path = os.path.abspath(__file__) 13 | proj_path = os.path.abspath(os.path.join(file_path, "..", "..")) 14 | sys.path.insert(0, proj_path) 15 | 16 | from core.config import settings # noqa 17 | from core.logger import app_logger as logger # noqa 18 | 19 | 20 | class DBManager(object): 21 | """ 22 | 提供系统 Mysql 服务: 23 | mysql 服务检查、数据库检查、迁移文件检查、表生成 24 | """ 25 | 26 | _dbname = settings.DATABASE_URL.database 27 | _host = settings.DATABASE_URL.hostname 28 | _port = settings.DATABASE_URL.port 29 | _user = settings.DATABASE_URL.username 30 | _password = settings.DATABASE_URL.password 31 | 32 | @classmethod 33 | def get_conn(cls) -> Connection | None: 34 | # 连接 mysql 服务,并获取 connection 35 | logger.info(f'mysql 连接信息:"{cls._dbname}"') 36 | try: 37 | conn: Connection = pymysql.connect( 38 | host=cls._host, 39 | port=cls._port, 40 | user=cls._user, 41 | password=cls._password, 42 | charset=settings.DB_CHARSET, 43 | ) 44 | logger.info("数据库连接成功!") 45 | 46 | except Exception as e: 47 | # 打印错误信息 48 | error_reason: str = "" 49 | # (2003, "Can't connect to MySQL server on '127.0.0.1' 50 | # ([WinError 10061] 由于目标计算机积极拒绝,无法连接。)") 51 | if str(e).startswith("(2003"): 52 | error_reason = "mysql 无法连接,请检查 mysql 服务是否正常启动." 53 | # (1045, "Access denied for user 'root'@'localhost' 54 | # (using password: YES)") 55 | elif str(e).startswith("(1045"): 56 | error_reason = "mysql 拒绝访问,请检查参数是否有误." 57 | else: 58 | error_reason = "原因不详." 59 | logger.error(f"数据库连接失败! 原因:{error_reason}\n系统日志:{e}") 60 | conn = None 61 | 62 | return conn 63 | 64 | @classmethod 65 | def make_and_run_migrate(cls): 66 | # 执行迁移文件 67 | logger.info("执行迁移文件...") 68 | os.chdir(proj_path) 69 | os.system("alembic revision --autogenerate -m \"autocreate migration\"") 70 | os.system("alembic upgrade heads") 71 | logger.info("执行迁移文件完成.") 72 | 73 | @classmethod 74 | def run_migrate(cls): 75 | # 执行迁移文件 76 | logger.info("执行迁移文件...") 77 | os.chdir(proj_path) 78 | # os.system('alembic revision --autogenerate -m "autocreate migration"') 79 | os.system("alembic upgrade heads") 80 | logger.info("执行迁移文件完成.") 81 | 82 | @classmethod 83 | def check_and_autocreate(cls) -> Dict[str, str]: 84 | # 检查 mysql 服务、数据库及表是否存在 85 | logger.info("检查 mysql 服务、数据库及表.") 86 | 87 | # 建立游标 88 | conn = cls.get_conn() 89 | if not conn: 90 | return {"code": -1, "msg": "获取连接失败."} 91 | 92 | cursor: Cursor = conn.cursor() 93 | 94 | # 检查数据库是否存在 95 | if cursor.execute(f"show databases like '{cls._dbname}';"): 96 | logger.info(f"数据库:{cls._dbname} 已存在.") 97 | else: 98 | # 数据库不存在,创建数据库 99 | logger.info(f"数据库:{cls._dbname} 不存在. 正在自动创建...") 100 | cursor.execute( 101 | f"create database if not exists `{cls._dbname}` default charset utf8mb4;" 102 | ) 103 | logger.info(f"数据库:{cls._dbname} 创建完成.") 104 | 105 | # 检查数据库表是否存在 106 | cls.run_migrate() 107 | 108 | logger.info("检查完成,已正确配置数据库及相关表!") 109 | 110 | return {"code": 0, "msg": "执行完成!"} 111 | 112 | @classmethod 113 | def reset_database(cls) -> Dict[str, str]: 114 | # 重置 mysql 数据库及数据库内所有表 115 | 116 | # 建立游标 117 | conn = cls.get_conn() 118 | if not conn: 119 | return {"code": -1, "msg": "获取连接失败."} 120 | 121 | cursor: Cursor = conn.cursor() 122 | 123 | # 检查数据库是否存在 124 | if cursor.execute(f"show databases like '{cls._dbname}';"): 125 | logger.info(f"数据库:{cls._dbname} 已存在. 正在自动删除...") 126 | cursor.execute(f"drop database if exists {cls._dbname};") 127 | logger.info(f"数据库:{cls._dbname} 删除完成. 正在重新创建...") 128 | else: 129 | # 数据库不存在,创建数据库 130 | logger.info(f"数据库:{cls._dbname} 不存在. 正在自动创建...") 131 | 132 | cursor.execute( 133 | f"create database if not exists {cls._dbname} " "default charset utf8mb4;" 134 | ) 135 | logger.info(f"数据库:{cls._dbname} 创建完成.") 136 | 137 | # 检查数据库表是否存在 138 | cls.run_migrate() 139 | 140 | logger.info("重置完成,已正确配置数据库及相关表!") 141 | 142 | return {"code": 0, "msg": "执行完成!"} 143 | 144 | @classmethod 145 | def remove_database(cls) -> Dict[str, str]: 146 | # 删除数据库 147 | # 建立游标 148 | conn = cls.get_conn() 149 | if not conn: 150 | return {"code": -1, "msg": "获取连接失败."} 151 | 152 | cursor: Cursor = conn.cursor() 153 | 154 | if cursor.execute(f"show databases like '{cls._dbname}';"): 155 | logger.info(f"数据库:{cls._dbname} 已存在. 正在自动删除...") 156 | cursor.execute(f"drop database if exists `{cls._dbname}`;") 157 | logger.info(f"数据库:{cls._dbname} 删除完成.") 158 | else: 159 | # 数据库不存在,创建数据库 160 | logger.info(f"数据库:{cls._dbname} 不存在.") 161 | 162 | return {"code": 0, "msg": "执行完成!"} 163 | 164 | 165 | __all__ = ["DBManager"] 166 | 167 | 168 | if __name__ == "__main__": 169 | parser = argparse.ArgumentParser(description="Manage the database operations.") 170 | parser.add_argument( 171 | "-c", 172 | "--create", 173 | action="store_true", 174 | help="Check and auto-create database if not exist", 175 | ) 176 | parser.add_argument("-r", "--reset", action="store_true", help="Reset the database") 177 | parser.add_argument( 178 | "-d", "--delete", action="store_true", help="Delete the database" 179 | ) 180 | parser.add_argument( 181 | "-m", "--migrate", action="store_true", help="Run alembic migration" 182 | ) 183 | 184 | args = parser.parse_args() 185 | 186 | try: 187 | if args.create: 188 | ret = DBManager.check_and_autocreate() 189 | elif args.reset: 190 | ret = DBManager.reset_database() 191 | elif args.delete: 192 | ret = DBManager.remove_database() 193 | elif args.migrate: 194 | ret = DBManager.make_and_run_migrate() 195 | else: 196 | ret = "No command specified. Use -h for help." 197 | except Exception as e: 198 | ret = f"Error occurred: {str(e)}" 199 | 200 | print(ret) 201 | -------------------------------------------------------------------------------- /fastapi_builder/templates/project/{{ cookiecutter.folder_name }}/README_EN.md: -------------------------------------------------------------------------------- 1 | # 「 {{ cookiecutter.name }} 」 2 | 3 | 6 | 7 |
8 | 9 | > 💡 Help you quickly build fastapi projects. 10 | 11 | + ***[Quick Start](#-Quick-Start)*** 12 | 13 | + ***[Project Structure](#-Project-Structure)*** 14 | 15 | + ***[Functional Examples](#-Functional-Examples)*** 16 | 17 |
18 | 19 |
20 | 21 |
22 | 23 | ## 🚀 Quick Start 24 | 25 | > *We highly recommend you to install and use fastapi-builder tool.*
26 | > After the project is started, enter the address http://127.0.0.1:8000/docs in the browser to access the swagger-ui document. 27 | 28 | ### ⭐ Method 1: Use fastapi-builder tool 29 | 30 | + Quick start the project: `fastapi run` 31 | + Check project configuration: `fastapi run --check` 32 | + Quickly configure the project: `fastapi run --config` 33 | 34 | *If you have not used fastapi-builder, try to manually complete the steps in method 2.* 35 | 36 | ### Method 2: Configure and Start the Project Manually 37 | 38 | **1. Modify project configuration** 39 | 40 | > To run this project, configuration information should be your first concern. 41 | 42 | ```js 43 | project 44 | ├── core/ 45 | │ ├── .env // Overall project configuration 46 | ├── alembic.ini // Database migration configuration 47 | ``` 48 | 49 | ```s 50 | # core/.env 51 | DB_CONNECTION=mysql+pymysql://username:password@127.0.0.1:3306/dbname 52 | SECRET_KEY=OauIrgmfnwCdxMBWpzPF7vfNzga1JVoiJi0hqz3fzkY 53 | 54 | 55 | # alembic.ini 56 | ... 57 | # line 53, the same value as DB_CONNECTION in .env file 58 | sqlalchemy.url = mysql+pymysql://root:admin@localhost/dbname 59 | ``` 60 | 61 | * (When you start reading the [server/core/config.py](#no-reply) file, you can start writing more related configurations)* 62 | 63 | **2. Activate the Database** 64 | 65 | Finally, you need to start the mysql service correctly in the environment, create a database, and execute the migration file to complete the creation of tables in the database.
66 | Fortunately, we have considered this as much as possible for you. You only need to correctly start the mysql service and execute it in [app/utils/](#no-reply): 67 | 68 | ```sh 69 | project\utils> python dbmanager.py 70 | ``` 71 | 72 | **3. Run the project** 73 | 74 | ```sh 75 | project> python main.py 76 | ``` 77 | 78 |
79 | 80 | ## 📌 Project Structure 81 | 82 | ```js 83 | project 84 | ├── alembic/ - Database migration tool 85 | │ ├── versions/ 86 | │ ├── env.py 87 | │ ├── README 88 | │ ├── script.py.mako 89 | ├── api/ - Web-related (routes, authentication, requests, responses). 90 | │ ├── errors/ - Defines error handling methods. 91 | │ │ ├── http_error.py - HTTP error handling method. 92 | │ │ ├── validation_error.py - Validation error handling method. 93 | │ ├── routes/ - Web routes. 94 | │ │ ├── api.py - Main route interface. 95 | │ │ └── authentication.py - Authentication-related (login, registration) routes. 96 | ├── app_user/ - User application. 97 | │ ├── api.py - Provides user interface methods. 98 | │ ├── model.py - Provides user table model. 99 | │ ├── schema.py - Provides user structure model. 100 | ├── core/ - Project core configuration, such as: configuration files, event handlers, logging. 101 | │ ├── .env - Configuration file. 102 | │ ├── config.py - Parses the configuration file for other files to read the configuration. 103 | │ ├── events.py - Defines fastapi event handlers. 104 | │ ├── logger.py - Defines project logging methods. 105 | ├── db/ - Database related. 106 | │ ├── base.py - Imports all application models. 107 | │ ├── database.py - sqlalchemy method application. 108 | │ ├── errors.py - Database-related error exceptions. 109 | │ ├── events.py - Database-related event handlers. 110 | ├── lib/ - Custom library. 111 | │ ├── jwt.py - User authentication jwt method. 112 | │ ├── security.py - Encryption-related methods. 113 | ├── logs/ - Directory for log files. 114 | ├── middleware/ - Project middleware. 115 | │ ├── logger.py - Request log processing. 116 | ├── models/ - sqlalchemy basic model related. 117 | │ ├── base.py - sqlalchemy declarative Base table model. 118 | │ └── mixins.py - mixin abstract model definition. 119 | ├── schemas/ - pydantic structure model related. 120 | │ ├── auth.py - User authentication-related structure model. 121 | │ └── base.py - pydantic structure model base class. 122 | │ ├── jwt.py - jwt related structure model. 123 | ├── utils/ - Utility classes. 124 | │ ├── consts.py - Project constant definition. 125 | │ ├── dbmanager.py - Database management service. 126 | │ ├── docs.py - Custom fastapi docs documentation. 127 | {% if cookiecutter.pre_commit == "True" -%} 128 | ├── .pre-commit-config.yaml - Pre-commit configuration file. 129 | {% else -%} 130 | 131 | {%- endif -%} 132 | ├── alembic.ini - alembic database migration tool configuration file. 133 | {% if cookiecutter.docker == "True" -%} 134 | ├── docker-compose.yaml - Docker configuration. 135 | ├── Dockerfile - Dockerfile. 136 | {% else -%} 137 | 138 | {%- endif -%} 139 | ├── .fastapi-builder.ini - fastapi-builder configuration file. 140 | {% if cookiecutter.license -%} 141 | ├── LICENSE - License information. 142 | {% else -%} 143 | 144 | {%- endif -%} 145 | ├── main.py - fastapi application creation and configuration. 146 | {% if cookiecutter.packaging == "poetry" -%} 147 | ├── pyproject.toml - Poetry requirement module information. 148 | {% else -%} 149 | 150 | {%- endif -%} 151 | ├── README.md - Project description document. 152 | {% if cookiecutter.packaging == "pip" -%} 153 | ├── requirements.txt - Pip requirement module information. 154 | {% else -%} 155 | 156 | {%- endif -%} 157 | {% if cookiecutter.pre_commit == "True" -%} 158 | ├── setup.cfg - Pre-commit configuration file. 159 | {% else -%} 160 | 161 | {%- endif -%} 162 | ``` 163 | 164 |
165 | 166 | ## 💬 Functional Examples 167 | 168 | For details, see the Swagger docs after starting the project. 169 | 170 |
171 | 172 | ## License 173 | 174 | This project is licensed under the terms of the {{ cookiecutter.license }} license. 175 | -------------------------------------------------------------------------------- /fastapi_builder/main.py: -------------------------------------------------------------------------------- 1 | import os 2 | import subprocess 3 | import platform 4 | import pkg_resources 5 | import typer 6 | 7 | from typing import Optional 8 | 9 | from fastapi_builder.constants import ( 10 | Database, 11 | Language, 12 | License, 13 | PackageManager, 14 | PythonVersion, 15 | DBCmd, 16 | VenvCmd, 17 | ) 18 | from fastapi_builder.context import AppContext, ProjectContext 19 | from fastapi_builder.generator import generate_app, generate_project 20 | from fastapi_builder.helpers import ( 21 | binary_question, 22 | question, 23 | text_question, 24 | ) 25 | from fastapi_builder.utils import ( 26 | check_env, 27 | read_conf, 28 | config_app, 29 | set_config_file_content, 30 | ) 31 | from questionary.form import form 32 | 33 | import sys 34 | import io 35 | 36 | # 强制设置标准输出为 UTF-8 编码 37 | sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding="utf-8") 38 | 39 | 40 | app = typer.Typer( 41 | add_completion=False, 42 | help="FastAPI-Builder make fastapi projects easy!", 43 | name="FastAPI-Builder", 44 | ) 45 | 46 | 47 | @app.command(help="Create a FastAPI project.") 48 | def startproject( 49 | name: str, 50 | interactive: bool = typer.Option(False, help="Run in interactive mode."), 51 | language: Optional[Language] = typer.Option("cn", case_sensitive=False), 52 | database: Optional[Database] = typer.Option("MySQL", case_sensitive=False), 53 | database_name: Optional[str] = typer.Option(None, "--dbname"), 54 | docker: bool = typer.Option(False), 55 | license_: Optional[License] = typer.Option(None, "--license", case_sensitive=False), 56 | packaging: PackageManager = typer.Option(PackageManager.PIP), 57 | pre_commit: bool = typer.Option(False, "--pre-commit"), 58 | python: PythonVersion = typer.Option(PythonVersion.THREE_DOT_TEN), 59 | ): 60 | if interactive: 61 | result = form( 62 | language=question(Language), 63 | packaging=question(PackageManager), 64 | python=question(PythonVersion), 65 | license=question(License), 66 | pre_commit=binary_question("pre commit"), 67 | docker=binary_question("docker"), 68 | database=question(Database), 69 | database_name=text_question(name), 70 | ).ask() 71 | context = ProjectContext(name=name, **result) 72 | else: 73 | database_name = database_name if database_name else name 74 | context = ProjectContext( 75 | name=name, 76 | language=language, 77 | packaging=packaging, 78 | python=python, 79 | license=license_, 80 | pre_commit=pre_commit, 81 | docker=docker, 82 | database=database, 83 | database_name=database_name, 84 | ) 85 | generate_project(context) 86 | 87 | 88 | @app.command(help="Create a FastAPI app.") 89 | def startapp( 90 | name: str, force: bool = typer.Option(False, help="Create a FastAPI app by force.") 91 | ): 92 | # force=False 时,app 必须生成在 project 项目下 93 | if not (force or "fastapi-builder.ini" in os.listdir()): 94 | typer.echo("\n❌ FastAPI app must be created under project root folder!") 95 | return 96 | 97 | output_dir = "." if force else "./apps" 98 | 99 | # 尝试从配置文件读取 language 信息,使用 try 是因为 force 条件下,不一定存在配置信息 100 | try: 101 | conf = read_conf("fastapi-builder.ini") 102 | language = conf.get("fastapi_builder", "language") or "cn" 103 | except Exception: 104 | language = "cn" 105 | 106 | context = AppContext( 107 | name=name, 108 | language=language, 109 | ) 110 | 111 | generate_app(context, output_dir) 112 | 113 | 114 | @app.command(help="Run a FastAPI application.") 115 | def run( 116 | prod: bool = typer.Option(False), 117 | check: bool = typer.Option(False, help="Check required run environment."), 118 | config: bool = typer.Option(False, help="Configuring startup resources."), 119 | ): 120 | # 命令必须运行在 project 项目下 121 | if "fastapi-builder.ini" not in os.listdir(): 122 | typer.echo("\nFastAPI app must run under project root folder!") 123 | return 124 | 125 | # 获取配置文件 conf 126 | conf = read_conf("fastapi-builder.ini") 127 | 128 | # 运行环境配置 129 | if config: 130 | config_app(conf) 131 | return 132 | 133 | # 运行环境检查 134 | if check: 135 | check_env() 136 | return 137 | 138 | # 如果是第一次启动项目,需要进行环境配置 139 | if conf.get("fastapi_builder", "first_launch") == "true": 140 | set_config_file_content("fastapi-builder.ini", "first_launch", "false") 141 | config_app(conf) 142 | 143 | args = [] 144 | if not prod: 145 | args.append("--reload") 146 | app_file = os.getenv("FASTAPI_APP", "main") 147 | subprocess.call(["uvicorn", f"{app_file}:app", *args]) 148 | 149 | 150 | @app.command(help="Database migration manager.") 151 | def db( 152 | cmd: DBCmd, 153 | migration_message: Optional[str] = typer.Option( 154 | "create migration", "-m", help="migration message" 155 | ), 156 | ): 157 | # 命令必须运行在 project 项目下 158 | if "fastapi-builder.ini" not in os.listdir(): 159 | typer.echo("\n`fastapi db` command must run under project root folder!") 160 | return 161 | 162 | # 检查 alembic 是否安装 163 | try: 164 | subprocess.call(["alembic", "--version"]) 165 | except Exception: 166 | typer.echo("\nPlease install alembic correctly first!") 167 | return 168 | 169 | if cmd == DBCmd.MAKEMIGRATIONS: 170 | subprocess.call( 171 | ["alembic", "revision", "--autogenerate", "-m", migration_message] 172 | ) 173 | elif cmd == DBCmd.MIGRATE: 174 | subprocess.call(["alembic", "upgrade", "head"]) 175 | 176 | 177 | @app.command(help="Virtual environment manager.") 178 | def venv( 179 | cmd: VenvCmd, 180 | name: Optional[str] = typer.Option(None, "--name"), 181 | ): 182 | def _exec_venv_cmd(filename: str, activate: bool = True) -> bool: 183 | cmd = "activate" if activate else "deactivate" 184 | platform_cmd = { 185 | "Windows": f".\\{filename}\\Scripts\\{cmd}", 186 | "Linux": f"source ./{filename}/bin/{cmd}", 187 | } 188 | return os.system(platform_cmd[platform.system()]) == 0 189 | 190 | if cmd == VenvCmd.CREATE: 191 | name = name if name is not None else "venv" 192 | if name in os.listdir(): 193 | typer.echo(f"\nVirtual environment {name} already exists.") 194 | return 195 | subprocess.call(["python", "-m", "venv", name]) 196 | typer.echo(f"\nVirtual environment {name} created successfully!") 197 | return 198 | 199 | # cmd is ON or OFF 200 | for fname in os.listdir(): 201 | if "env" not in fname: 202 | continue 203 | if _exec_venv_cmd(filename=fname, activate=cmd == VenvCmd.ON): 204 | typer.echo(f"\nVirtual environment {fname} {cmd} successfully!") 205 | return 206 | typer.echo(f"\nVirtual environment {cmd} failed!") 207 | 208 | 209 | def version_callback(value: bool): 210 | if value: 211 | version = pkg_resources.get_distribution("fastapi-builder").version 212 | typer.echo(f"fastapi-builder, version {version}") 213 | raise typer.Exit() 214 | 215 | 216 | @app.callback() 217 | def main( 218 | version: bool = typer.Option( 219 | None, 220 | "--version", 221 | callback=version_callback, 222 | is_eager=True, 223 | help="Show the FastAPI-Builder version information.", 224 | ) 225 | ): ... 226 | -------------------------------------------------------------------------------- /fastapi_builder/templates/project/{{ cookiecutter.folder_name }}/apps/app_user/api.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime 2 | from typing import List 3 | 4 | from fastapi import APIRouter, Body, Depends, Path 5 | from sqlalchemy import func, select, update 6 | from sqlalchemy.ext.asyncio import AsyncSession 7 | from starlette.status import HTTP_400_BAD_REQUEST, HTTP_404_NOT_FOUND 8 | 9 | from apps.app_user import doc, model, schema 10 | from core.e import ErrorCode, ErrorMessage 11 | from db.database import get_async_db 12 | from lib.jwt import get_current_user 13 | from schemas.base import OrderType 14 | from schemas.response import PaginationResponse 15 | 16 | 17 | router = APIRouter() 18 | 19 | 20 | """ 21 | 接口:User 用户表增删改查 22 | 23 | GET /api/users -> get_users -> 获取所有用户 24 | POST /api/users -> add_user -> 创建单个用户 25 | PATCH /api/users -> patch_users -> 批量更新用户 26 | DELETE /api/users -> delete_users -> 批量注销用户 27 | GET /api/users/{user_id} -> get_user -> 获取单个用户 28 | PUT /api/users/{user_id} -> update_user -> 更新单个用户 29 | DELETE /api/users/{user_id} -> delete_user -> 注销单个用户 30 | """ 31 | 32 | 33 | @router.get( 34 | "", 35 | name="获取用户列表", 36 | response_model=schema.UserListResponseModel, 37 | responses=doc.get_users_responses, 38 | dependencies=[Depends(get_current_user)], 39 | ) 40 | async def get_users( 41 | query_params: schema.UserListQueryRequest = Depends(), 42 | db: AsyncSession = Depends(get_async_db), 43 | ): 44 | # 获取总数 45 | total_count = (await db.execute(select(func.count()).select_from(model.User))).scalar() 46 | 47 | # 查询 48 | stmt = await model.User.query() 49 | if query_params.size is not None: 50 | offset = (query_params.page - 1) * query_params.size 51 | stmt = stmt.offset(offset).limit(query_params.size) 52 | if query_params.order_type: 53 | stmt = stmt.order_by( 54 | getattr(model.User, query_params.order_by).desc() 55 | if query_params.order_type == OrderType.DESC 56 | else getattr(model.User, query_params.order_by).asc() 57 | ) 58 | 59 | db_users: List[model.User] = (await db.execute(stmt)).scalars().all() 60 | 61 | return schema.UserListResponseModel( 62 | data=PaginationResponse( 63 | list=[ 64 | schema.UserListResponse.model_validate( 65 | db_user, from_attributes=True 66 | ).model_dump() 67 | for db_user in db_users 68 | ], 69 | count=len(db_users), 70 | page=query_params.page, 71 | size=query_params.size, 72 | total=total_count, 73 | ).model_dump() 74 | ) 75 | 76 | 77 | @router.post( 78 | "", 79 | name="创建单个用户", 80 | response_model=schema.UserCreateResponseModel, 81 | responses=doc.create_user_responses, 82 | dependencies=[Depends(get_current_user)], 83 | ) 84 | async def create_user( 85 | user: schema.UserCreateRequest = Body(..., openapi_examples=doc.create_user_request), 86 | db: AsyncSession = Depends(get_async_db), 87 | ): 88 | async with db.begin(): 89 | # 参数检查 90 | stmt = (await model.User.query()).filter( 91 | model.User.email == user.email or model.User.username == user.username 92 | ) 93 | db_users: List[model.User] = (await db.execute(stmt)).scalars().all() 94 | if len(db_users) > 0: 95 | return schema.UserCreateResponseModel( 96 | code=ErrorCode.USER_EXIST, 97 | message=ErrorMessage.get(ErrorCode.USER_EXIST), 98 | ).to_json(status_code=HTTP_400_BAD_REQUEST) 99 | 100 | db_user: model.User = await model.User.create(db, **user.model_dump()) 101 | return schema.UserCreateResponseModel( 102 | data=schema.UserCreateResponse.model_validate(db_user, from_attributes=True) 103 | ) 104 | 105 | 106 | @router.patch( 107 | "", 108 | name="批量更新用户", 109 | response_model=schema.UsersPatchResponseModel, 110 | responses=doc.patch_users_responses, 111 | dependencies=[Depends(get_current_user)], 112 | ) 113 | async def patch_users( 114 | users_patch_request: schema.UsersPatchRequest = Body( 115 | ..., openapi_examples=doc.patch_users_request 116 | ), 117 | db: AsyncSession = Depends(get_async_db), 118 | ): 119 | async with db.begin(): 120 | stmt = (await model.User.query()).filter(model.User.id.in_(users_patch_request.ids)) 121 | db_users: List[model.User] = (await db.execute(stmt)).scalars().all() 122 | for db_user in db_users: 123 | db_user.avatar_url = users_patch_request.avatar_url 124 | db.flush() 125 | return schema.UsersPatchResponseModel( 126 | data=schema.UsersPatchResponse( 127 | ids=[db_user.id for db_user in db_users], 128 | avatar_url=users_patch_request.avatar_url, 129 | ) 130 | ) 131 | 132 | 133 | @router.delete( 134 | "", 135 | name="批量注销用户", 136 | response_model=schema.UsersDeleteResponseModel, 137 | responses=doc.delete_users_responses, 138 | dependencies=[Depends(get_current_user)], 139 | ) 140 | async def delete_users( 141 | ids: List[int] = Body( 142 | ..., 143 | description="用户 id 列表", 144 | embed=True, 145 | json_schema_extra=doc.delete_users_request, 146 | ), 147 | db: AsyncSession = Depends(get_async_db), 148 | ): 149 | async with db.begin(): 150 | stmt_select = (await model.User.query()).filter(model.User.id.in_(ids)) 151 | db_users: List[model.User] = (await db.execute(stmt_select)).scalars().all() 152 | 153 | stmt_update = update(model.User).where(model.User.deleted_at.is_(None)).filter( 154 | model.User.id.in_(ids) 155 | ).values(deleted_at=datetime.now()) 156 | await db.execute(stmt_update) 157 | return schema.UsersDeleteResponseModel( 158 | data=schema.UsersDeleteResponse(ids=[db_user.id for db_user in db_users]) 159 | ) 160 | 161 | 162 | @router.get( 163 | "/{user_id}", 164 | name="查询用户 by user_id", 165 | response_model=schema.UserInfoResponseModel, 166 | responses=doc.get_user_by_id_responses, 167 | dependencies=[Depends(get_current_user)], 168 | ) 169 | async def get_user_by_id( 170 | user_id: int = Path(..., description="用户 id", ge=1, example=1), 171 | db: AsyncSession = Depends(get_async_db), 172 | ): 173 | db_user: model.User | None = await model.User.get_by(db, id=user_id) 174 | if db_user is None: 175 | return schema.UserInfoResponseModel( 176 | code=ErrorCode.USER_NOT_FOUND, 177 | message=ErrorMessage.get(ErrorCode.USER_NOT_FOUND), 178 | ).to_json(status_code=HTTP_404_NOT_FOUND) 179 | 180 | return schema.UserInfoResponseModel( 181 | data=schema.UserInfoResponse.model_validate(db_user, from_attributes=True) 182 | ) 183 | 184 | 185 | @router.put( 186 | "/{user_id}", 187 | name="更改用户 by user_id", 188 | response_model=schema.UserUpdateResponseModel, 189 | responses=doc.update_user_by_id_responses, 190 | dependencies=[Depends(get_current_user)], 191 | ) 192 | async def update_user_by_id( 193 | user_id: int = Path(..., ge=1), 194 | user_update_request: schema.UserUpdateRequest = Body( 195 | ..., openapi_examples=doc.update_user_by_id_request 196 | ), 197 | db: AsyncSession = Depends(get_async_db), 198 | ): 199 | async with db.begin(): 200 | db_user: model.User | None = await model.User.get_by(db, id=user_id) 201 | if db_user is None: 202 | return schema.UserUpdateResponseModel( 203 | code=ErrorCode.USER_NOT_FOUND, 204 | message=ErrorMessage.get(ErrorCode.USER_NOT_FOUND), 205 | ) 206 | 207 | # 更新 username 208 | if user_update_request.username is not None: 209 | if await model.User.get_by(db, username=user_update_request.username): 210 | return schema.UserUpdateResponseModel( 211 | code=ErrorCode.USER_EXIST, 212 | message=ErrorMessage.get(ErrorCode.USER_EXIST), 213 | ) 214 | db_user.username = user_update_request.username 215 | # 更新 email 216 | if user_update_request.email is not None: 217 | if await model.User.get_by(db, email=user_update_request.email): 218 | return schema.UserUpdateResponseModel( 219 | code=ErrorCode.USER_EXIST, 220 | message=ErrorMessage.get(ErrorCode.USER_EXIST), 221 | ) 222 | db_user.email = user_update_request.email 223 | 224 | await db_user.save(db) 225 | 226 | return schema.UserUpdateResponseModel( 227 | data=schema.UserUpdateResponse.model_validate( 228 | db_user, from_attributes=True 229 | ).model_dump(), 230 | ) 231 | 232 | 233 | @router.delete( 234 | "/{user_id}", 235 | name="注销用户 by user_id", 236 | response_model=schema.UserDeleteResponseModel, 237 | responses=doc.delete_user_by_id_responses, 238 | dependencies=[Depends(get_current_user)], 239 | ) 240 | async def delete_user_by_id( 241 | user_id: int = Path(..., ge=1), 242 | db: AsyncSession = Depends(get_async_db), 243 | ): 244 | async with db.begin(): 245 | db_user: model.User | None = await model.User.get_by(db, id=user_id) 246 | if db_user is None: 247 | return schema.UserDeleteResponseModel( 248 | code=ErrorCode.USER_NOT_FOUND, 249 | message=ErrorMessage.get(ErrorCode.USER_NOT_FOUND), 250 | ).to_json(status_code=HTTP_404_NOT_FOUND) 251 | await db_user.remove(db) 252 | return schema.UserDeleteResponseModel( 253 | data=schema.UserDeleteResponse(id=db_user.id) 254 | ) 255 | -------------------------------------------------------------------------------- /fastapi_builder/utils.py: -------------------------------------------------------------------------------- 1 | import os 2 | import re 3 | import sys 4 | import subprocess 5 | import configparser 6 | import typer 7 | import pymysql 8 | import questionary 9 | 10 | from configparser import ConfigParser 11 | 12 | 13 | def check_env(): 14 | """运行环境检查""" 15 | # 模块检查 16 | typer.secho("[module]", fg=typer.colors.MAGENTA, bold=True) 17 | fp = open("./requirements.txt") 18 | modules = {} 19 | max_length = 0 20 | for module in fp.readlines(): 21 | module = module.strip() 22 | if "==" not in module: 23 | continue 24 | name, version = module.split("==") 25 | modules[name] = version 26 | max_length = len(name) if len(name) > max_length else max_length 27 | 28 | for name, version in modules.items(): 29 | s_name = "*" + name + "*" 30 | typer.secho(f"check {s_name:{max_length + 2}} : ", nl=False) 31 | try: 32 | module_version = __import__(name).__version__ 33 | if module_version == version: 34 | typer.secho("pass", fg=typer.colors.GREEN) 35 | elif module_version > version: 36 | typer.secho("higher version.", fg=typer.colors.YELLOW) 37 | else: 38 | typer.secho("lower version.", fg=typer.colors.YELLOW) 39 | except Exception: 40 | try: 41 | subprocess.check_call( 42 | ["pip", "show", name], 43 | stdout=subprocess.DEVNULL, 44 | stderr=subprocess.STDOUT, 45 | ) 46 | typer.secho("pass", fg=typer.colors.GREEN) 47 | except Exception: 48 | typer.secho("module not exist!", fg=typer.colors.RED) 49 | fp.close() 50 | typer.echo() 51 | 52 | # 数据库检查 53 | typer.secho("[db]", fg=typer.colors.MAGENTA, bold=True) 54 | """ 55 | check mysql : pass 56 | check connection : pass 57 | check database : pass 58 | check tables : pass 59 | """ 60 | sys.path.append(os.path.join(".", "core")) 61 | try: 62 | db_url = __import__("config").DATABASE_URL 63 | db_charset = __import__("config").DB_CHARSET 64 | except Exception: 65 | typer.secho("module databases not installed", fg=typer.colors.RED) 66 | 67 | typer.echo("check mysql : ", nl=False) 68 | try: 69 | subprocess.check_call( 70 | ["mysql", "--version"], stdout=subprocess.DEVNULL, stderr=subprocess.STDOUT 71 | ) 72 | typer.secho("pass", fg=typer.colors.GREEN) 73 | except Exception: 74 | typer.secho("not exist!", fg=typer.colors.RED) 75 | 76 | typer.echo("check connection : ", nl=False) 77 | try: 78 | import pymysql 79 | 80 | conn = pymysql.connect( 81 | host=db_url.hostname, 82 | port=db_url.port, 83 | user=db_url.username, 84 | password=db_url.password, 85 | charset=db_charset, 86 | ) 87 | typer.secho("pass", fg=typer.colors.GREEN) 88 | except Exception: 89 | typer.secho("failed", fg=typer.colors.RED) 90 | else: 91 | cursor = conn.cursor() 92 | dbname = db_url.database 93 | typer.echo("check database :", nl=False) 94 | if cursor.execute(f"show databases like '{dbname}';"): 95 | typer.secho("pass", fg=typer.colors.GREEN) 96 | else: 97 | typer.secho("not exist!", fg=typer.colors.RED) 98 | 99 | 100 | def read_conf(file_name: str) -> ConfigParser: 101 | """读取配置文件""" 102 | conf = configparser.ConfigParser() 103 | conf.read(file_name) 104 | return conf 105 | 106 | 107 | def set_config_file_content(file_path: str, key: str, new_value: str) -> None: 108 | """ 109 | 修改配置文件内容 110 | 111 | Args: 112 | file_path (str): 配置文件路径 113 | key (str): 配置项 114 | new_value (str): 新值 115 | """ 116 | file_lines = [] 117 | with open(file_path) as fp: 118 | for line in fp.readlines(): 119 | line_lst = line.split("=") 120 | if line_lst[0] == key: 121 | line = f"{line_lst[0]}={new_value}\n" 122 | elif line_lst[0] == key + " ": 123 | line = f"{line_lst[0]}= {new_value}\n" 124 | file_lines.append(line) 125 | file_content = "".join(file_lines) 126 | with open(file_path, "w") as fp: 127 | fp.write(file_content) 128 | 129 | 130 | def config_app(conf: ConfigParser) -> None: 131 | """ 132 | 配置应用 133 | 134 | 0)读取 .fastapi-builder,获取虚拟环境、打包方式、数据库等信息 135 | 1)检查是否在虚拟环境下,没有的话会检查是否存在虚拟环境,若不存在,询问用户是否创建 136 | 2)进入虚拟环境 137 | 3)安装 requirements.txt 138 | 4)检查数据库连接,若失败,让用户填写数据库地址、用户名、端口。重复检查直到连接 139 | 5)创建数据库并运行迁移文件,创建相应的表 140 | """ 141 | # 3)安装 requirements.txt 142 | typer.echo("install required modules...") 143 | os.system("pip install -r requirements.txt") 144 | typer.echo("") 145 | 146 | # 4)检查数据库连接,若失败,让用户填写数据库地址、用户名、端口。重复检查直到连接 147 | # 若没安装 mysql/Postgres 直接退出 148 | try: 149 | subprocess.check_call( 150 | ["mysql", "--version"], stdout=subprocess.DEVNULL, stderr=subprocess.STDOUT 151 | ) 152 | except Exception: 153 | typer.secho("Please make sure you download mysql already!", fg=typer.colors.RED) 154 | return 155 | # 与数据库建立连接 156 | sys.path.append(os.path.join(".", "core")) 157 | db_url = __import__("config").DATABASE_URL 158 | db_charset = __import__("config").DB_CHARSET 159 | # 获取配置中数据库信息 160 | db_host: str = db_url.hostname 161 | db_port: int = db_url.port 162 | db_user: str = db_url.username 163 | db_pswd: str = db_url.password 164 | 165 | while True: 166 | typer.echo("check database connecting.....", nl=False) 167 | try: 168 | conn = pymysql.connect( 169 | host=db_host, 170 | port=db_port, 171 | user=db_user, 172 | password=db_pswd, 173 | charset=db_charset, 174 | ) 175 | typer.secho("success", fg=typer.colors.GREEN) 176 | # 写入到配置文件中 core/.env alembic.ini 177 | sql_url = f"mysql+pymysql://{db_user}:{db_pswd}@{db_host}:{db_port}/{db_url.database}?charset={db_charset}" # noqa 178 | set_config_file_content("alembic.ini", "sqlalchemy.url", sql_url) 179 | set_config_file_content( 180 | os.path.join(".", "core", ".env"), "DB_CONNECTION", sql_url 181 | ) 182 | 183 | # 创建数据库 184 | cursor = conn.cursor() 185 | cursor.execute( 186 | "create database if not exists %s default charset utf8mb4;" 187 | % db_url.database 188 | ) 189 | break 190 | except Exception: 191 | typer.secho("fail", fg=typer.colors.RED) 192 | db_host = questionary.text("database host is:", default=db_host).ask() 193 | db_port = questionary.text("database port is:", default=str(db_port)).ask() 194 | db_port = int(db_port) 195 | db_user = questionary.text("database username is:", default=db_user).ask() 196 | db_pswd = questionary.text("database password is:", default=db_pswd).ask() 197 | 198 | # 5)创建数据库并运行迁移文件,创建相应的表 199 | os.system("alembic revision --autogenerate -m \"create migration\"") 200 | os.system("alembic upgrade head") 201 | 202 | 203 | def new_app_inject_into_project( 204 | folder_name: str, pascal_name: str, snake_name: str 205 | ) -> None: 206 | """ 207 | 新 app 注入到 project 208 | 209 | Args: 210 | folder_name (str): app 文件夹名. eg: computer-book 211 | pascal_name (str): app 驼峰命名. eg: ComputerBook 212 | snake_name (str): app 蛇形命名. eg: computer_book 213 | """ 214 | 215 | # 1. 打开 api/routes/api.py 文件,创建路由 216 | import_line = f"from apps.app_{folder_name}.api import router as {folder_name}_router" 217 | include_router_line = f"router.include_router({folder_name}_router, tags=[\"{pascal_name} 类\"], prefix=\"/{snake_name}s\")" # noqa 218 | 219 | def get_new_content(pattern: str | re.Pattern[str], content: str, new_line: str) -> str: 220 | last_match = re.findall(pattern, content) 221 | if last_match: 222 | last_position = content.rfind(last_match[-1]) + len(last_match[-1]) 223 | else: 224 | last_position = 0 225 | 226 | content = content[:last_position] + "\n" + new_line + content[last_position:] 227 | return content 228 | 229 | api_file_path = os.path.join(".", "api", "routes", "api.py") 230 | with open(api_file_path, "r+", encoding="utf8") as f: 231 | content = f.read() 232 | # 找到最后一个 from ... import ... 233 | content = get_new_content(r"from \S+ import \S+ as \S+", content, import_line) 234 | # 找到最后一个 router.include_router(...) 235 | content = get_new_content(r"router.include_router\([^)]*\)", content, include_router_line) 236 | 237 | f.seek(0) 238 | f.write(content) 239 | f.truncate() 240 | 241 | # 2. 打开 db/base.py 导入 models 242 | db_file_path = os.path.join(".", "db", "base.py") 243 | with open(db_file_path, "r+", encoding="utf8") as f: 244 | content = f.read() 245 | 246 | # 找到最后一个 from ... import ... 语句 247 | last_import_match = re.findall(r"from \S+ import \S+", content) 248 | if last_import_match: 249 | last_import_position = content.rfind(last_import_match[-1]) + len(last_import_match[-1]) 250 | else: 251 | last_import_position = 0 252 | 253 | # 构建新的内容,添加新的导入语句 254 | content = ( 255 | content[:last_import_position] + 256 | "\n" + f"from apps.app_{snake_name}.model import {pascal_name}" + 257 | content[last_import_position:] 258 | ) 259 | 260 | # 找到 __all__ 赋值语句 261 | all_match = re.search(r"__all__ = \[(.*?)\]", content) 262 | if all_match: 263 | all_entries = re.findall(r"\"(\w+)\"|\'(\w+)\'", all_match.group(1)) 264 | all_entries = [entry[0] if entry[0] else entry[1] for entry in all_entries] # 处理元组结果 265 | all_entries.append(pascal_name) 266 | updated_all = ", ".join(f"\"{entry}\"" for entry in all_entries) 267 | content = ( 268 | content[:all_match.start()] + 269 | f"__all__ = [{updated_all}]" + 270 | content[all_match.end():] 271 | ) 272 | 273 | f.seek(0) 274 | f.write(content) 275 | f.truncate() 276 | -------------------------------------------------------------------------------- /fastapi_builder/templates/app/app_{{ cookiecutter.folder_name }}/api.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | from typing import List 3 | from fastapi import APIRouter, Body, Depends, Path 4 | from sqlalchemy import func, select, update 5 | from sqlalchemy.ext.asyncio import AsyncSession 6 | from starlette.status import HTTP_404_NOT_FOUND 7 | 8 | from apps.app_{{ cookiecutter.folder_name }} import doc 9 | from apps.app_{{ cookiecutter.folder_name }}.schema import ( 10 | {{ cookiecutter.pascal_name }}CreateRequest, 11 | {{ cookiecutter.pascal_name }}CreateResponse, 12 | {{ cookiecutter.pascal_name }}CreateResponseModel, 13 | {{ cookiecutter.pascal_name }}DeleteResponse, 14 | {{ cookiecutter.pascal_name }}DeleteResponseModel, 15 | {{ cookiecutter.pascal_name }}InfoResponse, 16 | {{ cookiecutter.pascal_name }}InfoResponseModel, 17 | {{ cookiecutter.pascal_name }}ListQueryRequest, 18 | {{ cookiecutter.pascal_name }}ListResponse, 19 | {{ cookiecutter.pascal_name }}ListResponseModel, 20 | {{ cookiecutter.pascal_name }}UpdateRequest, 21 | {{ cookiecutter.pascal_name }}UpdateResponse, 22 | {{ cookiecutter.pascal_name }}UpdateResponseModel, 23 | {{ cookiecutter.pascal_name }}sDeleteResponse, 24 | {{ cookiecutter.pascal_name }}sDeleteResponseModel, 25 | {{ cookiecutter.pascal_name }}sPatchRequest, 26 | {{ cookiecutter.pascal_name }}sPatchResponse, 27 | {{ cookiecutter.pascal_name }}sPatchResponseModel, 28 | ) 29 | from apps.app_{{ cookiecutter.folder_name }}.model import {{ cookiecutter.pascal_name }} 30 | from core.e import ErrorCode, ErrorMessage 31 | from db.database import get_async_db 32 | from schemas.base import OrderType 33 | from schemas.response import PaginationResponse 34 | 35 | 36 | router = APIRouter() 37 | 38 | 39 | """ 40 | 接口:{{ cookiecutter.pascal_name }} 表增删改查 41 | 42 | GET /api/{{ cookiecutter.snake_name }}s {% for i in range(cookiecutter.snake_name|length - 1) %} {% endfor %} -> get_{{ cookiecutter.snake_name }}s -> 获取所有 {{ cookiecutter.snake_name }} 43 | POST /api/{{ cookiecutter.snake_name }}s {% for i in range(cookiecutter.snake_name|length - 1) %} {% endfor %} -> add_{{ cookiecutter.snake_name }} -> 创建单个 {{ cookiecutter.snake_name }} 44 | PATCH /api/{{ cookiecutter.snake_name }}s {% for i in range(cookiecutter.snake_name|length - 1) %} {% endfor %} -> patch_{{ cookiecutter.snake_name }}s -> 批量更新 {{ cookiecutter.snake_name }} 45 | DELETE /api/{{ cookiecutter.snake_name }}s {% for i in range(cookiecutter.snake_name|length - 1) %} {% endfor %} -> delete_{{ cookiecutter.snake_name }}s -> 批量注销 {{ cookiecutter.snake_name }} 46 | GET /api/{{ cookiecutter.snake_name }}s/{{'{'}}{{ cookiecutter.snake_name }}_id{{'}'}} -> get_{{ cookiecutter.snake_name }} -> 获取单个 {{ cookiecutter.snake_name }} 47 | PUT /api/{{ cookiecutter.snake_name }}s/{{'{'}}{{ cookiecutter.snake_name }}_id{{'}'}} -> update_{{ cookiecutter.snake_name }} -> 更新单个 {{ cookiecutter.snake_name }} 48 | DELETE /api/{{ cookiecutter.snake_name }}s/{{'{'}}{{ cookiecutter.snake_name }}_id{{'}'}} -> delete_{{ cookiecutter.snake_name }} -> 注销单个 {{ cookiecutter.snake_name }} 49 | """ 50 | 51 | 52 | @router.get( 53 | "", 54 | name="获取所有 {{ cookiecutter.snake_name }}", 55 | response_model={{ cookiecutter.pascal_name }}ListResponseModel, 56 | responses=doc.get_{{ cookiecutter.snake_name }}s_responses, 57 | ) 58 | async def get_{{ cookiecutter.snake_name }}s( 59 | query_params: {{ cookiecutter.pascal_name }}ListQueryRequest = Depends(), 60 | db: AsyncSession = Depends(get_async_db), 61 | ): 62 | # 获取总数 63 | total_count = (await db.execute(select(func.count()).select_from({{ cookiecutter.pascal_name }}))).scalar() 64 | 65 | # 查询 66 | stmt = await {{ cookiecutter.pascal_name }}.query() 67 | if query_params.size is not None: 68 | offset = (query_params.page - 1) * query_params.size 69 | stmt = stmt.offset(offset).limit(query_params.size) 70 | if query_params.order_type: 71 | stmt = stmt.order_by( 72 | getattr({{ cookiecutter.pascal_name }}, query_params.order_by).desc() 73 | if query_params.order_type == OrderType.DESC 74 | else getattr({{ cookiecutter.pascal_name }}, query_params.order_by).asc() 75 | ) 76 | 77 | db_{{ cookiecutter.snake_name }}s: List[{{ cookiecutter.pascal_name }}] = (await db.execute(stmt)).scalars().all() 78 | 79 | return {{ cookiecutter.pascal_name }}ListResponseModel( 80 | data=PaginationResponse( 81 | list=[ 82 | {{ cookiecutter.pascal_name }}ListResponse.model_validate( 83 | db_{{ cookiecutter.snake_name }}, from_attributes=True 84 | ).model_dump() 85 | for db_{{ cookiecutter.snake_name }} in db_{{ cookiecutter.snake_name }}s 86 | ], 87 | count=len(db_{{ cookiecutter.snake_name }}s), 88 | page=query_params.page, 89 | size=query_params.size, 90 | total=total_count, 91 | ).model_dump() 92 | ) 93 | 94 | 95 | @router.post( 96 | "", 97 | name="创建单个 {{ cookiecutter.snake_name }}", 98 | response_model={{ cookiecutter.pascal_name }}CreateResponseModel, 99 | responses=doc.create_{{ cookiecutter.snake_name }}_responses, 100 | ) 101 | async def create_{{ cookiecutter.snake_name }}( 102 | {{ cookiecutter.snake_name }}: {{ cookiecutter.pascal_name }}CreateRequest = Body( 103 | ..., openapi_examples=doc.create_{{ cookiecutter.snake_name }}_request 104 | ), 105 | db: AsyncSession = Depends(get_async_db), 106 | ): 107 | async with db.begin(): 108 | db_{{ cookiecutter.snake_name }}: {{ cookiecutter.pascal_name }} = await {{ cookiecutter.pascal_name }}.create(db, **{{ cookiecutter.snake_name }}.model_dump()) 109 | return {{ cookiecutter.pascal_name }}CreateResponseModel( 110 | data={{ cookiecutter.pascal_name }}CreateResponse.model_validate(db_{{ cookiecutter.snake_name }}, from_attributes=True) 111 | ) 112 | 113 | 114 | @router.patch( 115 | "", 116 | name="批量更新 {{ cookiecutter.snake_name }}", 117 | response_model={{ cookiecutter.pascal_name }}sPatchResponseModel, 118 | responses=doc.patch_{{ cookiecutter.snake_name }}s_responses, 119 | ) 120 | async def patch_{{ cookiecutter.snake_name }}s( 121 | {{ cookiecutter.snake_name }}s_patch_request: {{ cookiecutter.pascal_name }}sPatchRequest = Body( 122 | ..., openapi_examples=doc.patch_{{ cookiecutter.snake_name }}s_request 123 | ), 124 | db: AsyncSession = Depends(get_async_db), 125 | ): 126 | async with db.begin(): 127 | stmt = (await {{ cookiecutter.pascal_name }}.query()).filter( 128 | {{ cookiecutter.pascal_name }}.id.in_({{ cookiecutter.snake_name }}s_patch_request.ids) 129 | ) 130 | db_{{ cookiecutter.snake_name }}s: List[{{ cookiecutter.pascal_name }}] = (await db.execute(stmt)).scalars().all() 131 | for db_{{ cookiecutter.snake_name }} in db_{{ cookiecutter.snake_name }}s: 132 | db_{{ cookiecutter.snake_name }}.name = {{ cookiecutter.snake_name }}s_patch_request.name 133 | db.flush() 134 | return {{ cookiecutter.pascal_name }}sPatchResponseModel( 135 | data={{ cookiecutter.pascal_name }}sPatchResponse( 136 | ids=[db_{{ cookiecutter.snake_name }}.id for db_{{ cookiecutter.snake_name }} in db_{{ cookiecutter.snake_name }}s], 137 | name={{ cookiecutter.snake_name }}s_patch_request.name, 138 | ) 139 | ) 140 | 141 | 142 | @router.delete( 143 | "", 144 | name="批量注销 {{ cookiecutter.snake_name }}", 145 | response_model={{ cookiecutter.pascal_name }}sDeleteResponseModel, 146 | responses=doc.delete_{{ cookiecutter.snake_name }}s_responses, 147 | ) 148 | async def delete_{{ cookiecutter.snake_name }}s( 149 | ids: List[int] = Body( 150 | ..., 151 | description="{{ cookiecutter.snake_name }} id 列表", 152 | embed=True, 153 | json_schema_extra=doc.delete_{{ cookiecutter.snake_name }}s_request, 154 | ), 155 | db: AsyncSession = Depends(get_async_db), 156 | ): 157 | async with db.begin(): 158 | stmt_select = (await {{ cookiecutter.pascal_name }}.query()).filter({{ cookiecutter.pascal_name }}.id.in_(ids)) 159 | db_{{ cookiecutter.snake_name }}s: List[{{ cookiecutter.pascal_name }}] = (await db.execute(stmt_select)).scalars().all() 160 | 161 | stmt_update = ( 162 | update({{ cookiecutter.pascal_name }}) 163 | .where({{ cookiecutter.pascal_name }}.deleted_at.is_(None)) 164 | .filter({{ cookiecutter.pascal_name }}.id.in_(ids)) 165 | .values(deleted_at=datetime.datetime.now()) 166 | ) 167 | await db.execute(stmt_update) 168 | return {{ cookiecutter.pascal_name }}sDeleteResponseModel( 169 | data={{ cookiecutter.pascal_name }}sDeleteResponse( 170 | ids=[db_{{ cookiecutter.snake_name }}.id for db_{{ cookiecutter.snake_name }} in db_{{ cookiecutter.snake_name }}s] 171 | ) 172 | ) 173 | 174 | 175 | @router.get( 176 | "/{{'{'}}{{ cookiecutter.snake_name }}_id{{'}'}}", 177 | name="获取单个 {{ cookiecutter.snake_name }} by id", 178 | response_model={{ cookiecutter.pascal_name }}InfoResponseModel, 179 | responses=doc.get_{{ cookiecutter.snake_name }}_by_id_responses, 180 | ) 181 | async def get_{{ cookiecutter.snake_name }}_by_id( 182 | {{ cookiecutter.snake_name }}_id: int = Path(..., description="{{ cookiecutter.snake_name }} id", ge=1, example=1), 183 | db: AsyncSession = Depends(get_async_db), 184 | ): 185 | db_{{ cookiecutter.snake_name }}: {{ cookiecutter.pascal_name }} | None = await {{ cookiecutter.pascal_name }}.get_by(db, id={{ cookiecutter.snake_name }}_id) 186 | if db_{{ cookiecutter.snake_name }} is None: 187 | return {{ cookiecutter.pascal_name }}InfoResponseModel( 188 | code=ErrorCode.NOT_FOUND, 189 | message=ErrorMessage.get(ErrorCode.NOT_FOUND), 190 | ).to_json(status_code=HTTP_404_NOT_FOUND) 191 | 192 | return {{ cookiecutter.pascal_name }}InfoResponseModel( 193 | data={{ cookiecutter.pascal_name }}InfoResponse.model_validate(db_{{ cookiecutter.snake_name }}, from_attributes=True) 194 | ) 195 | 196 | 197 | @router.put( 198 | "/{{'{'}}{{ cookiecutter.snake_name }}_id{{'}'}}", 199 | name="更新单个 {{ cookiecutter.snake_name }} by id", 200 | response_model={{ cookiecutter.pascal_name }}UpdateResponseModel, 201 | responses=doc.update_{{ cookiecutter.snake_name }}_by_id_responses, 202 | ) 203 | async def update_{{ cookiecutter.snake_name }}_by_id( 204 | {{ cookiecutter.snake_name }}_id: int = Path(..., description="{{ cookiecutter.snake_name }} id", ge=1), 205 | {{ cookiecutter.snake_name }}_update_request: {{ cookiecutter.pascal_name }}UpdateRequest = Body( 206 | ..., openapi_examples=doc.update_{{ cookiecutter.snake_name }}_by_id_request 207 | ), 208 | db: AsyncSession = Depends(get_async_db), 209 | ): 210 | async with db.begin(): 211 | db_{{ cookiecutter.snake_name }}: {{ cookiecutter.pascal_name }} | None = await {{ cookiecutter.pascal_name }}.get_by(db, id={{ cookiecutter.snake_name }}_id) 212 | if db_{{ cookiecutter.snake_name }} is None: 213 | return {{ cookiecutter.pascal_name }}UpdateResponseModel( 214 | code=ErrorCode.NOT_FOUND, 215 | message=ErrorMessage.get(ErrorCode.NOT_FOUND), 216 | ) 217 | 218 | # 更新 name 219 | if {{ cookiecutter.snake_name }}_update_request.name is not None: 220 | db_{{ cookiecutter.snake_name }}.username = {{ cookiecutter.snake_name }}_update_request.name 221 | 222 | await db_{{ cookiecutter.snake_name }}.save(db) 223 | 224 | return {{ cookiecutter.pascal_name }}UpdateResponseModel( 225 | data={{ cookiecutter.pascal_name }}UpdateResponse.model_validate( 226 | db_{{ cookiecutter.snake_name }}, from_attributes=True 227 | ).model_dump(), 228 | ) 229 | 230 | 231 | @router.delete( 232 | "/{{'{'}}{{ cookiecutter.snake_name }}_id{{'}'}}", 233 | name="注销单个 {{ cookiecutter.snake_name }} by id", 234 | response_model={{ cookiecutter.pascal_name }}DeleteResponseModel, 235 | responses=doc.delete_{{ cookiecutter.snake_name }}_by_id_responses, 236 | ) 237 | async def delete_{{ cookiecutter.snake_name }}_by_id( 238 | {{ cookiecutter.snake_name }}_id: int = Path(..., description="{{ cookiecutter.snake_name }} id", ge=1), 239 | db: AsyncSession = Depends(get_async_db), 240 | ): 241 | async with db.begin(): 242 | db_{{ cookiecutter.snake_name }}: {{ cookiecutter.pascal_name }} | None = await {{ cookiecutter.pascal_name }}.get_by(db, id={{ cookiecutter.snake_name }}_id) 243 | if db_{{ cookiecutter.snake_name }} is None: 244 | return {{ cookiecutter.pascal_name }}DeleteResponseModel( 245 | code=ErrorCode.NOT_FOUND, 246 | message=ErrorMessage.get(ErrorCode.NOT_FOUND), 247 | ).to_json(status_code=HTTP_404_NOT_FOUND) 248 | await db_{{ cookiecutter.snake_name }}.remove(db) 249 | 250 | return {{ cookiecutter.pascal_name }}DeleteResponseModel(data={{ cookiecutter.pascal_name }}DeleteResponse(id=db_{{ cookiecutter.snake_name }}.id)) 251 | -------------------------------------------------------------------------------- /fastapi_builder/templates/project/{{ cookiecutter.folder_name }}/apps/app_user/doc.py: -------------------------------------------------------------------------------- 1 | from starlette.status import ( 2 | HTTP_200_OK, 3 | HTTP_400_BAD_REQUEST, 4 | HTTP_401_UNAUTHORIZED, 5 | HTTP_404_NOT_FOUND, 6 | ) 7 | 8 | from core.e import ErrorCode, ErrorMessage 9 | from schemas.response import StandardResponse 10 | 11 | # ======================>>>>>>>>>>>>>>>>>>>>>> login 12 | 13 | login_responses = { 14 | HTTP_200_OK: { 15 | "description": "登录成功", 16 | "content": { 17 | "application/json": { 18 | "example": { 19 | "code": 0, 20 | "message": "", 21 | "data": { 22 | "id": 1, 23 | "username": "fmw666", 24 | "email": "fmw19990718@gmail.com", 25 | "token_type": "bearer", 26 | "access_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJleHAiOjE3MjU1MzIzOTMsInN1YiI6IjYifQ.MXJutcQ2e7HHUC0FVkeqRtHyn6fT1fclPugo-qpy8e4", # noqa 27 | }, 28 | } 29 | } 30 | }, 31 | }, 32 | HTTP_400_BAD_REQUEST: { 33 | "description": "密码错误", 34 | "model": StandardResponse, 35 | "content": { 36 | "application/json": { 37 | "example": { 38 | "code": ErrorCode.USER_PASSWORD_ERROR, 39 | "message": ErrorMessage.get(ErrorCode.USER_PASSWORD_ERROR), 40 | "data": None, 41 | } 42 | } 43 | }, 44 | }, 45 | HTTP_404_NOT_FOUND: { 46 | "description": "用户不存在", 47 | "model": StandardResponse, 48 | "content": { 49 | "application/json": { 50 | "example": { 51 | "code": ErrorCode.USER_NOT_FOUND, 52 | "message": ErrorMessage.get(ErrorCode.USER_NOT_FOUND), 53 | "data": None, 54 | } 55 | } 56 | }, 57 | }, 58 | } 59 | 60 | login_request = { 61 | "普通用户登录": { 62 | "description": "使用 **自己的用户名****密码** 进行登录.", 63 | "value": { 64 | "username": "fmw666", 65 | "password": "123456", 66 | }, 67 | }, 68 | "内置管理员登录": { 69 | "description": "使用 **内置管理员** 的账号 `root01` 和密码 `123456` 进行登录.", 70 | "value": { 71 | "username": "root01", 72 | "password": "123456", 73 | }, 74 | }, 75 | } 76 | 77 | # ======================>>>>>>>>>>>>>>>>>>>>>> register 78 | 79 | register_responses = { 80 | HTTP_200_OK: { 81 | "description": "用户注册成功.", 82 | "content": { 83 | "application/json": { 84 | "example": { 85 | "code": 0, 86 | "message": "", 87 | "data": { 88 | "id": 1, 89 | "username": "fmw666", 90 | "email": "fmw19990718@gmail.com", 91 | }, 92 | } 93 | } 94 | }, 95 | }, 96 | HTTP_400_BAD_REQUEST: { 97 | "description": "用户注册失败(用户名已存在/邮箱已存在).", 98 | "model": StandardResponse, 99 | "content": { 100 | "application/json": { 101 | "example": { 102 | "code": ErrorCode.USER_NAME_EXIST, 103 | "message": ErrorMessage.get(ErrorCode.USER_NAME_EXIST), 104 | } 105 | } 106 | }, 107 | }, 108 | } 109 | 110 | register_request = { 111 | "普通用户注册": { 112 | "description": ( 113 | "注册时需要使用 **邮箱****用户名****密码**.\n" 114 | "* 账号和密码长度为 6~12\n* 邮箱不超过 32 位" 115 | ), 116 | "value": { 117 | "email": "fmw19990718@gmail.com", 118 | "username": "fmw666", 119 | "password": "123456", 120 | }, 121 | }, 122 | "内置用户注册": { 123 | "description": "注册 **内置管理员** 的账号使用 `root01` 和密码 `123456`.", 124 | "value": { 125 | "email": "root01@example.com", 126 | "username": "root01", 127 | "password": "123456", 128 | }, 129 | }, 130 | } 131 | 132 | # ======================>>>>>>>>>>>>>>>>>>>>>> get_user_info 133 | 134 | get_user_info_response = { 135 | HTTP_200_OK: { 136 | "description": "获取用户信息成功.", 137 | "content": { 138 | "application/json": { 139 | "example": { 140 | "code": 0, 141 | "message": "", 142 | "data": { 143 | "id": 1, 144 | "username": "admin", 145 | "phone": "18066666666", 146 | "avator_url": "", 147 | "portfolio_name": "work", 148 | "created_at": "2023-07-03 08:03:03", 149 | "updated_at": "2023-07-03 08:03:03", 150 | }, 151 | } 152 | } 153 | }, 154 | }, 155 | HTTP_401_UNAUTHORIZED: { 156 | "description": "用户未登录.", 157 | "content": { 158 | "application/json": { 159 | "example": { 160 | "code": ErrorCode.UNAUTHORIZED, 161 | "message": ErrorMessage.get(ErrorCode.UNAUTHORIZED), 162 | } 163 | } 164 | }, 165 | }, 166 | HTTP_404_NOT_FOUND: { 167 | "description": "用户不存在.", 168 | "content": { 169 | "application/json": { 170 | "example": { 171 | "code": ErrorCode.USER_NOT_FOUND, 172 | "message": ErrorMessage.get(ErrorCode.USER_NOT_FOUND), 173 | } 174 | } 175 | }, 176 | }, 177 | } 178 | 179 | # ======================>>>>>>>>>>>>>>>>>>>>>> get_users 180 | 181 | get_users_responses = { 182 | HTTP_200_OK: { 183 | "description": "获取 User 列表成功", 184 | "content": { 185 | "application/json": { 186 | "example": { 187 | "code": 0, 188 | "data": { 189 | "list": [ 190 | { 191 | "id": 1, 192 | "email": "user01", 193 | "username": "user01@example.com", 194 | }, 195 | { 196 | "id": 2, 197 | "email": "user02", 198 | "username": "user02@example.com", 199 | } 200 | ], 201 | "count": 2, 202 | "total": 5, 203 | "page": 1, 204 | "size": 2 205 | }, 206 | "message": "", 207 | } 208 | } 209 | }, 210 | } 211 | } 212 | 213 | # ======================>>>>>>>>>>>>>>>>>>>>>> create_user 214 | 215 | create_user_request = { 216 | "创建用户": { 217 | "description": ( 218 | "创建时需要使用 **邮箱****用户名****密码**.\n" 219 | "* 账号和密码长度为 6~12\n* 邮箱不超过 32 位" 220 | ), 221 | "value": { 222 | "email": "fmw19990718@gmail.com", 223 | "username": "fmw666", 224 | "password": "123456", 225 | } 226 | } 227 | } 228 | 229 | create_user_responses = { 230 | HTTP_200_OK: { 231 | "description": "创建用户成功", 232 | "content": { 233 | "application/json": { 234 | "example": { 235 | "code": 0, 236 | "data": { 237 | "id": 1, 238 | "username": "fmw666", 239 | "email": "fmw19990718@gmail.com", 240 | }, 241 | "message": "", 242 | } 243 | } 244 | } 245 | }, 246 | HTTP_400_BAD_REQUEST: { 247 | "description": "创建用户失败(用户名已存在/邮箱已存在)", 248 | "model": StandardResponse, 249 | "content": { 250 | "application/json": { 251 | "example": { 252 | "code": ErrorCode.USER_EXIST, 253 | "message": ErrorMessage.get(ErrorCode.USER_EXIST), 254 | } 255 | } 256 | }, 257 | } 258 | } 259 | 260 | # ======================>>>>>>>>>>>>>>>>>>>>>> patch_users 261 | 262 | patch_users_responses = { 263 | HTTP_200_OK: { 264 | "description": "批量更新用户成功", 265 | "content": { 266 | "application/json": { 267 | "example": { 268 | "code": 0, 269 | "data": { 270 | "ids": [1, 2], 271 | "avatar_url": "https://example.com/avatar.png", 272 | }, 273 | "message": "", 274 | } 275 | } 276 | } 277 | } 278 | } 279 | 280 | patch_users_request = { 281 | "批量更新用户头像": { 282 | "description": "批量更新用户,返回更新成功的用户 id 和更新条目", 283 | "value": { 284 | "ids": [1, 2, 3], 285 | "avatar_url": "https://example.com/avatar.png", 286 | }, 287 | } 288 | } 289 | 290 | # ======================>>>>>>>>>>>>>>>>>>>>>> delete_users 291 | 292 | delete_users_responses = { 293 | HTTP_200_OK: { 294 | "description": "批量删除用户成功", 295 | "content": { 296 | "application/json": { 297 | "example": { 298 | "code": 0, 299 | "data": { 300 | "ids": [1, 2], 301 | }, 302 | "message": "", 303 | } 304 | } 305 | } 306 | } 307 | } 308 | 309 | delete_users_request = { 310 | "example": [1, 2, 3], 311 | } 312 | 313 | # ======================>>>>>>>>>>>>>>>>>>>>>> get_user_by_id 314 | 315 | get_user_by_id_responses = { 316 | HTTP_200_OK: { 317 | "description": "获取用户信息成功", 318 | "content": { 319 | "application/json": { 320 | "example": { 321 | "code": 0, 322 | "data": { 323 | "id": 1, 324 | "username": "fmw666", 325 | "email": "fmw19990718@gmail.com", 326 | }, 327 | "message": "" 328 | } 329 | } 330 | } 331 | }, 332 | HTTP_404_NOT_FOUND: { 333 | "description": "用户不存在", 334 | "model": StandardResponse, 335 | "content": { 336 | "application/json": { 337 | "example": { 338 | "code": ErrorCode.USER_NOT_FOUND, 339 | "message": ErrorMessage.get(ErrorCode.USER_NOT_FOUND), 340 | } 341 | } 342 | } 343 | } 344 | } 345 | 346 | # ======================>>>>>>>>>>>>>>>>>>>>>> update_user_by_id 347 | 348 | update_user_by_id_responses = { 349 | HTTP_200_OK: { 350 | "description": "更改 user 成功", 351 | "content": { 352 | "application/json": { 353 | "example": { 354 | "code": 0, 355 | "data": { 356 | "id": 1, 357 | "username": "fmw666", 358 | "email": "fmw19990718@gmail.com", 359 | }, 360 | "message": "", 361 | }, 362 | }, 363 | }, 364 | }, 365 | HTTP_400_BAD_REQUEST: { 366 | "description": "更改 user 失败(用户名已存在/邮箱已存在)", 367 | "model": StandardResponse, 368 | "content": { 369 | "application/json": { 370 | "example": { 371 | "code": ErrorCode.USER_EXIST, 372 | "message": ErrorMessage.get(ErrorCode.USER_EXIST), 373 | } 374 | } 375 | }, 376 | }, 377 | HTTP_404_NOT_FOUND: { 378 | "description": "用户不存在", 379 | "model": StandardResponse, 380 | "content": { 381 | "application/json": { 382 | "example": { 383 | "code": ErrorCode.USER_NOT_FOUND, 384 | "message": ErrorMessage.get(ErrorCode.USER_NOT_FOUND), 385 | } 386 | } 387 | } 388 | }, 389 | } 390 | 391 | update_user_by_id_request = { 392 | "仅更新 username 的情况": { 393 | "description": "只设置 `username`,其余的字段设置为 None,或者不要.", 394 | "value": { 395 | "username": "new username", 396 | }, 397 | }, 398 | "仅更新 email 的情况": { 399 | "description": "只设置 `email`,其余的字段设置为 None,或者不要.", 400 | "value": { 401 | "email": "new_email@example.com", 402 | }, 403 | }, 404 | "同时更新 username 和 email 的情况": { 405 | "description": "同时设置 `username` 和 `email`,其余的字段设置为 None,或者不要.", 406 | "value": { 407 | "username": "new username", 408 | "email": "new_email@example.com", 409 | }, 410 | }, 411 | } 412 | 413 | # ======================>>>>>>>>>>>>>>>>>>>>>> delete_user_by_id 414 | 415 | delete_user_by_id_responses = { 416 | HTTP_200_OK: { 417 | "description": "注销 user 成功", 418 | "content": { 419 | "application/json": { 420 | "example": { 421 | "code": 0, 422 | "data": { 423 | "id": 1, 424 | }, 425 | "message": "" 426 | }, 427 | }, 428 | }, 429 | }, 430 | HTTP_404_NOT_FOUND: { 431 | "description": "user 不存在", 432 | "model": StandardResponse, 433 | "content": { 434 | "application/json": { 435 | "example": { 436 | "code": ErrorCode.USER_NOT_FOUND, 437 | "message": ErrorMessage.get(ErrorCode.USER_NOT_FOUND), 438 | }, 439 | }, 440 | }, 441 | }, 442 | } 443 | --------------------------------------------------------------------------------