├── .gitignore ├── LICENSE ├── README.md ├── app ├── .env.example ├── Dockerfile ├── main.py ├── requirements.txt └── src │ ├── api │ ├── __init__.py │ ├── api.py │ ├── deps.py │ └── endpoints │ │ ├── __init__.py │ │ ├── items.py │ │ ├── login.py │ │ └── users.py │ ├── core │ ├── __init__.py │ ├── config.py │ └── security.py │ ├── crud │ ├── __init__.py │ ├── base.py │ ├── crud_item.py │ └── crud_user.py │ ├── db │ ├── __init__.py │ ├── base.py │ ├── base_class.py │ ├── init_db.py │ └── session.py │ ├── models │ ├── __init__.py │ ├── item.py │ └── user.py │ └── schemas │ ├── __init__.py │ ├── item.py │ ├── msg.py │ ├── token.py │ └── user.py ├── docker-compose.yaml ├── nginx ├── Dockerfile └── nginx.conf └── routes.png /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | .vscode/ 7 | 8 | # Distribution / packaging 9 | .Python 10 | build/ 11 | develop-eggs/ 12 | dist/ 13 | downloads/ 14 | eggs/ 15 | .eggs/ 16 | lib/ 17 | lib64/ 18 | parts/ 19 | sdist/ 20 | var/ 21 | wheels/ 22 | pip-wheel-metadata/ 23 | share/python-wheels/ 24 | *.egg-info/ 25 | .installed.cfg 26 | *.egg 27 | MANIFEST 28 | 29 | # PyInstaller 30 | # Usually these files are written by a python script from a template 31 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 32 | *.manifest 33 | *.spec 34 | 35 | # Installer logs 36 | pip-log.txt 37 | pip-delete-this-directory.txt 38 | 39 | # Unit test / coverage reports 40 | htmlcov/ 41 | .tox/ 42 | .nox/ 43 | .coverage 44 | .coverage.* 45 | .cache 46 | nosetests.xml 47 | coverage.xml 48 | *.cover 49 | *.py,cover 50 | .hypothesis/ 51 | .pytest_cache/ 52 | 53 | # Translations 54 | *.mo 55 | *.pot 56 | 57 | # Django stuff: 58 | *.log 59 | local_settings.py 60 | db.sqlite3 61 | db.sqlite3-journal 62 | 63 | # Flask stuff: 64 | instance/ 65 | .webassets-cache 66 | 67 | # Scrapy stuff: 68 | .scrapy 69 | 70 | # Sphinx documentation 71 | docs/_build/ 72 | 73 | # PyBuilder 74 | target/ 75 | 76 | # Jupyter Notebook 77 | .ipynb_checkpoints 78 | 79 | # IPython 80 | profile_default/ 81 | ipython_config.py 82 | 83 | # pyenv 84 | .python-version 85 | 86 | # pipenv 87 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 88 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 89 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 90 | # install all needed dependencies. 91 | #Pipfile.lock 92 | 93 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow 94 | __pypackages__/ 95 | 96 | # Celery stuff 97 | celerybeat-schedule 98 | celerybeat.pid 99 | 100 | # SageMath parsed files 101 | *.sage.py 102 | 103 | # Environments 104 | .env 105 | .venv 106 | env/ 107 | venv/ 108 | ENV/ 109 | env.bak/ 110 | venv.bak/ 111 | 112 | # Spyder project settings 113 | .spyderproject 114 | .spyproject 115 | 116 | # Rope project settings 117 | .ropeproject 118 | 119 | # mkdocs documentation 120 | /site 121 | 122 | # mypy 123 | .mypy_cache/ 124 | .dmypy.json 125 | dmypy.json 126 | 127 | # Pyre type checker 128 | .pyre/ 129 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 Mokrane Abdelmalek 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 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # fastapi_project_template 2 | RESTful Back-end project template with FastAPI + PostgreSQL + JWT + Docker + Nginx 3 | 4 | 5 | ## Features 6 | - Docker with [FastAPI](https://fastapi.tiangolo.com/) and [PostgresSQL](https://www.postgresql.org/). 7 | - Authentication and securing `Item` routes with [JWT tokens](https://jwt.io/) 8 | - Fastapi which means an interactive API documentation. 9 | - Scalable and evolutive structure for large projects. 10 | 11 | Built on Python: 3.11. 12 | 13 | ## Routes 14 | ![routes](./routes.png) 15 | 16 | ## File Structure 17 | ``` 18 | . 19 | ├── app 20 | │ ├── Dockerfile 21 | │ ├── main.py 22 | │ ├── requirements.txt 23 | │ ├── .env 24 | │ └── src 25 | │ ├── api 26 | │ ├── endpoints 27 | │ ├── items.py 28 | │ ├── user.py 29 | │ └── login.py 30 | │ ├── api.py 31 | │ └── deps.py 32 | │ ├── core 33 | │ ├── config.py 34 | │ └── security.py 35 | │ ├── crud 36 | │ ├── base.py 37 | │ ├── crud_user.py 38 | │ └── crud_item.py 39 | │ ├── db 40 | │ ├── base_class.py 41 | │ ├── base.py 42 | │ ├── init_db.py 43 | │ └── session.py 44 | │ ├── models 45 | │ ├── item.py 46 | │ └── user.py 47 | │ └── schemas 48 | │ ├── item.py 49 | │ ├── msg.py 50 | │ ├── token.py 51 | │ └── user.py 52 | └── docker-compose.yml 53 | ``` 54 | 55 | ## Installation and usage 56 | - clone the repository 57 | ```bash 58 | git clone git@github.com:m0kr4n3/fastapi_projetct_template.git 59 | cd fastapi_projetct_template/app/ 60 | ``` 61 | 1) Using python 62 | - use `venv` virtual environment 63 | ```bash 64 | pip install venv 65 | python -m venv venv 66 | source $PWD/venv/bin/activate 67 | ``` 68 | - Install dependencies 69 | ```bash 70 | pip install -r requirements.txt 71 | ``` 72 | - Create env from env template: 73 | ```bash 74 | cp example.env .env #only once 75 | ``` 76 | - Put there the necessary info 77 | - Run main.py 78 | ```bash 79 | python main.py 80 | ``` 81 | 2) Using docker 82 | Install docker-compose if it's not done already 83 | ```bash 84 | sudo apt install docker-compose 85 | ``` 86 | - Run docker-compose in the repo root directory : 87 | ```bash 88 | sudo docker-compose up 89 | ``` 90 | 91 | #### Inspired from [Full Stack FastAPI and PostgreSQL - Base Project Generator](https://github.com/tiangolo/full-stack-fastapi-postgresql) 92 | -------------------------------------------------------------------------------- /app/.env.example: -------------------------------------------------------------------------------- 1 | POSTGRES_SERVER= 2 | POSTGRES_USER= 3 | POSTGRES_PASSWORD= 4 | POSTGRES_DB= 5 | FIRST_SUPERUSER_EMAIL= 6 | FIRST_SUPERUSER_PASSWORD= 7 | USERS_OPEN_REGISTRATION= -------------------------------------------------------------------------------- /app/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM python:3.11-slim 2 | 3 | WORKDIR /app 4 | 5 | COPY requirements.txt requirements.txt 6 | RUN pip3 install -r requirements.txt 7 | 8 | EXPOSE 8080 9 | 10 | COPY . . 11 | 12 | CMD ["python3","main.py"] -------------------------------------------------------------------------------- /app/main.py: -------------------------------------------------------------------------------- 1 | import uvicorn 2 | import logging 3 | 4 | from src.db.init_db import init_db 5 | from src.db.session import SessionLocal 6 | 7 | logging.basicConfig(level=logging.INFO) 8 | logger = logging.getLogger(__name__) 9 | 10 | 11 | 12 | 13 | if __name__ == "__main__": 14 | 15 | logger.info("Creating initial data") 16 | db = SessionLocal() 17 | init_db(db) 18 | logger.info("Initial data created") 19 | 20 | uvicorn.run("src.api.api:api_router", host="0.0.0.0", port=8080, reload=True) 21 | -------------------------------------------------------------------------------- /app/requirements.txt: -------------------------------------------------------------------------------- 1 | alembic==1.12.1 2 | amqp==5.2.0 3 | annotated-types==0.6.0 4 | anyio==3.7.1 5 | attrs==23.1.0 6 | bcrypt==4.1.1 7 | billiard==4.2.0 8 | cachetools==5.3.2 9 | celery==5.3.6 10 | certifi==2023.11.17 11 | cffi==1.16.0 12 | chardet==5.2.0 13 | charset-normalizer==3.3.2 14 | click==8.1.7 15 | click-didyoumean==0.3.0 16 | click-plugins==1.1.1 17 | click-repl==0.3.0 18 | cssselect==1.2.0 19 | cssutils==2.9.0 20 | dnspython==2.4.2 21 | ecdsa==0.18.0 22 | email-validator==2.1.0.post1 23 | emails==0.6 24 | fastapi==0.104.1 25 | greenlet==3.0.1 26 | gunicorn==21.2.0 27 | h11==0.14.0 28 | httptools==0.6.1 29 | idna==3.6 30 | iniconfig==2.0.0 31 | Jinja2==3.1.2 32 | jose==1.0.0 33 | kombu==5.3.4 34 | lxml==4.9.3 35 | Mako==1.3.0 36 | MarkupSafe==2.1.3 37 | more-itertools==10.1.0 38 | packaging==23.2 39 | passlib==1.7.4 40 | pluggy==1.3.0 41 | premailer==3.10.0 42 | prompt-toolkit==3.0.41 43 | psycopg2-binary==2.9.9 44 | py==1.11.0 45 | pyasn1==0.5.1 46 | pycparser==2.21 47 | pydantic==2.5.2 48 | pydantic_core==2.14.5 49 | pyparsing==3.1.1 50 | pytest==7.4.3 51 | python-dateutil==2.8.2 52 | python-dotenv==1.0.0 53 | python-editor==1.0.4 54 | python-jose==3.3.0 55 | python-multipart==0.0.6 56 | pytz==2023.3.post1 57 | raven==6.10.0 58 | requests==2.31.0 59 | rsa==4.9 60 | six==1.16.0 61 | sniffio==1.3.0 62 | SQLAlchemy==2.0.23 63 | starlette==0.27.0 64 | tenacity==8.2.3 65 | typing_extensions==4.8.0 66 | tzdata==2023.3 67 | urllib3==2.1.0 68 | uvicorn==0.24.0.post1 69 | uvloop==0.19.0 70 | vine==5.1.0 71 | wcwidth==0.2.12 72 | websockets==12.0 73 | pydantic-settings==2.1.0 74 | -------------------------------------------------------------------------------- /app/src/api/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/m0kr4n3/fastapi_project_template/d8a75aab042a8d9a435eab038cec45a7cfaee80c/app/src/api/__init__.py -------------------------------------------------------------------------------- /app/src/api/api.py: -------------------------------------------------------------------------------- 1 | from src.api.endpoints import login, users, items 2 | from fastapi import FastAPI, APIRouter 3 | from starlette.middleware.cors import CORSMiddleware 4 | from src.core.config import settings 5 | 6 | api_router = FastAPI() 7 | 8 | # Set all CORS enabled origins 9 | if settings.BACKEND_CORS_ORIGINS: 10 | api_router.add_middleware( 11 | CORSMiddleware, 12 | allow_origins=[str(origin) for origin in settings.BACKEND_CORS_ORIGINS], 13 | allow_credentials=True, 14 | allow_methods=["*"], 15 | allow_headers=["*"], 16 | ) 17 | 18 | root_router = APIRouter() 19 | 20 | @root_router.get("/") 21 | def root(): 22 | return {"message": "welcome! go to /docs for API documentation."} 23 | 24 | api_router.include_router(root_router, tags=["root"]) 25 | api_router.include_router(login.router, prefix='/login', tags=["login"]) 26 | api_router.include_router(users.router, prefix="/users", tags=["users"]) 27 | api_router.include_router(items.router, prefix="/items", tags=["items"]) -------------------------------------------------------------------------------- /app/src/api/deps.py: -------------------------------------------------------------------------------- 1 | from typing import Generator 2 | from fastapi import Depends, HTTPException, status 3 | from fastapi.security import OAuth2PasswordBearer 4 | from jose import jwt 5 | from pydantic import ValidationError 6 | from sqlalchemy.orm import Session 7 | from src.models import User 8 | from src import models, schemas 9 | from src.crud import crud_user 10 | from src.core import security 11 | from src.core.config import settings 12 | from src.db.session import SessionLocal 13 | 14 | reusable_oauth2 = OAuth2PasswordBearer( 15 | tokenUrl=f"/login/access-token" 16 | ) 17 | 18 | 19 | def get_db() -> Generator: 20 | try: 21 | db = SessionLocal() 22 | yield db 23 | finally: 24 | db.close() 25 | 26 | 27 | def get_current_user( 28 | db: Session = Depends(get_db), token: str = Depends(reusable_oauth2) 29 | ) -> User: 30 | try: 31 | payload = jwt.decode( 32 | token, settings.SECRET_KEY, algorithms=[security.ALGORITHM] 33 | ) 34 | token_data = schemas.TokenPayload(**payload) 35 | except (jwt.JWTError, ValidationError): 36 | raise HTTPException( 37 | status_code=status.HTTP_403_FORBIDDEN, 38 | detail="Could not validate credentials", 39 | ) 40 | user = crud_user.user.get(db, id=token_data.sub) 41 | if not user: 42 | raise HTTPException(status_code=404, detail="User not found") 43 | return user 44 | 45 | 46 | def get_current_active_user( 47 | current_user: User = Depends(get_current_user), 48 | ) -> User: 49 | if not crud_user.user.is_active(current_user): 50 | raise HTTPException(status_code=400, detail="Inactive user") 51 | return current_user 52 | 53 | 54 | def get_current_active_superuser( 55 | current_user: User = Depends(get_current_user), 56 | ) -> User: 57 | if not crud_user.user.is_superuser(current_user): 58 | raise HTTPException( 59 | status_code=400, detail="The user doesn't have enough privileges" 60 | ) 61 | return current_user 62 | -------------------------------------------------------------------------------- /app/src/api/endpoints/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/m0kr4n3/fastapi_project_template/d8a75aab042a8d9a435eab038cec45a7cfaee80c/app/src/api/endpoints/__init__.py -------------------------------------------------------------------------------- /app/src/api/endpoints/items.py: -------------------------------------------------------------------------------- 1 | from typing import Any, List 2 | 3 | from fastapi import APIRouter, Depends, HTTPException 4 | from sqlalchemy.orm import Session 5 | 6 | from src import crud, models, schemas 7 | from src.api import deps 8 | 9 | router = APIRouter() 10 | 11 | 12 | @router.get("/", response_model=List[schemas.Item]) 13 | def read_items( 14 | db: Session = Depends(deps.get_db), 15 | skip: int = 0, 16 | limit: int = 100, 17 | current_user: models.User = Depends(deps.get_current_active_user), 18 | ) -> Any: 19 | """ 20 | Retrieve items. 21 | """ 22 | if crud.user.is_superuser(current_user): 23 | items = crud.item.get_multi(db, skip=skip, limit=limit) 24 | else: 25 | items = crud.item.get_multi_by_owner( 26 | db=db, owner_id=current_user.id, skip=skip, limit=limit 27 | ) 28 | return items 29 | 30 | 31 | @router.post("/", response_model=schemas.Item) 32 | def create_item( 33 | *, 34 | db: Session = Depends(deps.get_db), 35 | item_in: schemas.ItemCreate, 36 | current_user: models.User = Depends(deps.get_current_active_user), 37 | ) -> Any: 38 | """ 39 | Create new item. 40 | """ 41 | item = crud.item.create_with_owner(db=db, obj_in=item_in, owner_id=current_user.id) 42 | return item 43 | 44 | 45 | @router.put("/{id}", response_model=schemas.Item) 46 | def update_item( 47 | *, 48 | db: Session = Depends(deps.get_db), 49 | id: int, 50 | item_in: schemas.ItemUpdate, 51 | current_user: models.User = Depends(deps.get_current_active_user), 52 | ) -> Any: 53 | """ 54 | Update an item. 55 | """ 56 | item = crud.item.get(db=db, id=id) 57 | if not item: 58 | raise HTTPException(status_code=404, detail="Item not found") 59 | if not crud.user.is_superuser(current_user) and (item.owner_id != current_user.id): 60 | raise HTTPException(status_code=400, detail="Not enough permissions") 61 | item = crud.item.update(db=db, db_obj=item, obj_in=item_in) 62 | return item 63 | 64 | 65 | @router.get("/{id}", response_model=schemas.Item) 66 | def read_item( 67 | *, 68 | db: Session = Depends(deps.get_db), 69 | id: int, 70 | current_user: models.User = Depends(deps.get_current_active_user), 71 | ) -> Any: 72 | """ 73 | Get item by ID. 74 | """ 75 | item = crud.item.get(db=db, id=id) 76 | if not item: 77 | raise HTTPException(status_code=404, detail="Item not found") 78 | if not crud.user.is_superuser(current_user) and (item.owner_id != current_user.id): 79 | raise HTTPException(status_code=400, detail="Not enough permissions") 80 | return item 81 | 82 | 83 | @router.delete("/{id}", response_model=schemas.Item) 84 | def delete_item( 85 | *, 86 | db: Session = Depends(deps.get_db), 87 | id: int, 88 | current_user: models.User = Depends(deps.get_current_active_user), 89 | ) -> Any: 90 | """ 91 | Delete an item. 92 | """ 93 | item = crud.item.get(db=db, id=id) 94 | if not item: 95 | raise HTTPException(status_code=404, detail="Item not found") 96 | if not crud.user.is_superuser(current_user) and (item.owner_id != current_user.id): 97 | raise HTTPException(status_code=400, detail="Not enough permissions") 98 | item = crud.item.remove(db=db, id=id) 99 | return item 100 | -------------------------------------------------------------------------------- /app/src/api/endpoints/login.py: -------------------------------------------------------------------------------- 1 | from datetime import timedelta 2 | from typing import Any 3 | 4 | from fastapi import APIRouter, Body, Depends, HTTPException 5 | from fastapi.security import OAuth2PasswordRequestForm 6 | from sqlalchemy.orm import Session 7 | 8 | from src import models, schemas 9 | from src.schemas.user import UserCreate 10 | from src.api import deps 11 | from src.core import security 12 | from src.core.config import settings 13 | from src.crud import crud_user 14 | 15 | router = APIRouter() 16 | 17 | 18 | @router.post("/access-token", response_model=schemas.Token) 19 | def login_access_token( 20 | db: Session = Depends(deps.get_db),user_in: UserCreate = Body(None) 21 | ) -> Any: 22 | """ 23 | OAuth2 compatible token login, get an access token for future requests 24 | """ 25 | if not user_in: 26 | raise HTTPException(status_code=400, detail="Empty body") 27 | user = crud_user.user.authenticate( 28 | db, email=user_in.email, password=user_in.password 29 | ) 30 | if not user: 31 | raise HTTPException(status_code=400, detail="Incorrect email or password") 32 | elif not crud_user.user.is_active(user): 33 | raise HTTPException(status_code=400, detail="Inactive user") 34 | access_token_expires = timedelta(minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES) 35 | return { 36 | "access_token": security.create_access_token( 37 | user.id, expires_delta=access_token_expires 38 | ), 39 | "token_type": "bearer", 40 | } 41 | 42 | @router.post("/test-token", response_model=schemas.User) 43 | def test_token(current_user: models.User = Depends(deps.get_current_user)) -> Any: 44 | """ 45 | Test access token 46 | """ 47 | return current_user 48 | -------------------------------------------------------------------------------- /app/src/api/endpoints/users.py: -------------------------------------------------------------------------------- 1 | from typing import Any, List 2 | 3 | from fastapi import APIRouter, Body, Depends, HTTPException 4 | from fastapi.encoders import jsonable_encoder 5 | from pydantic.networks import EmailStr 6 | from sqlalchemy.orm import Session 7 | 8 | from src import models, schemas 9 | from src.crud import crud_user 10 | from src.api import deps 11 | from src.core.config import settings 12 | 13 | router = APIRouter() 14 | 15 | 16 | @router.get("/", response_model=List[schemas.User]) 17 | def read_users( 18 | db: Session = Depends(deps.get_db), 19 | skip: int = 0, 20 | limit: int = 100, 21 | current_user: models.User = Depends(deps.get_current_active_superuser), 22 | ) -> Any: 23 | """ 24 | Retrieve users. 25 | """ 26 | users = crud_user.user.get_multi(db, skip=skip, limit=limit) 27 | return users 28 | 29 | 30 | @router.post("/", response_model=schemas.User) 31 | def create_user( 32 | *, 33 | db: Session = Depends(deps.get_db), 34 | user_in: schemas.UserCreate, 35 | current_user: models.User = Depends(deps.get_current_active_superuser), 36 | ) -> Any: 37 | """ 38 | Create new user. 39 | """ 40 | user = crud_user.user.get_by_email(db, email=user_in.email) 41 | if user: 42 | raise HTTPException( 43 | status_code=400, 44 | detail="The user with this username already exists in the system.", 45 | ) 46 | user = crud_user.user.create(db, obj_in=user_in) 47 | 48 | return user 49 | 50 | 51 | @router.put("/me", response_model=schemas.User) 52 | def update_user_me( 53 | *, 54 | db: Session = Depends(deps.get_db), 55 | password: str = Body(None), 56 | full_name: str = Body(None), 57 | email: EmailStr = Body(None), 58 | current_user: models.User = Depends(deps.get_current_active_user), 59 | ) -> Any: 60 | """ 61 | Update own user. 62 | """ 63 | current_user_data = jsonable_encoder(current_user) 64 | user_in = schemas.UserUpdate(**current_user_data) 65 | if password is not None: 66 | user_in.password = password 67 | if full_name is not None: 68 | user_in.full_name = full_name 69 | if email is not None: 70 | user_in.email = email 71 | user = crud_user.user.update(db, db_obj=current_user, obj_in=user_in) 72 | return user 73 | 74 | 75 | @router.get("/me", response_model=schemas.User) 76 | def read_user_me( 77 | db: Session = Depends(deps.get_db), 78 | current_user: models.User = Depends(deps.get_current_active_user), 79 | ) -> Any: 80 | """ 81 | Get current user. 82 | """ 83 | return current_user 84 | 85 | 86 | @router.post("/open", response_model=schemas.User) 87 | def create_user_open( 88 | *, 89 | db: Session = Depends(deps.get_db), 90 | password: str = Body(...), 91 | email: EmailStr = Body(...), 92 | full_name: str = Body(None), 93 | ) -> Any: 94 | """ 95 | Create new user without the need to be logged in. 96 | """ 97 | if not settings.USERS_OPEN_REGISTRATION: 98 | raise HTTPException( 99 | status_code=403, 100 | detail="Open user registration is forbidden on this server", 101 | ) 102 | user = crud_user.user.get_by_email(db, email=email) 103 | if user: 104 | raise HTTPException( 105 | status_code=400, 106 | detail="The user with this username already exists in the system", 107 | ) 108 | user_in = schemas.UserCreate(password=password, email=email, full_name=full_name) 109 | user = crud_user.user.create(db, obj_in=user_in) 110 | return user 111 | 112 | 113 | @router.get("/{user_id}", response_model=schemas.User) 114 | def read_user_by_id( 115 | user_id: int, 116 | current_user: models.User = Depends(deps.get_current_active_superuser), 117 | db: Session = Depends(deps.get_db), 118 | ) -> Any: 119 | """ 120 | Get a specific user by id. 121 | """ 122 | user = crud_user.user.get(db, id=user_id) 123 | if not user: 124 | raise HTTPException( 125 | status_code=404, 126 | detail="The user with this username does not exist in the system", 127 | ) 128 | return user 129 | 130 | 131 | @router.put("/{user_id}", response_model=schemas.User) 132 | def update_user( 133 | *, 134 | db: Session = Depends(deps.get_db), 135 | user_id: int, 136 | user_in: schemas.UserUpdate, 137 | current_user: models.User = Depends(deps.get_current_active_superuser), 138 | ) -> Any: 139 | """ 140 | Update a user. 141 | """ 142 | user = crud_user.user.get(db, id=user_id) 143 | if not user: 144 | raise HTTPException( 145 | status_code=404, 146 | detail="The user with this username does not exist in the system", 147 | ) 148 | user = crud_user.user.update(db, db_obj=user, obj_in=user_in) 149 | return user 150 | -------------------------------------------------------------------------------- /app/src/core/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/m0kr4n3/fastapi_project_template/d8a75aab042a8d9a435eab038cec45a7cfaee80c/app/src/core/__init__.py -------------------------------------------------------------------------------- /app/src/core/config.py: -------------------------------------------------------------------------------- 1 | import secrets 2 | from typing import Any, Dict, List, Optional, Union 3 | 4 | from pydantic import AnyHttpUrl, PostgresDsn, validator 5 | from pydantic_settings import BaseSettings 6 | 7 | class Settings(BaseSettings): 8 | SECRET_KEY: str = "A"*32#secrets.token_urlsafe(32) 9 | # 60 minutes * 24 hours * 8 days = 8 days 10 | ACCESS_TOKEN_EXPIRE_MINUTES: int = 60 * 24 * 8 11 | 12 | USERS_OPEN_REGISTRATION: bool = False 13 | # BACKEND_CORS_ORIGINS is a JSON-formatted list of origins 14 | # e.g: '["http://localhost", "http://localhost:4200", "http://localhost:3000", \ 15 | # "http://localhost:8080", "http://local.dockertoolbox.tiangolo.com"]' 16 | BACKEND_CORS_ORIGINS: List[AnyHttpUrl] = [] 17 | 18 | @validator("BACKEND_CORS_ORIGINS", pre=True) 19 | def assemble_cors_origins(cls, v: Union[str, List[str]]) -> Union[List[str], str]: 20 | if isinstance(v, str) and not v.startswith("["): 21 | return [i.strip() for i in v.split(",")] 22 | elif isinstance(v, (list, str)): 23 | return v 24 | raise ValueError(v) 25 | 26 | POSTGRES_SERVER: str 27 | POSTGRES_USER: str 28 | POSTGRES_PASSWORD: str 29 | POSTGRES_DB: str 30 | SQLALCHEMY_DATABASE_URI: Optional[PostgresDsn] = None 31 | 32 | @validator("SQLALCHEMY_DATABASE_URI", pre=True) 33 | def assemble_db_connection(cls, v: Optional[str], values: Dict[str, Any]) -> Any: 34 | if isinstance(v, str): 35 | return v 36 | return PostgresDsn.build( 37 | scheme="postgresql", 38 | username=values.get("POSTGRES_USER"), 39 | password=values.get("POSTGRES_PASSWORD"), 40 | host=values.get("POSTGRES_SERVER"), 41 | path=values.get('POSTGRES_DB'), 42 | ) 43 | 44 | FIRST_SUPERUSER_EMAIL: str 45 | FIRST_SUPERUSER_PASSWORD: str 46 | 47 | class Config: 48 | case_sensitive = True 49 | env_file = '.env' 50 | env_file_encoding = 'utf-8' 51 | 52 | 53 | settings = Settings() 54 | -------------------------------------------------------------------------------- /app/src/core/security.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime, timedelta 2 | from typing import Any, Union 3 | 4 | from jose import jwt 5 | from passlib.context import CryptContext 6 | 7 | from src.core.config import settings 8 | 9 | pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto") 10 | 11 | 12 | ALGORITHM = "HS256" 13 | 14 | 15 | def create_access_token( 16 | subject: Union[str, Any], expires_delta: timedelta = None 17 | ) -> str: 18 | if expires_delta: 19 | expire = datetime.utcnow() + expires_delta 20 | else: 21 | expire = datetime.utcnow() + timedelta( 22 | minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES 23 | ) 24 | to_encode = {"exp": expire, "sub": str(subject)} 25 | encoded_jwt = jwt.encode(to_encode, settings.SECRET_KEY, algorithm=ALGORITHM) 26 | return encoded_jwt 27 | 28 | 29 | def verify_password(plain_password: str, hashed_password: str) -> bool: 30 | return pwd_context.verify(plain_password, hashed_password) 31 | 32 | 33 | def get_password_hash(password: str) -> str: 34 | return pwd_context.hash(password) 35 | -------------------------------------------------------------------------------- /app/src/crud/__init__.py: -------------------------------------------------------------------------------- 1 | from .crud_item import item 2 | from .crud_user import user 3 | 4 | # For a new basic set of CRUD operations you could just do 5 | 6 | # from .base import CRUDBase 7 | # from app.models.item import Item 8 | # from app.schemas.item import ItemCreate, ItemUpdate 9 | 10 | # item = CRUDBase[Item, ItemCreate, ItemUpdate](Item) -------------------------------------------------------------------------------- /app/src/crud/base.py: -------------------------------------------------------------------------------- 1 | from typing import Any, Dict, Generic, List, Optional, Type, TypeVar, Union 2 | 3 | from fastapi.encoders import jsonable_encoder 4 | from pydantic import BaseModel 5 | from sqlalchemy.orm import Session 6 | 7 | from src.db.base_class import Base 8 | 9 | ModelType = TypeVar("ModelType", bound=Base) 10 | CreateSchemaType = TypeVar("CreateSchemaType", bound=BaseModel) 11 | UpdateSchemaType = TypeVar("UpdateSchemaType", bound=BaseModel) 12 | 13 | 14 | class CRUDBase(Generic[ModelType, CreateSchemaType, UpdateSchemaType]): 15 | def __init__(self, model: Type[ModelType]): 16 | """ 17 | CRUD object with default methods to Create, Read, Update, Delete (CRUD). 18 | 19 | **Parameters** 20 | 21 | * `model`: A SQLAlchemy model class 22 | * `schema`: A Pydantic model (schema) class 23 | """ 24 | self.model = model 25 | 26 | def get(self, db: Session, id: Any) -> Optional[ModelType]: 27 | return db.query(self.model).filter(self.model.id == id).first() 28 | 29 | def get_multi( 30 | self, db: Session, *, skip: int = 0, limit: int = 100 31 | ) -> List[ModelType]: 32 | return db.query(self.model).offset(skip).limit(limit).all() 33 | 34 | def create(self, db: Session, *, obj_in: CreateSchemaType) -> ModelType: 35 | obj_in_data = jsonable_encoder(obj_in) 36 | db_obj = self.model(**obj_in_data) # type: ignore 37 | db.add(db_obj) 38 | db.commit() 39 | db.refresh(db_obj) 40 | return db_obj 41 | 42 | def update( 43 | self, 44 | db: Session, 45 | *, 46 | db_obj: ModelType, 47 | obj_in: Union[UpdateSchemaType, Dict[str, Any]] 48 | ) -> ModelType: 49 | obj_data = jsonable_encoder(db_obj) 50 | if isinstance(obj_in, dict): 51 | update_data = obj_in 52 | else: 53 | update_data = obj_in.dict(exclude_unset=True) 54 | for field in obj_data: 55 | if field in update_data: 56 | setattr(db_obj, field, update_data[field]) 57 | db.add(db_obj) 58 | db.commit() 59 | db.refresh(db_obj) 60 | return db_obj 61 | 62 | def remove(self, db: Session, *, id: int) -> ModelType: 63 | obj = db.query(self.model).get(id) 64 | db.delete(obj) 65 | db.commit() 66 | return obj 67 | -------------------------------------------------------------------------------- /app/src/crud/crud_item.py: -------------------------------------------------------------------------------- 1 | from typing import List 2 | 3 | from fastapi.encoders import jsonable_encoder 4 | from sqlalchemy.orm import Session 5 | 6 | from src.crud.base import CRUDBase 7 | from src.models.item import Item 8 | from src.schemas.item import ItemCreate, ItemUpdate 9 | 10 | 11 | class CRUDItem(CRUDBase[Item, ItemCreate, ItemUpdate]): 12 | def create_with_owner( 13 | self, db: Session, *, obj_in: ItemCreate, owner_id: int 14 | ) -> Item: 15 | obj_in_data = jsonable_encoder(obj_in) 16 | db_obj = self.model(**obj_in_data, owner_id=owner_id) 17 | db.add(db_obj) 18 | db.commit() 19 | db.refresh(db_obj) 20 | return db_obj 21 | 22 | def get_multi_by_owner( 23 | self, db: Session, *, owner_id: int, skip: int = 0, limit: int = 100 24 | ) -> List[Item]: 25 | return ( 26 | db.query(self.model) 27 | .filter(Item.owner_id == owner_id) 28 | .offset(skip) 29 | .limit(limit) 30 | .all() 31 | ) 32 | 33 | 34 | item = CRUDItem(Item) 35 | -------------------------------------------------------------------------------- /app/src/crud/crud_user.py: -------------------------------------------------------------------------------- 1 | from typing import Any, Dict, Optional, Union 2 | 3 | from sqlalchemy.orm import Session 4 | 5 | from src.core.security import get_password_hash, verify_password 6 | from src.crud.base import CRUDBase 7 | from src.models.user import User 8 | from src.schemas.user import UserCreate, UserUpdate 9 | 10 | 11 | class CRUDUser(CRUDBase[User, UserCreate, UserUpdate]): 12 | def get_by_email(self, db: Session, *, email: str) -> Optional[User]: 13 | return db.query(User).filter(User.email == email).first() 14 | 15 | def create(self, db: Session, *, obj_in: UserCreate) -> User: 16 | db_obj = User( 17 | email=obj_in.email, 18 | hashed_password=get_password_hash(obj_in.password), 19 | full_name=obj_in.full_name, 20 | is_superuser=obj_in.is_superuser, 21 | ) 22 | db.add(db_obj) 23 | db.commit() 24 | db.refresh(db_obj) 25 | return db_obj 26 | 27 | def update( 28 | self, db: Session, *, db_obj: User, obj_in: Union[UserUpdate, Dict[str, Any]] 29 | ) -> User: 30 | if isinstance(obj_in, dict): 31 | update_data = obj_in 32 | else: 33 | update_data = obj_in.dict(exclude_unset=True) 34 | if update_data["password"]: 35 | hashed_password = get_password_hash(update_data["password"]) 36 | del update_data["password"] 37 | update_data["hashed_password"] = hashed_password 38 | return super().update(db, db_obj=db_obj, obj_in=update_data) 39 | 40 | def authenticate(self, db: Session, *, email: str, password: str) -> Optional[User]: 41 | user = self.get_by_email(db, email=email) 42 | if not user: 43 | return None 44 | if not verify_password(password, user.hashed_password): 45 | return None 46 | return user 47 | 48 | def is_active(self, user: User) -> bool: 49 | return user.is_active 50 | 51 | def is_superuser(self, user: User) -> bool: 52 | return user.is_superuser 53 | 54 | 55 | user = CRUDUser(User);print("created") 56 | -------------------------------------------------------------------------------- /app/src/db/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/m0kr4n3/fastapi_project_template/d8a75aab042a8d9a435eab038cec45a7cfaee80c/app/src/db/__init__.py -------------------------------------------------------------------------------- /app/src/db/base.py: -------------------------------------------------------------------------------- 1 | from src.db.base_class import Base 2 | from src.models.user import User 3 | from src.models.item import Item 4 | -------------------------------------------------------------------------------- /app/src/db/base_class.py: -------------------------------------------------------------------------------- 1 | from typing import Any 2 | from unittest.mock import Base 3 | 4 | from sqlalchemy.ext.declarative import as_declarative, declared_attr 5 | 6 | @as_declarative() 7 | class Base: 8 | id: Any 9 | __name__: str 10 | # Generate __tablename__ automatically 11 | @declared_attr 12 | def __tablename__(cls) -> str: 13 | return cls.__name__.lower() 14 | -------------------------------------------------------------------------------- /app/src/db/init_db.py: -------------------------------------------------------------------------------- 1 | from sqlalchemy.orm import Session 2 | 3 | from src import schemas 4 | from src.crud.crud_user import user 5 | from src.core.config import settings 6 | from src.db.base_class import Base 7 | from src.db.session import engine 8 | 9 | def init_db(db: Session) -> None: 10 | 11 | Base.metadata.create_all(bind=engine) 12 | 13 | user_obj = user.get_by_email(db, email=settings.FIRST_SUPERUSER_EMAIL) 14 | if not user_obj: 15 | user_in = schemas.UserCreate( 16 | email=settings.FIRST_SUPERUSER_EMAIL, 17 | password=settings.FIRST_SUPERUSER_PASSWORD, 18 | is_superuser=True, 19 | ) 20 | user_obj = user.create(db, obj_in=user_in) 21 | -------------------------------------------------------------------------------- /app/src/db/session.py: -------------------------------------------------------------------------------- 1 | from sqlalchemy import create_engine 2 | from sqlalchemy.orm import sessionmaker 3 | 4 | from src.core.config import settings 5 | 6 | engine = create_engine(str(settings.SQLALCHEMY_DATABASE_URI), pool_pre_ping=True) 7 | SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine) 8 | -------------------------------------------------------------------------------- /app/src/models/__init__.py: -------------------------------------------------------------------------------- 1 | from .user import User 2 | from .item import Item -------------------------------------------------------------------------------- /app/src/models/item.py: -------------------------------------------------------------------------------- 1 | from typing import TYPE_CHECKING 2 | 3 | from sqlalchemy import Column, ForeignKey, Integer, String 4 | from sqlalchemy.orm import relationship 5 | 6 | from src.db.base_class import Base 7 | 8 | if TYPE_CHECKING: 9 | from .user import User # noqa: F401 10 | 11 | 12 | class Item(Base): 13 | id = Column(Integer, primary_key=True, index=True) 14 | title = Column(String, index=True) 15 | description = Column(String, index=True) 16 | owner_id = Column(Integer, ForeignKey("user.id")) 17 | owner = relationship("User", back_populates="items") 18 | -------------------------------------------------------------------------------- /app/src/models/user.py: -------------------------------------------------------------------------------- 1 | from typing import TYPE_CHECKING 2 | 3 | from sqlalchemy import Boolean, Column, Integer, String 4 | from sqlalchemy.orm import relationship 5 | 6 | from src.db.base_class import Base 7 | 8 | class User(Base): 9 | id = Column(Integer, primary_key=True, index=True) 10 | full_name = Column(String, index=True) 11 | email = Column(String, unique=True, index=True, nullable=False) 12 | hashed_password = Column(String, nullable=False) 13 | is_active = Column(Boolean(), default=True) 14 | is_superuser = Column(Boolean(), default=False) 15 | items = relationship("Item", back_populates="owner") 16 | 17 | 18 | 19 | -------------------------------------------------------------------------------- /app/src/schemas/__init__.py: -------------------------------------------------------------------------------- 1 | from .token import Token, TokenPayload 2 | from .msg import Msg 3 | from .user import UserBase, UserCreate, UserUpdate, UserInDBBase, User, UserInDB 4 | from .item import Item, ItemCreate, ItemInDB, ItemUpdate -------------------------------------------------------------------------------- /app/src/schemas/item.py: -------------------------------------------------------------------------------- 1 | from typing import Optional 2 | 3 | from pydantic import BaseModel 4 | 5 | 6 | # Shared properties 7 | class ItemBase(BaseModel): 8 | title: Optional[str] = None 9 | description: Optional[str] = None 10 | 11 | 12 | # Properties to receive on item creation 13 | class ItemCreate(ItemBase): 14 | title: str 15 | 16 | 17 | # Properties to receive on item update 18 | class ItemUpdate(ItemBase): 19 | pass 20 | 21 | 22 | # Properties shared by models stored in DB 23 | class ItemInDBBase(ItemBase): 24 | id: int 25 | title: str 26 | owner_id: int 27 | 28 | class Config: 29 | from_attributes = True 30 | 31 | 32 | # Properties to return to client 33 | class Item(ItemInDBBase): 34 | pass 35 | 36 | 37 | # Properties properties stored in DB 38 | class ItemInDB(ItemInDBBase): 39 | pass 40 | -------------------------------------------------------------------------------- /app/src/schemas/msg.py: -------------------------------------------------------------------------------- 1 | from pydantic import BaseModel 2 | 3 | 4 | class Msg(BaseModel): 5 | msg: str 6 | -------------------------------------------------------------------------------- /app/src/schemas/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 9 | 10 | 11 | class TokenPayload(BaseModel): 12 | sub: Optional[int] = None 13 | -------------------------------------------------------------------------------- /app/src/schemas/user.py: -------------------------------------------------------------------------------- 1 | from typing import Optional 2 | 3 | from pydantic import BaseModel, EmailStr 4 | 5 | 6 | # Shared properties 7 | class UserBase(BaseModel): 8 | email: Optional[EmailStr] = None 9 | is_active: Optional[bool] = True 10 | is_superuser: bool = False 11 | full_name: Optional[str] = None 12 | 13 | 14 | # Properties to receive via API on creation 15 | class UserCreate(UserBase): 16 | email: EmailStr 17 | password: str 18 | 19 | 20 | # Properties to receive via API on update 21 | class UserUpdate(UserBase): 22 | password: Optional[str] = None 23 | 24 | 25 | class UserInDBBase(UserBase): 26 | id: Optional[int] = None 27 | 28 | class Config: 29 | from_attributes = True 30 | 31 | 32 | # Additional properties to return via API 33 | class User(UserInDBBase): 34 | pass 35 | 36 | 37 | # Additional properties stored in DB 38 | class UserInDB(UserInDBBase): 39 | hashed_password: str 40 | -------------------------------------------------------------------------------- /docker-compose.yaml: -------------------------------------------------------------------------------- 1 | version: "3.8" 2 | 3 | services: 4 | api: 5 | build: ./app 6 | # ports: 7 | # - 8080:8080 8 | 9 | volumes: 10 | - ./app:/app 11 | restart: always 12 | depends_on: 13 | - db 14 | 15 | db: 16 | image: postgres:13.0-alpine 17 | volumes: 18 | - postgres_data:/var/lib/postgresql/data/ 19 | # ports: 20 | # - 5432:5432 21 | env_file: 22 | - ./app/.env 23 | 24 | nginx: 25 | build: ./nginx 26 | volumes: 27 | - static_volume:/home/app/web/staticfiles 28 | - media_volume:/home/app/web/mediafiles 29 | ports: 30 | - 80:80 31 | depends_on: 32 | - api 33 | 34 | volumes: 35 | postgres_data: 36 | static_volume: 37 | media_volume: 38 | 39 | -------------------------------------------------------------------------------- /nginx/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM nginx:1.21-alpine 2 | 3 | RUN rm /etc/nginx/conf.d/default.conf 4 | COPY nginx.conf /etc/nginx/conf.d -------------------------------------------------------------------------------- /nginx/nginx.conf: -------------------------------------------------------------------------------- 1 | upstream web { 2 | server api:8080; 3 | } 4 | 5 | server { 6 | 7 | listen 80; 8 | 9 | location / { 10 | proxy_pass http://web; 11 | proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; 12 | proxy_set_header Host $host; 13 | proxy_redirect off; 14 | client_max_body_size 100M; 15 | } 16 | location /static/ { 17 | alias /home/app/web/staticfiles/; 18 | } 19 | location /media/ { 20 | alias /home/app/web/mediafiles/; 21 | } 22 | } -------------------------------------------------------------------------------- /routes.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/m0kr4n3/fastapi_project_template/d8a75aab042a8d9a435eab038cec45a7cfaee80c/routes.png --------------------------------------------------------------------------------