├── app ├── __init__.py ├── db │ ├── __init__.py │ └── base.py ├── api │ ├── __init__.py │ ├── api_healthcheck.py │ ├── api_router.py │ ├── api_register.py │ ├── api_login.py │ └── api_user.py ├── core │ ├── __init__.py │ ├── config.py │ └── security.py ├── helpers │ ├── enums.py │ ├── login_manager.py │ ├── exception_handler.py │ └── paging.py ├── models │ ├── __init__.py │ ├── model_user.py │ └── model_base.py ├── schemas │ ├── sche_token.py │ ├── sche_base.py │ └── sche_user.py ├── main.py └── services │ └── srv_user.py ├── tests ├── __init__.py ├── env.example ├── faker │ ├── __init__.py │ └── user_provider.py ├── api │ ├── __init__.py │ ├── test_register.py │ └── test_login.py └── conftest.py ├── _config.yml ├── postgresql.conf ├── pytest.ini ├── alembic ├── README ├── script.py.mako ├── versions │ └── f9a075ca46e9_.py └── env.py ├── .dockerignore ├── logo-teal.png ├── logosite.png ├── env.example ├── Dockerfile ├── logging.ini ├── alembic.ini ├── requirements.txt ├── docker-compose.yaml ├── .gitignore ├── document └── CREATE_DB_GUIDE.md ├── .gitlab-ci.yml └── README.md /app/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /app/db/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /app/api/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /app/core/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /_config.yml: -------------------------------------------------------------------------------- 1 | theme: jekyll-theme-slate -------------------------------------------------------------------------------- /postgresql.conf: -------------------------------------------------------------------------------- 1 | listen_addresses = '*' -------------------------------------------------------------------------------- /pytest.ini: -------------------------------------------------------------------------------- 1 | [pytest] 2 | testpaths = tests -------------------------------------------------------------------------------- /alembic/README: -------------------------------------------------------------------------------- 1 | Generic single-database configuration. -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | docker/ 2 | .venv/* 3 | .idea/* 4 | docker-compose.yaml 5 | Dockerfile -------------------------------------------------------------------------------- /logo-teal.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Longdh57/fastapi-base/HEAD/logo-teal.png -------------------------------------------------------------------------------- /logosite.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Longdh57/fastapi-base/HEAD/logosite.png -------------------------------------------------------------------------------- /app/helpers/enums.py: -------------------------------------------------------------------------------- 1 | import enum 2 | 3 | 4 | class UserRole(enum.Enum): 5 | ADMIN = 'admin' 6 | GUEST = 'guest' 7 | -------------------------------------------------------------------------------- /env.example: -------------------------------------------------------------------------------- 1 | PROJECT_NAME=FASTAPI BASE 2 | SECRET_KEY=123456 3 | SQL_DATABASE_URL=postgresql+psycopg2://postgres:postgres@db:5432/postgres -------------------------------------------------------------------------------- /tests/env.example: -------------------------------------------------------------------------------- 1 | SQLALCHEMY_DATABASE_URL = 'postgresql+psycopg2://db_user:secret123@localhost:5432/fastapi_base_testing' 2 | SECRET_KEY = 12345678 -------------------------------------------------------------------------------- /tests/faker/__init__.py: -------------------------------------------------------------------------------- 1 | import faker.providers 2 | 3 | from tests.faker.user_provider import UserProvider 4 | 5 | fake = faker.Faker() 6 | 7 | fake.add_provider(UserProvider) 8 | -------------------------------------------------------------------------------- /app/models/__init__.py: -------------------------------------------------------------------------------- 1 | # Import all the models, so that Base has them before being 2 | # imported by Alembic 3 | from app.models.model_base import Base # noqa 4 | from app.models.model_user import User # noqa 5 | -------------------------------------------------------------------------------- /app/schemas/sche_token.py: -------------------------------------------------------------------------------- 1 | from typing import Optional 2 | 3 | from pydantic import BaseModel 4 | 5 | 6 | class Token(BaseModel): 7 | access_token: str 8 | token_type: str = 'bearer' 9 | 10 | 11 | class TokenPayload(BaseModel): 12 | user_id: Optional[int] = None 13 | -------------------------------------------------------------------------------- /app/api/api_healthcheck.py: -------------------------------------------------------------------------------- 1 | from fastapi import APIRouter 2 | 3 | from app.schemas.sche_base import ResponseSchemaBase 4 | 5 | router = APIRouter() 6 | 7 | 8 | @router.get("", response_model=ResponseSchemaBase) 9 | async def get(): 10 | return {"message": "Health check success"} -------------------------------------------------------------------------------- /tests/api/__init__.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | 4 | @pytest.mark.usefixtures('app_class') 5 | class APITestCase(): 6 | pass 7 | 8 | 9 | class MockResponse: 10 | def __init__(self, text, status_code): 11 | self.text = text 12 | self.status_code = status_code 13 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM python:3.8.6 2 | 3 | WORKDIR /app 4 | 5 | COPY requirements.txt . 6 | 7 | RUN pip install -r requirements.txt 8 | 9 | COPY . . 10 | 11 | RUN groupadd -g 1000 app_group 12 | 13 | RUN useradd -g app_group --uid 1000 app_user 14 | 15 | RUN chown -R app_user:app_group /app 16 | 17 | USER app_user 18 | 19 | CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"] -------------------------------------------------------------------------------- /app/models/model_user.py: -------------------------------------------------------------------------------- 1 | from sqlalchemy import Column, String, Boolean, DateTime 2 | 3 | from app.models.model_base import BareBaseModel 4 | 5 | 6 | class User(BareBaseModel): 7 | full_name = Column(String, index=True) 8 | email = Column(String, unique=True, index=True) 9 | hashed_password = Column(String(255)) 10 | is_active = Column(Boolean, default=True) 11 | role = Column(String, default='guest') 12 | last_login = Column(DateTime) 13 | -------------------------------------------------------------------------------- /app/db/base.py: -------------------------------------------------------------------------------- 1 | from typing import Generator 2 | from app.core.config import settings 3 | from sqlalchemy import create_engine 4 | from sqlalchemy.orm import sessionmaker 5 | 6 | engine = create_engine(settings.DATABASE_URL, pool_pre_ping=True) 7 | SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine) 8 | 9 | 10 | def get_db() -> Generator: 11 | try: 12 | db = SessionLocal() 13 | yield db 14 | finally: 15 | db.close() 16 | -------------------------------------------------------------------------------- /app/api/api_router.py: -------------------------------------------------------------------------------- 1 | from fastapi import APIRouter 2 | 3 | from app.api import api_user, api_login, api_register, api_healthcheck 4 | 5 | router = APIRouter() 6 | 7 | router.include_router(api_healthcheck.router, tags=["health-check"], prefix="/healthcheck") 8 | router.include_router(api_login.router, tags=["login"], prefix="/login") 9 | router.include_router(api_register.router, tags=["register"], prefix="/register") 10 | router.include_router(api_user.router, tags=["user"], prefix="/users") 11 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /logging.ini: -------------------------------------------------------------------------------- 1 | ### 2 | # logging configuration 3 | # http://docs.pylonsproject.org/projects/pyramid/en/1.5-branch/narr/logging.html 4 | ### 5 | [loggers] 6 | keys = root, app 7 | 8 | [handlers] 9 | keys = console 10 | 11 | [formatters] 12 | keys = generic 13 | 14 | [logger_root] 15 | level = WARN 16 | handlers = console 17 | 18 | [logger_app] 19 | level = DEBUG 20 | handlers = console 21 | qualname = app 22 | propagate = 0 23 | 24 | [handler_console] 25 | class = StreamHandler 26 | level = NOTSET 27 | args = (sys.stderr,) 28 | formatter = generic 29 | 30 | [formatter_generic] 31 | format = %(levelname)-10.10s %(asctime)s [%(name)s][%(module)s:%(lineno)d] %(message)s 32 | -------------------------------------------------------------------------------- /app/models/model_base.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime 2 | 3 | from sqlalchemy import Column, Integer, DateTime 4 | from sqlalchemy.ext.declarative import as_declarative, declared_attr 5 | 6 | 7 | @as_declarative() 8 | class Base: 9 | __abstract__ = True 10 | __name__: str 11 | 12 | # Generate __tablename__ automatically 13 | @declared_attr 14 | def __tablename__(cls) -> str: 15 | return cls.__name__.lower() 16 | 17 | 18 | class BareBaseModel(Base): 19 | __abstract__ = True 20 | 21 | id = Column(Integer, primary_key=True, autoincrement=True) 22 | created_at = Column(DateTime, default=datetime.now) 23 | updated_at = Column(DateTime, default=datetime.now, onupdate=datetime.now) 24 | -------------------------------------------------------------------------------- /alembic.ini: -------------------------------------------------------------------------------- 1 | [alembic] 2 | # path to migration scripts 3 | script_location = alembic 4 | 5 | # Logging configuration 6 | [loggers] 7 | keys = root,sqlalchemy,alembic 8 | 9 | [handlers] 10 | keys = console 11 | 12 | [formatters] 13 | keys = generic 14 | 15 | [logger_root] 16 | level = WARN 17 | handlers = console 18 | qualname = 19 | 20 | [logger_sqlalchemy] 21 | level = WARN 22 | handlers = 23 | qualname = sqlalchemy.engine 24 | 25 | [logger_alembic] 26 | level = INFO 27 | handlers = 28 | qualname = alembic 29 | 30 | [handler_console] 31 | class = StreamHandler 32 | args = (sys.stderr,) 33 | level = NOTSET 34 | formatter = generic 35 | 36 | [formatter_generic] 37 | format = %(levelname)-5.5s [%(name)s] %(message)s 38 | datefmt = %H:%M:%S 39 | -------------------------------------------------------------------------------- /app/core/config.py: -------------------------------------------------------------------------------- 1 | import os 2 | from dotenv import load_dotenv 3 | from pydantic import BaseSettings 4 | 5 | BASE_DIR = os.path.abspath(os.path.join(os.path.dirname(__file__), '../../')) 6 | load_dotenv(os.path.join(BASE_DIR, '.env')) 7 | 8 | 9 | class Settings(BaseSettings): 10 | PROJECT_NAME = os.getenv('PROJECT_NAME', 'FASTAPI BASE') 11 | SECRET_KEY = os.getenv('SECRET_KEY', '') 12 | API_PREFIX = '' 13 | BACKEND_CORS_ORIGINS = ['*'] 14 | DATABASE_URL = os.getenv('SQL_DATABASE_URL', '') 15 | ACCESS_TOKEN_EXPIRE_SECONDS: int = 60 * 60 * 24 * 7 # Token expired after 7 days 16 | SECURITY_ALGORITHM = 'HS256' 17 | LOGGING_CONFIG_FILE = os.path.join(BASE_DIR, 'logging.ini') 18 | 19 | 20 | settings = Settings() 21 | -------------------------------------------------------------------------------- /app/api/api_register.py: -------------------------------------------------------------------------------- 1 | from typing import Any 2 | 3 | from fastapi import APIRouter, Depends 4 | 5 | from app.helpers.exception_handler import CustomException 6 | from app.schemas.sche_base import DataResponse 7 | from app.schemas.sche_user import UserItemResponse, UserRegisterRequest 8 | from app.services.srv_user import UserService 9 | 10 | router = APIRouter() 11 | 12 | 13 | @router.post('', response_model=DataResponse[UserItemResponse]) 14 | def register(register_data: UserRegisterRequest, user_service: UserService = Depends()) -> Any: 15 | try: 16 | register_user = user_service.register_user(register_data) 17 | return DataResponse().success_response(data=register_user) 18 | except Exception as e: 19 | raise CustomException(http_code=400, code='400', message=str(e)) 20 | -------------------------------------------------------------------------------- /app/helpers/login_manager.py: -------------------------------------------------------------------------------- 1 | from fastapi import HTTPException, Depends 2 | 3 | from app.models import User 4 | from app.services.srv_user import UserService 5 | 6 | 7 | def login_required(http_authorization_credentials=Depends(UserService().reusable_oauth2)): 8 | return UserService().get_current_user(http_authorization_credentials) 9 | 10 | 11 | class PermissionRequired: 12 | def __init__(self, *args): 13 | self.user = None 14 | self.permissions = args 15 | 16 | def __call__(self, user: User = Depends(login_required)): 17 | self.user = user 18 | if self.user.role not in self.permissions and self.permissions: 19 | raise HTTPException(status_code=400, 20 | detail=f'User {self.user.email} can not access this api') 21 | -------------------------------------------------------------------------------- /app/core/security.py: -------------------------------------------------------------------------------- 1 | import jwt 2 | 3 | from typing import Any, Union 4 | from app.core.config import settings 5 | from datetime import datetime, timedelta 6 | from passlib.context import CryptContext 7 | 8 | pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto") 9 | 10 | 11 | def create_access_token(user_id: Union[int, Any]) -> str: 12 | expire = datetime.utcnow() + timedelta( 13 | seconds=settings.ACCESS_TOKEN_EXPIRE_SECONDS 14 | ) 15 | to_encode = { 16 | "exp": expire, "user_id": str(user_id) 17 | } 18 | encoded_jwt = jwt.encode(to_encode, settings.SECRET_KEY, algorithm=settings.SECURITY_ALGORITHM) 19 | return encoded_jwt 20 | 21 | 22 | def verify_password(plain_password: str, hashed_password: str) -> bool: 23 | return pwd_context.verify(plain_password, hashed_password) 24 | 25 | 26 | def get_password_hash(password: str) -> str: 27 | return pwd_context.hash(password) 28 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | alembic==1.5.8 2 | anyio==3.6.1 3 | attrs==20.3.0 4 | bcrypt==3.2.0 5 | certifi==2020.12.5 6 | cffi==1.14.5 7 | chardet==4.0.0 8 | click==7.1.2 9 | dnspython==2.1.0 10 | docstring-parser==0.7.3 11 | email-validator==1.1.2 12 | Faker==7.0.0 13 | fastapi==0.85.0 14 | FastAPI-SQLAlchemy==0.2.1 15 | greenlet==1.0.0 16 | h11==0.12.0 17 | idna==2.10 18 | importlib-metadata==5.0.0 19 | iniconfig==1.1.1 20 | Mako==1.1.4 21 | MarkupSafe==1.1.1 22 | numpy==1.21.6 23 | packaging==20.9 24 | pandas==1.3.5 25 | passlib==1.7.4 26 | pluggy==0.13.1 27 | psycopg2-binary==2.9.4 28 | py==1.10.0 29 | pycparser==2.20 30 | pydantic==1.10.2 31 | PyJWT==2.0.1 32 | pyparsing==2.4.7 33 | pytest==6.2.2 34 | python-dateutil==2.8.1 35 | python-dotenv==0.15.0 36 | python-editor==1.0.4 37 | python-multipart==0.0.5 38 | pytz==2022.4 39 | requests==2.25.1 40 | six==1.15.0 41 | sniffio==1.3.0 42 | SQLAlchemy==1.4.3 43 | starlette==0.20.4 44 | text-unidecode==1.3 45 | toml==0.10.2 46 | typing_extensions==4.4.0 47 | urllib3==1.26.4 48 | uvicorn==0.13.4 49 | zipp==3.9.0 50 | -------------------------------------------------------------------------------- /docker-compose.yaml: -------------------------------------------------------------------------------- 1 | version: '3.7' 2 | 3 | services: 4 | db: 5 | image: postgres:13.2 6 | volumes: 7 | - ./docker/db:/var/lib/postgresql/data 8 | - ./postgresql.conf:/etc/postgresql/postgresql.conf 9 | expose: 10 | - 5432 11 | environment: 12 | - POSTGRES_DB=postgres 13 | - POSTGRES_USER=postgres 14 | - POSTGRES_PASSWORD=postgres 15 | ports: 16 | - "5433:5432" 17 | restart: unless-stopped 18 | 19 | alembic: 20 | image: fastapi-base:latest 21 | environment: 22 | SQL_DATABASE_URL: 'postgresql+psycopg2://postgres:postgres@db:5432/postgres' 23 | SECRET_KEY: ${SECRET_KEY} 24 | command: [ "alembic", "upgrade", "head" ] 25 | depends_on: 26 | - db 27 | 28 | app: 29 | image: fastapi-base:latest 30 | environment: 31 | PROJECT_NAME: ${PROJECT_NAME} 32 | SQL_DATABASE_URL: 'postgresql+psycopg2://postgres:postgres@db:5432/postgres' 33 | SECRET_KEY: ${SECRET_KEY} 34 | ports: 35 | - "8000:8000" 36 | restart: unless-stopped 37 | depends_on: 38 | - db 39 | - alembic -------------------------------------------------------------------------------- /tests/faker/user_provider.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import faker.providers 3 | 4 | from app.helpers.enums import UserRole 5 | from app.models import User 6 | from fastapi_sqlalchemy import db 7 | from app.core.security import get_password_hash 8 | 9 | logger = logging.getLogger() 10 | fake = faker.Faker() 11 | 12 | 13 | class UserProvider(faker.providers.BaseProvider): 14 | @staticmethod 15 | def user(data={}): 16 | """ 17 | Fake an user in db for testing 18 | :return: user model object 19 | """ 20 | user = User( 21 | full_name=data.get('name') or fake.name(), 22 | email=data.get('email') or fake.email(), 23 | hashed_password=get_password_hash(data.get('password')) or get_password_hash(fake.lexify(text='?????????')), 24 | is_active=data.get('is_active') if data.get('is_active') is not None else True, 25 | role=data.get('role') if data.get('role') is not None else UserRole.GUEST.value 26 | ) 27 | with db(): 28 | db.session.add(user) 29 | db.session.commit() 30 | db.session.refresh(user) 31 | return user 32 | -------------------------------------------------------------------------------- /tests/api/test_register.py: -------------------------------------------------------------------------------- 1 | import json 2 | import random 3 | 4 | from starlette.testclient import TestClient 5 | 6 | from app.core.config import settings 7 | from app.helpers.enums import UserRole 8 | from tests.faker.user_provider import fake 9 | 10 | 11 | class TestRegister: 12 | def test_success(self, client: TestClient): 13 | """ 14 | Test api user register success 15 | Step by step: 16 | - Gọi API Register với đầu vào chuẩn 17 | - Đầu ra mong muốn: 18 | . status code: 200 19 | """ 20 | register_data = { 21 | 'full_name': fake.name(), 22 | 'email': fake.email(), 23 | 'password': 'secret123', 24 | 'role': random.choice(list(UserRole)).value 25 | } 26 | print(f'[x] register_data: {register_data}') 27 | r = client.post(f"{settings.API_PREFIX}/register", json=register_data) 28 | print(f'[x] Response: {r.json()}') 29 | assert r.status_code == 200 30 | response = r.json() 31 | assert response['code'] == '000' 32 | assert response['message'] == 'Thành công' 33 | assert response['data']['email'] is not None 34 | -------------------------------------------------------------------------------- /app/api/api_login.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime 2 | 3 | from fastapi import APIRouter, HTTPException, Depends 4 | from fastapi_sqlalchemy import db 5 | from pydantic import EmailStr, BaseModel 6 | 7 | from app.core.security import create_access_token 8 | from app.schemas.sche_base import DataResponse 9 | from app.schemas.sche_token import Token 10 | from app.services.srv_user import UserService 11 | 12 | router = APIRouter() 13 | 14 | 15 | class LoginRequest(BaseModel): 16 | username: EmailStr = 'long.dh@teko.vn' 17 | password: str = 'secret123' 18 | 19 | 20 | @router.post('', response_model=DataResponse[Token]) 21 | def login_access_token(form_data: LoginRequest, user_service: UserService = Depends()): 22 | user = user_service.authenticate(email=form_data.username, password=form_data.password) 23 | if not user: 24 | raise HTTPException(status_code=400, detail='Incorrect email or password') 25 | elif not user.is_active: 26 | raise HTTPException(status_code=401, detail='Inactive user') 27 | 28 | user.last_login = datetime.now() 29 | db.session.commit() 30 | 31 | return DataResponse().success_response({ 32 | 'access_token': create_access_token(user_id=user.id) 33 | }) 34 | -------------------------------------------------------------------------------- /app/schemas/sche_base.py: -------------------------------------------------------------------------------- 1 | from typing import Optional, TypeVar, Generic 2 | from pydantic.generics import GenericModel 3 | 4 | from pydantic import BaseModel 5 | 6 | T = TypeVar("T") 7 | 8 | 9 | class ResponseSchemaBase(BaseModel): 10 | __abstract__ = True 11 | 12 | code: str = '' 13 | message: str = '' 14 | 15 | def custom_response(self, code: str, message: str): 16 | self.code = code 17 | self.message = message 18 | return self 19 | 20 | def success_response(self): 21 | self.code = '000' 22 | self.message = 'Thành công' 23 | return self 24 | 25 | 26 | class DataResponse(ResponseSchemaBase, GenericModel, Generic[T]): 27 | data: Optional[T] = None 28 | 29 | class Config: 30 | arbitrary_types_allowed = True 31 | 32 | def custom_response(self, code: str, message: str, data: T): 33 | self.code = code 34 | self.message = message 35 | self.data = data 36 | return self 37 | 38 | def success_response(self, data: T): 39 | self.code = '000' 40 | self.message = 'Thành công' 41 | self.data = data 42 | return self 43 | 44 | 45 | class MetadataSchema(BaseModel): 46 | current_page: int 47 | page_size: int 48 | total_items: int 49 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # docker volumes 2 | /volumes 3 | 4 | # Byte-compiled / optimized / DLL files 5 | __pycache__/ 6 | 7 | # C extensions 8 | *.so 9 | 10 | # Distribution / packaging 11 | .Python 12 | env/ 13 | build/ 14 | develop-eggs/ 15 | dist/ 16 | downloads/ 17 | eggs/ 18 | .eggs/ 19 | .idea/ 20 | lib/ 21 | lib64/ 22 | parts/ 23 | sdist/ 24 | wheels/ 25 | *.db 26 | *.egg-info/ 27 | .installed.cfg 28 | *.egg 29 | 30 | # PyInstaller 31 | # Usually these files are written by a python script from a template 32 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 33 | *.manifest 34 | *.spec 35 | 36 | # Installer logs 37 | pip-log.txt 38 | pip-delete-this-directory.txt 39 | 40 | # Unit test / coverage reports 41 | htmlcov/ 42 | .tox/ 43 | .coverage 44 | .coverage.* 45 | .cache 46 | nosetests.xml 47 | coverage.xml 48 | *.cover 49 | .hypothesis/ 50 | 51 | # Flask stuff: 52 | instance/ 53 | .webassets-cache 54 | 55 | # Scrapy stuff: 56 | .scrapy 57 | 58 | # Sphinx documentation 59 | docs/_build/ 60 | 61 | # PyBuilder 62 | target/ 63 | 64 | # Jupyter Notebook 65 | .ipynb_checkpoints 66 | 67 | # celery beat schedule file 68 | celerybeat-schedule 69 | 70 | # dotenv 71 | .env 72 | 73 | # virtualenv 74 | .venv/* 75 | venv/* 76 | 77 | # docker local data 78 | docker/ 79 | 80 | # .DS_Store 81 | .DS_Store 82 | */.DS_Store -------------------------------------------------------------------------------- /app/schemas/sche_user.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime 2 | from typing import Optional 3 | 4 | from pydantic import BaseModel, EmailStr 5 | 6 | from app.helpers.enums import UserRole 7 | 8 | 9 | class UserBase(BaseModel): 10 | full_name: Optional[str] = None 11 | email: Optional[EmailStr] = None 12 | is_active: Optional[bool] = True 13 | 14 | class Config: 15 | orm_mode = True 16 | 17 | 18 | class UserItemResponse(UserBase): 19 | id: int 20 | full_name: str 21 | email: EmailStr 22 | is_active: bool 23 | role: str 24 | last_login: Optional[datetime] 25 | 26 | 27 | class UserCreateRequest(UserBase): 28 | full_name: Optional[str] 29 | password: str 30 | email: EmailStr 31 | is_active: bool = True 32 | role: UserRole = UserRole.GUEST 33 | 34 | 35 | class UserRegisterRequest(BaseModel): 36 | full_name: str 37 | email: EmailStr 38 | password: str 39 | role: UserRole = UserRole.GUEST 40 | 41 | 42 | class UserUpdateMeRequest(BaseModel): 43 | full_name: Optional[str] 44 | email: Optional[EmailStr] 45 | password: Optional[str] 46 | 47 | 48 | class UserUpdateRequest(BaseModel): 49 | full_name: Optional[str] 50 | email: Optional[EmailStr] 51 | password: Optional[str] 52 | is_active: Optional[bool] = True 53 | role: Optional[UserRole] 54 | -------------------------------------------------------------------------------- /alembic/versions/f9a075ca46e9_.py: -------------------------------------------------------------------------------- 1 | """empty message 2 | 3 | Revision ID: f9a075ca46e9 4 | Revises: 5 | Create Date: 2021-03-26 16:00:56.723890 6 | 7 | """ 8 | from alembic import op 9 | import sqlalchemy as sa 10 | 11 | 12 | # revision identifiers, used by Alembic. 13 | revision = 'f9a075ca46e9' 14 | down_revision = None 15 | branch_labels = None 16 | depends_on = None 17 | 18 | 19 | def upgrade(): 20 | # ### commands auto generated by Alembic - please adjust! ### 21 | op.create_table('user', 22 | sa.Column('id', sa.Integer(), autoincrement=True, nullable=False), 23 | sa.Column('created_at', sa.DateTime(), nullable=True), 24 | sa.Column('updated_at', sa.DateTime(), nullable=True), 25 | sa.Column('full_name', sa.String(), nullable=True), 26 | sa.Column('email', sa.String(), nullable=True), 27 | sa.Column('hashed_password', sa.String(length=255), nullable=True), 28 | sa.Column('is_active', sa.Boolean(), nullable=True), 29 | sa.Column('role', sa.String(), nullable=True), 30 | sa.Column('last_login', sa.DateTime(), nullable=True), 31 | sa.PrimaryKeyConstraint('id') 32 | ) 33 | op.create_index(op.f('ix_user_email'), 'user', ['email'], unique=True) 34 | op.create_index(op.f('ix_user_full_name'), 'user', ['full_name'], unique=False) 35 | # ### end Alembic commands ### 36 | 37 | 38 | def downgrade(): 39 | # ### commands auto generated by Alembic - please adjust! ### 40 | op.drop_index(op.f('ix_user_full_name'), table_name='user') 41 | op.drop_index(op.f('ix_user_email'), table_name='user') 42 | op.drop_table('user') 43 | # ### end Alembic commands ### 44 | -------------------------------------------------------------------------------- /app/main.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | import uvicorn 4 | from fastapi import FastAPI 5 | from fastapi_sqlalchemy import DBSessionMiddleware 6 | from starlette.middleware.cors import CORSMiddleware 7 | 8 | from app.api.api_router import router 9 | from app.models import Base 10 | from app.db.base import engine 11 | from app.core.config import settings 12 | from app.helpers.exception_handler import CustomException, http_exception_handler 13 | 14 | logging.config.fileConfig(settings.LOGGING_CONFIG_FILE, disable_existing_loggers=False) 15 | Base.metadata.create_all(bind=engine) 16 | 17 | 18 | def get_application() -> FastAPI: 19 | application = FastAPI( 20 | title=settings.PROJECT_NAME, docs_url="/docs", redoc_url='/re-docs', 21 | openapi_url=f"{settings.API_PREFIX}/openapi.json", 22 | description=''' 23 | Base frame with FastAPI micro framework + Postgresql 24 | - Login/Register with JWT 25 | - Permission 26 | - CRUD User 27 | - Unit testing with Pytest 28 | - Dockerize 29 | ''' 30 | ) 31 | application.add_middleware( 32 | CORSMiddleware, 33 | allow_origins=[str(origin) for origin in settings.BACKEND_CORS_ORIGINS], 34 | allow_credentials=True, 35 | allow_methods=["*"], 36 | allow_headers=["*"], 37 | ) 38 | application.add_middleware(DBSessionMiddleware, db_url=settings.DATABASE_URL) 39 | application.include_router(router, prefix=settings.API_PREFIX) 40 | application.add_exception_handler(CustomException, http_exception_handler) 41 | 42 | return application 43 | 44 | 45 | app = get_application() 46 | if __name__ == '__main__': 47 | uvicorn.run(app, host="0.0.0.0", port=8000) 48 | -------------------------------------------------------------------------------- /document/CREATE_DB_GUIDE.md: -------------------------------------------------------------------------------- 1 | ## Guide: Tạo bảng Book quản lý list sách bằng migration 2 | 3 | --- 4 | Trong folder `app/models` tạo file `model_book.py` với nội dung 5 | ``` 6 | # app/models/model_book.py 7 | from sqlalchemy import Column, String, DateTime 8 | 9 | from app.models.model_base import BareBaseModel 10 | 11 | 12 | class Book(BareBaseModel): 13 | name = Column(String(200), index=True) 14 | author = Column(String(255)) 15 | publishing_year = Column(DateTime) 16 | 17 | ``` 18 | 19 | --- 20 | Thêm 1 dòng vào `app/models/__init__.py` ở dưới 21 | ``` 22 | # app/models/____init__.py 23 | 24 | ... 25 | from app.models.model_book import Book # noqa 26 | ``` 27 | 28 | --- 29 | Mở FastAPI Terminal 30 | ``` 31 | $ alembic revision --autogenerate # Create migration versions 32 | ``` 33 | 34 | --- 35 | Sau bước này, 1 file migration với file name random sẽ được generate tại `alembic/versions/` 36 | ``` 37 | # alembic/versions/6f0df0651d97_.py 38 | ... 39 | def upgrade(): 40 | # ### commands auto generated by Alembic - please adjust! ### 41 | op.create_table('book', 42 | sa.Column('id', sa.Integer(), autoincrement=True, nullable=False), 43 | sa.Column('created_at', sa.DateTime(), nullable=True), 44 | sa.Column('updated_at', sa.DateTime(), nullable=True), 45 | sa.Column('name', sa.String(length=200), nullable=True), 46 | sa.Column('author', sa.String(length=255), nullable=True), 47 | sa.Column('publishing_year', sa.DateTime(), nullable=True), 48 | sa.PrimaryKeyConstraint('id') 49 | ) 50 | op.create_index(op.f('ix_book_name'), 'book', ['name'], unique=False) 51 | op.drop_table('table_name') 52 | # ### end Alembic commands ### 53 | ... 54 | ``` 55 | 56 | --- 57 | Run migration để update Database 58 | ``` 59 | $ alembic upgrade head # Upgrade to last version migration 60 | ``` 61 | 62 | --- 63 | Cuối cùng, check lại thay đổi trong Database -------------------------------------------------------------------------------- /app/helpers/exception_handler.py: -------------------------------------------------------------------------------- 1 | import enum 2 | 3 | from fastapi import Request 4 | from fastapi.encoders import jsonable_encoder 5 | from fastapi.responses import JSONResponse 6 | from app.schemas.sche_base import ResponseSchemaBase 7 | 8 | 9 | class ExceptionType(enum.Enum): 10 | MS_UNAVAILABLE = 500, '990', 'Hệ thống đang bảo trì, quý khách vui lòng thử lại sau' 11 | MS_INVALID_API_PATH = 500, '991', 'Hệ thống đang bảo trì, quý khách vui lòng thử lại sau' 12 | DATA_RESPONSE_MALFORMED = 500, '992', 'Có lỗi xảy ra, vui lòng liên hệ admin!' 13 | 14 | def __new__(cls, *args, **kwds): 15 | value = len(cls.__members__) + 1 16 | obj = object.__new__(cls) 17 | obj._value_ = value 18 | return obj 19 | 20 | def __init__(self, http_code, code, message): 21 | self.http_code = http_code 22 | self.code = code 23 | self.message = message 24 | 25 | 26 | class CustomException(Exception): 27 | http_code: int 28 | code: str 29 | message: str 30 | 31 | def __init__(self, http_code: int = None, code: str = None, message: str = None): 32 | self.http_code = http_code if http_code else 500 33 | self.code = code if code else str(self.http_code) 34 | self.message = message 35 | 36 | 37 | async def http_exception_handler(request: Request, exc: CustomException): 38 | return JSONResponse( 39 | status_code=exc.http_code, 40 | content=jsonable_encoder(ResponseSchemaBase().custom_response(exc.code, exc.message)) 41 | ) 42 | 43 | 44 | async def validation_exception_handler(request, exc): 45 | return JSONResponse( 46 | status_code=400, 47 | content=jsonable_encoder(ResponseSchemaBase().custom_response('400', get_message_validation(exc))) 48 | ) 49 | 50 | 51 | async def fastapi_error_handler(request, exc): 52 | return JSONResponse( 53 | status_code=500, 54 | content=jsonable_encoder(ResponseSchemaBase().custom_response('500', "Có lỗi xảy ra, vui lòng liên hệ admin!")) 55 | ) 56 | 57 | 58 | def get_message_validation(exc): 59 | message = "" 60 | for error in exc.errors(): 61 | message += "/'" + str(error.get("loc")[1]) + "'/" + ': ' + error.get("msg") + ", " 62 | 63 | message = message[:-2] 64 | 65 | return message 66 | -------------------------------------------------------------------------------- /tests/api/test_login.py: -------------------------------------------------------------------------------- 1 | from starlette.testclient import TestClient 2 | 3 | from app.core.config import settings 4 | from app.models import User 5 | from tests.faker import fake 6 | 7 | 8 | class TestLogin: 9 | def test_success(self, client: TestClient): 10 | """ 11 | Test api user login success 12 | Step by step: 13 | - Khởi tạo data mẫu với password hash 14 | - Gọi API Login 15 | - Đầu ra mong muốn: 16 | . status code: 200 17 | . access_token != null 18 | . token_type == 'bearer' 19 | """ 20 | current_user: User = fake.user({'password': 'secret123'}) 21 | r = client.post(f"{settings.API_PREFIX}/login", data={ 22 | 'username': current_user.email, 23 | 'password': 'secret123' 24 | }) 25 | assert r.status_code == 200 26 | response = r.json() 27 | assert response['data']['access_token'] is not None 28 | assert response['data']['token_type'] == 'bearer' 29 | 30 | def test_incorrect_password(self, client: TestClient): 31 | """ 32 | Test api user login with incorrect password 33 | Step by step: 34 | - Khởi tạo data mẫu với password hash 35 | - Gọi API Login với wrong password 36 | - Đầu ra mong muốn: 37 | . status code: 400 38 | """ 39 | current_user: User = fake.user({'password': 'secret123'}) 40 | r = client.post(f"{settings.API_PREFIX}/login", data={ 41 | 'username': current_user.email, 42 | 'password': 'secret1234' 43 | }) 44 | assert r.status_code == 400 45 | 46 | def test_user_inactive(self, client: TestClient): 47 | """ 48 | Test api user is_active = False 49 | Step by step: 50 | - Khởi tạo data mẫu với password hash và is_active = False 51 | - Gọi API Login 52 | - Đầu ra mong muốn: 53 | . status code: 401 54 | """ 55 | current_user: User = fake.user({'password': 'secret123', 'is_active': False}) 56 | r = client.post(f"{settings.API_PREFIX}/login", data={ 57 | 'username': current_user.email, 58 | 'password': 'secret123' 59 | }) 60 | assert r.status_code == 401 61 | -------------------------------------------------------------------------------- /app/helpers/paging.py: -------------------------------------------------------------------------------- 1 | import logging 2 | from pydantic import BaseModel, conint 3 | from abc import ABC, abstractmethod 4 | from typing import Optional, Generic, Sequence, Type, TypeVar 5 | 6 | from sqlalchemy import asc, desc 7 | from sqlalchemy.orm import Query 8 | from pydantic.generics import GenericModel 9 | from contextvars import ContextVar 10 | 11 | from app.schemas.sche_base import ResponseSchemaBase, MetadataSchema 12 | from app.helpers.exception_handler import CustomException 13 | 14 | T = TypeVar("T") 15 | C = TypeVar("C") 16 | 17 | logger = logging.getLogger() 18 | 19 | 20 | class PaginationParams(BaseModel): 21 | page_size: Optional[conint(gt=0, lt=1001)] = 10 22 | page: Optional[conint(gt=0)] = 1 23 | sort_by: Optional[str] = 'id' 24 | order: Optional[str] = 'desc' 25 | 26 | 27 | class BasePage(ResponseSchemaBase, GenericModel, Generic[T], ABC): 28 | data: Sequence[T] 29 | 30 | class Config: 31 | arbitrary_types_allowed = True 32 | 33 | @classmethod 34 | @abstractmethod 35 | def create(cls: Type[C], code: str, message: str, data: Sequence[T], metadata: MetadataSchema) -> C: 36 | pass # pragma: no cover 37 | 38 | 39 | class Page(BasePage[T], Generic[T]): 40 | metadata: MetadataSchema 41 | 42 | @classmethod 43 | def create(cls, code: str, message: str, data: Sequence[T], metadata: MetadataSchema) -> "Page[T]": 44 | return cls( 45 | code=code, 46 | message=message, 47 | data=data, 48 | metadata=metadata 49 | ) 50 | 51 | 52 | PageType: ContextVar[Type[BasePage]] = ContextVar("PageType", default=Page) 53 | 54 | 55 | def paginate(model, query: Query, params: Optional[PaginationParams]) -> BasePage: 56 | code = '200' 57 | message = 'Success' 58 | 59 | try: 60 | total = query.count() 61 | 62 | if params.order: 63 | direction = desc if params.order == 'desc' else asc 64 | query = query.order_by(direction(getattr(model, params.sort_by))) 65 | 66 | data = query.limit(params.page_size).offset(params.page_size * (params.page-1)).all() 67 | 68 | metadata = MetadataSchema( 69 | current_page=params.page, 70 | page_size=params.page_size, 71 | total_items=total 72 | ) 73 | 74 | except Exception as e: 75 | raise CustomException(http_code=500, code='500', message=str(e)) 76 | 77 | return PageType.get().create(code, message, data, metadata) 78 | -------------------------------------------------------------------------------- /.gitlab-ci.yml: -------------------------------------------------------------------------------- 1 | image: docker/compose:alpine-1.27.4 2 | 3 | services: 4 | - docker:dind 5 | 6 | stages: 7 | - build 8 | - release 9 | - deploy 10 | 11 | before_script: 12 | - docker version 13 | - docker login -u $CI_REGISTRY_USER -p $CI_REGISTRY_PASSWORD $CI_REGISTRY 14 | 15 | build: 16 | stage: build 17 | script: 18 | - docker pull $CI_REGISTRY_IMAGE:latest || true 19 | - docker build --cache-from $CI_REGISTRY_IMAGE:latest --tag $CI_REGISTRY_IMAGE:$CI_COMMIT_SHA . 20 | - docker push $CI_REGISTRY_IMAGE:$CI_COMMIT_SHA 21 | 22 | release-tag: 23 | variables: 24 | GIT_STRATEGY: none 25 | stage: release 26 | only: 27 | refs: 28 | - develop 29 | - staging 30 | - test 31 | except: 32 | - master 33 | script: 34 | - docker pull $CI_REGISTRY_IMAGE:$CI_COMMIT_SHA 35 | - docker tag $CI_REGISTRY_IMAGE:$CI_COMMIT_SHA $CI_REGISTRY_IMAGE:$CI_COMMIT_REF_NAME 36 | - docker push $CI_REGISTRY_IMAGE:$CI_COMMIT_REF_NAME 37 | 38 | release-latest: 39 | variables: 40 | GIT_STRATEGY: none 41 | stage: release 42 | only: 43 | refs: 44 | - master 45 | script: 46 | - docker pull $CI_REGISTRY_IMAGE:$CI_COMMIT_SHA 47 | - docker tag $CI_REGISTRY_IMAGE:$CI_COMMIT_SHA $CI_REGISTRY_IMAGE:latest 48 | - docker push $CI_REGISTRY_IMAGE:latest 49 | 50 | deploy: 51 | stage: deploy 52 | variables: 53 | GIT_STRATEGY: none 54 | only: 55 | refs: 56 | - master 57 | before_script: 58 | - apk update && apk add openssh-client bash 59 | script: 60 | # chạy ssh-agent tương ứng với Gitlab Runner hiện tại 61 | - eval $(ssh-agent -s) 62 | 63 | # thêm nội dung của biến SSH_PRIVATE_KEY vào agent store 64 | - bash -c 'ssh-add <(echo "$SSH_PRIVATE_KEY")' 65 | 66 | # tạo folder ~/.ssh 67 | - mkdir -p ~/.ssh 68 | 69 | # Scan lấy SSH Host key cho địa chỉ IP server 70 | # Được kết quả bao nhiêu thì thêm vào file known_hosts 71 | - ssh-keyscan -H $SSH_SERVER_IP >> ~/.ssh/known_hosts 72 | 73 | # Sửa lại quyền của file known_hosts 74 | - chmod 644 ~/.ssh/known_hosts 75 | 76 | # Thực hiện SSH vào server, login vào Registry, chuyển tới folder project 77 | # Down project, pull image về, up project và xoá đi image cũ 78 | - > 79 | ssh $SSH_USER@$SSH_SERVER_IP 80 | "docker login -u ${CI_REGISTRY_USER} -p ${CI_REGISTRY_PASSWORD} ${CI_REGISTRY}; 81 | cd ${PATH_TO_PROJECT}; 82 | docker-compose down; 83 | docker pull ${CI_REGISTRY_IMAGE}:latest; 84 | docker-compose up -d; 85 | docker image prune -f;" -------------------------------------------------------------------------------- /alembic/env.py: -------------------------------------------------------------------------------- 1 | import os 2 | import sys 3 | from logging.config import fileConfig 4 | 5 | from dotenv import load_dotenv 6 | from sqlalchemy import engine_from_config 7 | from sqlalchemy import pool 8 | 9 | from alembic import context 10 | 11 | BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) 12 | load_dotenv(os.path.join(BASE_DIR, ".env")) 13 | sys.path.append(BASE_DIR) 14 | 15 | # this is the Alembic Config object, which provides 16 | # access to the values within the .ini file in use. 17 | config = context.config 18 | config.set_main_option("sqlalchemy.url", os.environ["SQL_DATABASE_URL"]) 19 | 20 | # Interpret the config file for Python logging. 21 | # This line sets up loggers basically. 22 | fileConfig(config.config_file_name) 23 | 24 | # add your model's MetaData object here 25 | # for 'autogenerate' support 26 | # from myapp import mymodel 27 | # target_metadata = mymodel.Base.metadata 28 | from app.models import Base 29 | 30 | target_metadata = Base.metadata 31 | 32 | 33 | # other values from the config, defined by the needs of env.py, 34 | # can be acquired: 35 | # my_important_option = config.get_main_option("my_important_option") 36 | # ... etc. 37 | 38 | 39 | def run_migrations_offline(): 40 | """Run migrations in 'offline' mode. 41 | 42 | This configures the context with just a URL 43 | and not an Engine, though an Engine is acceptable 44 | here as well. By skipping the Engine creation 45 | we don't even need a DBAPI to be available. 46 | 47 | Calls to context.execute() here emit the given string to the 48 | script output. 49 | 50 | """ 51 | url = config.get_main_option("sqlalchemy.url", os.environ["SQL_DATABASE_URL"]) 52 | context.configure( 53 | url=url, 54 | target_metadata=target_metadata, 55 | literal_binds=True, 56 | dialect_opts={"paramstyle": "named"}, 57 | ) 58 | 59 | with context.begin_transaction(): 60 | context.run_migrations() 61 | 62 | 63 | def run_migrations_online(): 64 | """Run migrations in 'online' mode. 65 | 66 | In this scenario we need to create an Engine 67 | and associate a connection with the context. 68 | 69 | """ 70 | connectable = engine_from_config( 71 | config.get_section(config.config_ini_section), 72 | prefix="sqlalchemy.", 73 | poolclass=pool.NullPool, 74 | ) 75 | 76 | with connectable.connect() as connection: 77 | context.configure( 78 | connection=connection, target_metadata=target_metadata 79 | ) 80 | 81 | with context.begin_transaction(): 82 | context.run_migrations() 83 | 84 | 85 | if context.is_offline_mode(): 86 | run_migrations_offline() 87 | else: 88 | run_migrations_online() 89 | -------------------------------------------------------------------------------- /app/api/api_user.py: -------------------------------------------------------------------------------- 1 | import logging 2 | from typing import Any 3 | 4 | from fastapi import APIRouter, Depends, HTTPException 5 | from fastapi_sqlalchemy import db 6 | 7 | from app.helpers.exception_handler import CustomException 8 | from app.helpers.login_manager import login_required, PermissionRequired 9 | from app.helpers.paging import Page, PaginationParams, paginate 10 | from app.schemas.sche_base import DataResponse 11 | from app.schemas.sche_user import UserItemResponse, UserCreateRequest, UserUpdateMeRequest, UserUpdateRequest 12 | from app.services.srv_user import UserService 13 | from app.models import User 14 | 15 | logger = logging.getLogger() 16 | router = APIRouter() 17 | 18 | 19 | @router.get("", dependencies=[Depends(login_required)], response_model=Page[UserItemResponse]) 20 | def get(params: PaginationParams = Depends()) -> Any: 21 | """ 22 | API Get list User 23 | """ 24 | try: 25 | _query = db.session.query(User) 26 | users = paginate(model=User, query=_query, params=params) 27 | return users 28 | except Exception as e: 29 | return HTTPException(status_code=400, detail=logger.error(e)) 30 | 31 | 32 | @router.post("", dependencies=[Depends(PermissionRequired('admin'))], response_model=DataResponse[UserItemResponse]) 33 | def create(user_data: UserCreateRequest, user_service: UserService = Depends()) -> Any: 34 | """ 35 | API Create User 36 | """ 37 | try: 38 | new_user = user_service.create_user(user_data) 39 | return DataResponse().success_response(data=new_user) 40 | except Exception as e: 41 | raise CustomException(http_code=400, code='400', message=str(e)) 42 | 43 | 44 | @router.get("/me", dependencies=[Depends(login_required)], response_model=DataResponse[UserItemResponse]) 45 | def detail_me(current_user: User = Depends(UserService.get_current_user)) -> Any: 46 | """ 47 | API get detail current User 48 | """ 49 | return DataResponse().success_response(data=current_user) 50 | 51 | 52 | @router.put("/me", dependencies=[Depends(login_required)], response_model=DataResponse[UserItemResponse]) 53 | def update_me(user_data: UserUpdateMeRequest, 54 | current_user: User = Depends(UserService.get_current_user), 55 | user_service: UserService = Depends()) -> Any: 56 | """ 57 | API Update current User 58 | """ 59 | try: 60 | updated_user = user_service.update_me(data=user_data, current_user=current_user) 61 | return DataResponse().success_response(data=updated_user) 62 | except Exception as e: 63 | raise CustomException(http_code=400, code='400', message=str(e)) 64 | 65 | 66 | @router.get("/{user_id}", dependencies=[Depends(login_required)], response_model=DataResponse[UserItemResponse]) 67 | def detail(user_id: int, user_service: UserService = Depends()) -> Any: 68 | """ 69 | API get Detail User 70 | """ 71 | try: 72 | return DataResponse().success_response(data=user_service.get(user_id)) 73 | except Exception as e: 74 | raise CustomException(http_code=400, code='400', message=str(e)) 75 | 76 | 77 | @router.put("/{user_id}", dependencies=[Depends(PermissionRequired('admin'))], 78 | response_model=DataResponse[UserItemResponse]) 79 | def update(user_id: int, user_data: UserUpdateRequest, user_service: UserService = Depends()) -> Any: 80 | """ 81 | API update User 82 | """ 83 | try: 84 | updated_user = user_service.update(user_id=user_id, data=user_data) 85 | return DataResponse().success_response(data=updated_user) 86 | except Exception as e: 87 | raise CustomException(http_code=400, code='400', message=str(e)) 88 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ![alt text](logo-teal.png "FastAPI") 2 | 3 | # FASTAPI BASE 4 | 5 | ## Introduction 6 | Dựng khung project để phát triển dự án khá tốn kém thời gian và công sức. 7 | 8 | Vì vậy mình quyết định dựng FastAPI Base cung cấp sẵn các function basic nhất như CRUD User, Login, Register. 9 | 10 | Project đã bao gồm migration DB và pytest để sẵn sàng sử dụng trong môi trường doanh nghiệp. 11 | 12 | ## Source Library 13 | Đây là phiên bản Backend base từ framework FastAPI. Trong code base này đã cấu hình sẵn 14 | - [FastAPI](https://fastapi.tiangolo.com/) 15 | - [Postgresql(>=12)](https://www.postgresql.org/) 16 | - Alembic 17 | - API Login 18 | - API CRUD User, API Get Me & API Update Me 19 | - Pagination 20 | - Authen/Authorize với JWT 21 | - Permission_required & Login_required 22 | - Logging 23 | - Pytest 24 | 25 | ## Installation 26 | **Cách 1:** 27 | - Clone Project 28 | - Cài đặt Postgresql & Create Database 29 | - Cài đặt requirements.txt 30 | - Run project ở cổng 8000 31 | ``` 32 | // Tạo postgresql Databases via CLI (Ubuntu 20.04) 33 | $ sudo -u postgres psql 34 | # CREATE DATABASE fastapi_base; 35 | # CREATE USER db_user WITH PASSWORD 'secret123'; 36 | # GRANT ALL PRIVILEGES ON DATABASE fastapi_base TO db_user; 37 | 38 | // Clone project & run 39 | $ git clone https://github.com/Longdh57/fastapi-base 40 | $ cd fastapi-base 41 | $ virtualenv -p python3 .venv 42 | $ source .venv/bin/activate 43 | $ pip install -r requirements.txt 44 | $ cp env.example .env // Recheck SQL_DATABASE_URL ở bước này 45 | $ alembic upgrade head 46 | $ uvicorn app.main:app --host 0.0.0.0 --port 8000 --reload 47 | ``` 48 | **Cách 2:** Dùng Docker & Docker Compose - đơn giản hơn nhưng cần có kiến thức Docker 49 | - Clone Project 50 | - Run docker-compose 51 | ``` 52 | $ git clone https://github.com/Longdh57/fastapi-base 53 | $ cd fastapi-base 54 | $ DOCKER_BUILDKIT=1 docker build -t fastapi-base:latest . 55 | $ docker-compose up -d 56 | ``` 57 | 58 | ## Cấu trúc project 59 | ``` 60 | . 61 | ├── alembic 62 | │ ├── versions // thư mục chứa file migrations 63 | │ └── env.py 64 | ├── app 65 | │ ├── api // các file api được đặt trong này 66 | │ ├── core // chứa file config load các biến env & function tạo/verify JWT access-token 67 | │ ├── db // file cấu hình make DB session 68 | │ ├── helpers // các function hỗ trợ như login_manager, paging 69 | │ ├── models // Database model, tích hợp với alembic để auto generate migration 70 | │ ├── schemas // Pydantic Schema 71 | │ ├── services // Chứa logic CRUD giao tiếp với DB 72 | │ └── main.py // cấu hình chính của toàn bộ project 73 | ├── tests 74 | │ ├── api // chứa các file test cho từng api 75 | │ ├── faker // chứa file cấu hình faker để tái sử dụng 76 | │ ├── .env // config DB test 77 | │ └── conftest.py // cấu hình chung của pytest 78 | ├── .gitignore 79 | ├── alembic.ini 80 | ├── docker-compose.yaml 81 | ├── Dockerfile 82 | ├── env.example 83 | ├── logging.ini // cấu hình logging 84 | ├── postgresql.conf // file cấu hình postgresql, sử dụng khi run docker-compose 85 | ├── pytest.ini // file setup cho pytest 86 | ├── README.md 87 | └── requirements.txt // file chứa các thư viện để cài đặt qua pip install 88 | ``` 89 | 90 | ## Demo URL 91 | [FASTAPI-BASE](http://fastapi-base.longblog.site/docs) 92 | 93 | ## Migration 94 | Migration là một tính năng cho phép bạn thay đổi cả cấu trúc và dữ liệu trong database. 95 | 96 | Thay vì thay đổi trực tiếp vào database thì project FastAPI này sử dụng alembic để thực hiện việc thay đổi các table 97 | 98 | **File migration - SAMPLE** 99 | ``` 100 | # alembic/versions/f9a075ca46e9_.py 101 | 102 | ... 103 | def upgrade(): 104 | # ### commands auto generated by Alembic - please adjust! ### 105 | op.create_table('user', 106 | sa.Column('id', sa.Integer(), autoincrement=True, nullable=False), 107 | sa.Column('full_name', sa.String(), nullable=True), 108 | sa.Column('email', sa.String(), nullable=True), 109 | sa.Column('hashed_password', sa.String(length=255), nullable=True), 110 | sa.PrimaryKeyConstraint('id') 111 | ) 112 | op.create_index(op.f('ix_user_email'), 'user', ['email'], unique=True) 113 | op.create_index(op.f('ix_user_full_name'), 'user', ['full_name'], unique=False) 114 | # ### end Alembic commands ### 115 | 116 | 117 | def downgrade(): 118 | # ### commands auto generated by Alembic - please adjust! ### 119 | op.drop_index(op.f('ix_user_full_name'), table_name='user') 120 | op.drop_index(op.f('ix_user_email'), table_name='user') 121 | op.drop_table('user') 122 | # ### end Alembic commands ### 123 | ... 124 | ``` 125 | 126 | ## Guide: Tạo bảng Book quản lý list sách bằng migration 127 | Link [CREATE_DB_GUIDE.md](./document/CREATE_DB_GUIDE.md) 128 | 129 | 130 | ## FastAPI UI 131 | Song song với FastAPI backend, đây là phiên bản Frontend tương thích với project này. 132 | 133 | URL: [FastAPI Base Frontend](https://github.com/Longdh57/FastAPI-Base-Frontend) -------------------------------------------------------------------------------- /app/services/srv_user.py: -------------------------------------------------------------------------------- 1 | import jwt 2 | 3 | from typing import Optional 4 | from fastapi import Depends, HTTPException 5 | from fastapi.security import HTTPBearer 6 | from fastapi_sqlalchemy import db 7 | from pydantic import ValidationError 8 | from starlette import status 9 | 10 | from app.models import User 11 | from app.core.config import settings 12 | from app.core.security import verify_password, get_password_hash 13 | from app.schemas.sche_token import TokenPayload 14 | from app.schemas.sche_user import UserCreateRequest, UserUpdateMeRequest, UserUpdateRequest, UserRegisterRequest 15 | 16 | 17 | class UserService(object): 18 | __instance = None 19 | 20 | def __init__(self) -> None: 21 | pass 22 | 23 | reusable_oauth2 = HTTPBearer( 24 | scheme_name='Authorization' 25 | ) 26 | 27 | @staticmethod 28 | def authenticate(*, email: str, password: str) -> Optional[User]: 29 | """ 30 | Check username and password is correct. 31 | Return object User if correct, else return None 32 | """ 33 | user = db.session.query(User).filter_by(email=email).first() 34 | if not user: 35 | return None 36 | if not verify_password(password, user.hashed_password): 37 | return None 38 | return user 39 | 40 | @staticmethod 41 | def get_current_user(http_authorization_credentials=Depends(reusable_oauth2)) -> User: 42 | """ 43 | Decode JWT token to get user_id => return User info from DB query 44 | """ 45 | try: 46 | payload = jwt.decode( 47 | http_authorization_credentials.credentials, settings.SECRET_KEY, 48 | algorithms=[settings.SECURITY_ALGORITHM] 49 | ) 50 | token_data = TokenPayload(**payload) 51 | except (jwt.PyJWTError, ValidationError): 52 | raise HTTPException( 53 | status_code=status.HTTP_403_FORBIDDEN, 54 | detail=f"Could not validate credentials", 55 | ) 56 | user = db.session.query(User).get(token_data.user_id) 57 | if not user: 58 | raise HTTPException(status_code=404, detail="User not found") 59 | return user 60 | 61 | @staticmethod 62 | def register_user(data: UserRegisterRequest): 63 | exist_user = db.session.query(User).filter(User.email == data.email).first() 64 | if exist_user: 65 | raise Exception('Email already exists') 66 | register_user = User( 67 | full_name=data.full_name, 68 | email=data.email, 69 | hashed_password=get_password_hash(data.password), 70 | is_active=True, 71 | role=data.role.value, 72 | ) 73 | db.session.add(register_user) 74 | db.session.commit() 75 | return register_user 76 | 77 | @staticmethod 78 | def create_user(data: UserCreateRequest): 79 | exist_user = db.session.query(User).filter(User.email == data.email).first() 80 | if exist_user: 81 | raise Exception('Email already exists') 82 | new_user = User( 83 | full_name=data.full_name, 84 | email=data.email, 85 | hashed_password=get_password_hash(data.password), 86 | is_active=data.is_active, 87 | role=data.role.value, 88 | ) 89 | db.session.add(new_user) 90 | db.session.commit() 91 | return new_user 92 | 93 | @staticmethod 94 | def update_me(data: UserUpdateMeRequest, current_user: User): 95 | if data.email is not None: 96 | exist_user = db.session.query(User).filter( 97 | User.email == data.email, User.id != current_user.id).first() 98 | if exist_user: 99 | raise Exception('Email already exists') 100 | current_user.full_name = current_user.full_name if data.full_name is None else data.full_name 101 | current_user.email = current_user.email if data.email is None else data.email 102 | current_user.hashed_password = current_user.hashed_password if data.password is None else get_password_hash( 103 | data.password) 104 | db.session.commit() 105 | return current_user 106 | 107 | @staticmethod 108 | def update(user_id: int, data: UserUpdateRequest): 109 | user = db.session.query(User).get(user_id) 110 | if user is None: 111 | raise Exception('User not exists') 112 | user.full_name = user.full_name if data.full_name is None else data.full_name 113 | user.email = user.email if data.email is None else data.email 114 | user.hashed_password = user.hashed_password if data.password is None else get_password_hash( 115 | data.password) 116 | user.is_active = user.is_active if data.is_active is None else data.is_active 117 | user.role = user.role if data.role is None else data.role.value 118 | db.session.commit() 119 | return user 120 | 121 | @staticmethod 122 | def get(user_id): 123 | exist_user = db.session.query(User).get(user_id) 124 | if exist_user is None: 125 | raise Exception('User not exists') 126 | return exist_user 127 | -------------------------------------------------------------------------------- /tests/conftest.py: -------------------------------------------------------------------------------- 1 | import os 2 | import pytest 3 | import requests 4 | from datetime import datetime 5 | from operator import itemgetter 6 | from docstring_parser import parse 7 | from app.main import get_application 8 | from app.models.model_base import Base 9 | from sqlalchemy import create_engine 10 | from sqlalchemy.orm import sessionmaker 11 | from typing import Any, Generator 12 | from fastapi import FastAPI 13 | from fastapi.testclient import TestClient 14 | from app.db.base import get_db 15 | from fastapi_sqlalchemy import DBSessionMiddleware 16 | from dotenv import load_dotenv 17 | 18 | load_dotenv(verbose=True) 19 | 20 | SQLALCHEMY_DATABASE_URL = os.getenv('SQLALCHEMY_DATABASE_URL', '/tests') 21 | connect_args = {} 22 | if SQLALCHEMY_DATABASE_URL[:6] == 'sqlite': 23 | connect_args['check_same_thread'] = False 24 | engine = create_engine( 25 | SQLALCHEMY_DATABASE_URL, 26 | connect_args=connect_args 27 | ) 28 | 29 | TestingSessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine) 30 | 31 | 32 | @pytest.hookimpl(hookwrapper=True, tryfirst=True) 33 | def pytest_runtest_makereport(item, call): 34 | outcome = yield 35 | rep = outcome.get_result() 36 | setattr(item, 'test_outcome', rep) 37 | 38 | 39 | def pytest_addoption(parser): 40 | parser.addoption('--submit-tests', 41 | action='store_true', 42 | help='Submit tests to Jira') 43 | 44 | 45 | class JiraTestService: 46 | def __init__(self, jira_settings): 47 | self.project_key = jira_settings['project_key'] 48 | self.auth_string = (jira_settings['user'], jira_settings['password']) 49 | self.url = jira_settings['url'] + '/rest/atm/1.0' 50 | self.issue_url = jira_settings['url'] + '/rest/api/latest/issue' 51 | 52 | def get_issue_info(self, issue_key): 53 | return requests.get( 54 | url=self.issue_url + '/' + issue_key, 55 | auth=self.auth_string 56 | ).json() 57 | 58 | def get_tests_in_issue(self, issue_key): 59 | params = { 60 | 'query': 61 | 'projectKey = "%s" AND issueKeys IN (%s)' % 62 | (self.project_key, issue_key) 63 | } 64 | response = requests.get(url=self.url + '/testcase/search', 65 | params=params, 66 | auth=self.auth_string).json() 67 | return list(map(itemgetter('name', 'key'), response)) 68 | 69 | def create_test(self, test_name, issue_key, objective, steps): 70 | json = { 71 | 'name': test_name, 72 | 'projectKey': self.project_key, 73 | 'issueLinks': [issue_key], 74 | 'status': 'Approved', 75 | 'objective': objective, 76 | "testScript": { 77 | 'type': 'PLAIN_TEXT', 78 | 'text': steps or "Default" 79 | } 80 | } 81 | response = requests.post(url=self.url + '/testcase', 82 | json=json, 83 | auth=self.auth_string) 84 | if response.status_code != 201: 85 | raise Exception('Create test return with error status code', 86 | response.status_code) 87 | 88 | test_key = response.json()['key'] 89 | return test_key 90 | 91 | def delete_test(self, test_key): 92 | response = requests.delete(url=self.url + '/testcase/' + test_key, 93 | auth=self.auth_string) 94 | if response.status_code != 204: 95 | raise Exception('Delete test return with error status code', 96 | response.status_code) 97 | 98 | def create_test_cycle(self, name, issue_key, items): 99 | def get_current_time(): 100 | return datetime.now().strftime('%Y-%m-%dT%H:%M:%S') 101 | 102 | json = { 103 | 'name': name, 104 | 'projectKey': self.project_key, 105 | 'issueKey': issue_key, 106 | 'plannedStartDate': get_current_time(), 107 | 'plannedEndDate': get_current_time(), 108 | 'items': items 109 | } 110 | response = requests.post(url=self.url + '/testrun', 111 | json=json, 112 | auth=self.auth_string) 113 | if response.status_code != 201: 114 | raise Exception('Create test cycle return with error status code', 115 | response.status_code) 116 | 117 | 118 | def jira_test_service(): 119 | return JiraTestService({ 120 | 'url': os.getenv('JIRA_URL', '/tests'), 121 | 'user': os.getenv('JIRA_USER', '/tests'), 122 | 'password': os.getenv('JIRA_PASSWORD', '/tests'), 123 | 'project_key': os.getenv('JIRA_PROJECT_KEY', '/tests') 124 | }) 125 | 126 | 127 | delete_tests_on_issue = set() 128 | 129 | 130 | @pytest.fixture(scope='class') 131 | def each_test_suite(request): 132 | # Before each test suite 133 | cls = request.cls 134 | cls.results = {} 135 | cls.tests_list = [] 136 | 137 | test_service = jira_test_service() # Currently only support Jira 138 | 139 | submit_tests = request.config.getoption('--submit-tests', default=False) 140 | if not getattr(cls, 'ISSUE_KEY', None): 141 | submit_tests = False 142 | if submit_tests: 143 | issue_info = test_service.get_issue_info(cls.ISSUE_KEY) 144 | if issue_info['fields']['status']['name'] == 'Closed': 145 | submit_tests = False 146 | 147 | if submit_tests: 148 | cls.tests_list = test_service.get_tests_in_issue(cls.ISSUE_KEY) 149 | 150 | if cls.ISSUE_KEY not in delete_tests_on_issue: 151 | for _, test_key in cls.tests_list: 152 | test_service.delete_test(test_key) 153 | delete_tests_on_issue.add(cls.ISSUE_KEY) 154 | 155 | yield 156 | 157 | # After each test suite 158 | if submit_tests: 159 | # Create test keys 160 | for name in cls.results: 161 | test_key = test_service.create_test( 162 | cls.__name__ + '_' + name, 163 | cls.ISSUE_KEY, 164 | cls.results[name]['objective'], 165 | cls.results[name]['steps'] 166 | ) 167 | cls.results[name]['testCaseKey'] = test_key 168 | test_cycle_items = [] 169 | 170 | for k, v in cls.results.items(): 171 | del v['objective'] 172 | del v['steps'] 173 | test_cycle_items.append(v) 174 | 175 | # Create test cycle 176 | test_service.create_test_cycle( 177 | cls.ISSUE_KEY + ' - ' + cls.__name__, 178 | cls.ISSUE_KEY, 179 | test_cycle_items 180 | ) 181 | 182 | 183 | @pytest.fixture() 184 | def each_test_case(request): 185 | # Before each test case 186 | MAX_NAME_LENGTH = 255 187 | name = request._pyfuncitem.name 188 | if len(name) > MAX_NAME_LENGTH: 189 | name = name.substring(0, MAX_NAME_LENGTH) 190 | request.cls.results[name] = {'status': 'Pass'} 191 | yield 192 | 193 | # After each test case 194 | if request.node.test_outcome.failed: 195 | request.cls.results[name]['status'] = 'Fail' 196 | 197 | docstring = parse(request._pyfuncitem._obj.__doc__) 198 | 199 | step_string = 'Step by step:' 200 | if docstring.long_description and step_string in docstring.long_description: 201 | objective, steps = map( 202 | str.strip, docstring.long_description.split(step_string, 1)) 203 | steps = '
' + steps + '
' 204 | else: 205 | objective = docstring.long_description 206 | steps = None 207 | 208 | request.cls.results[name]['objective'] = objective 209 | request.cls.results[name]['steps'] = steps 210 | 211 | 212 | @pytest.fixture(autouse=True) 213 | def app() -> Generator[FastAPI, Any, None]: 214 | """ 215 | Create a fresh database on each test case. 216 | """ 217 | Base.metadata.create_all(engine) # Create the tables. 218 | _app = get_application() 219 | _app.add_middleware(DBSessionMiddleware, db_url=SQLALCHEMY_DATABASE_URL) 220 | yield _app 221 | Base.metadata.drop_all(engine) 222 | 223 | 224 | @pytest.fixture 225 | def db_session(app: FastAPI) -> Generator[TestingSessionLocal, Any, None]: 226 | """ 227 | Creates a fresh sqlalchemy session for each test that operates in a 228 | transaction. The transaction is rolled back at the end of each test ensuring 229 | a clean state. 230 | """ 231 | 232 | # connect to the database 233 | connection = engine.connect() 234 | # begin a non-ORM transaction 235 | transaction = connection.begin() 236 | # bind an individual Session to the connection 237 | session = TestingSessionLocal(bind=connection) 238 | yield session # use the session in tests. 239 | session.close() 240 | # rollback - everything that happened with the 241 | # Session above (including calls to commit()) 242 | # is rolled back. 243 | transaction.rollback() 244 | # return connection to the Engine 245 | connection.close() 246 | 247 | 248 | @pytest.fixture() 249 | def client(app: FastAPI, db_session: TestingSessionLocal) -> Generator[TestClient, Any, None]: 250 | """ 251 | Create a new FastAPI TestClient that uses the `db_session` fixture to override 252 | the `get_db` dependency that is injected into routes. 253 | """ 254 | 255 | def _get_test_db(): 256 | try: 257 | yield db_session 258 | finally: 259 | pass 260 | 261 | app.dependency_overrides[get_db] = _get_test_db 262 | with TestClient(app) as client: 263 | yield client 264 | 265 | 266 | @pytest.fixture 267 | def app_class(request, app): 268 | if request.cls is not None: 269 | request.cls.app = app 270 | 271 | 272 | # For Jira Test 273 | @pytest.mark.usefixtures('each_test_case', 'each_test_suite') 274 | class Jira: 275 | pass 276 | --------------------------------------------------------------------------------