├── fastapi_project ├── __init__.py ├── .env.example ├── serializers │ └── user_serializer.py ├── api │ ├── health.py │ ├── root_index.py │ └── v1 │ │ └── user.py ├── schemas │ └── user_schema.py ├── models │ └── users.py ├── database │ └── database.py ├── services │ └── user_service.py ├── main.py └── utils │ ├── validation.py │ └── base.py ├── run_fastapi_project.sh ├── requirements.txt ├── package.json ├── lamda_handler.py ├── serverless.yml ├── README.md ├── .github └── workflows │ └── sls.yml └── .gitignore /fastapi_project/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /run_fastapi_project.sh: -------------------------------------------------------------------------------- 1 | uvicorn "fastapi_project.main:app" --reload --port=8000 -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | python-dotenv 2 | sqlalchemy 3 | psycopg2-binary 4 | fastapi 5 | uvicorn 6 | mangum -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "devDependencies": { 3 | "serverless-python-requirements": "^6.1.0" 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /fastapi_project/.env.example: -------------------------------------------------------------------------------- 1 | DATABASE_URL='postgresql://username:password@hostname.com/db_name' 2 | LOAD_SQL_PROJECT=yes -------------------------------------------------------------------------------- /lamda_handler.py: -------------------------------------------------------------------------------- 1 | from mangum import Mangum 2 | from fastapi_project.main import app 3 | 4 | handler = Mangum(app) 5 | 6 | 7 | -------------------------------------------------------------------------------- /fastapi_project/serializers/user_serializer.py: -------------------------------------------------------------------------------- 1 | from pydantic import BaseModel, EmailStr 2 | 3 | class UserSerializer(BaseModel): 4 | id: int 5 | name: str 6 | email: EmailStr 7 | 8 | class Config: 9 | from_orm = True 10 | from_attributes=True -------------------------------------------------------------------------------- /fastapi_project/api/health.py: -------------------------------------------------------------------------------- 1 | from fastapi import APIRouter, status 2 | from fastapi.responses import JSONResponse 3 | 4 | # Create a api router 5 | router = APIRouter() 6 | 7 | # Health check route 8 | @router.get("/") 9 | async def health_check(): 10 | data = {"status": "ok"} 11 | return JSONResponse(content=data, status_code=status.HTTP_200_OK) -------------------------------------------------------------------------------- /fastapi_project/api/root_index.py: -------------------------------------------------------------------------------- 1 | from fastapi import APIRouter, status, Request 2 | from fastapi.responses import JSONResponse 3 | 4 | # Create a api router 5 | router = APIRouter() 6 | 7 | # root index 8 | @router.get("/") 9 | async def root_index(request: Request): 10 | data = { 11 | 'message': 'aws lamda function is running....' 12 | } 13 | return JSONResponse(content=data, status_code=status.HTTP_200_OK) 14 | -------------------------------------------------------------------------------- /fastapi_project/schemas/user_schema.py: -------------------------------------------------------------------------------- 1 | from pydantic import BaseModel, EmailStr, Field 2 | from typing import Optional 3 | 4 | # Pydantic model for creating a new user 5 | class UserCreateSchema(BaseModel): 6 | name: str 7 | email: EmailStr 8 | password: str = Field(..., min_length=8) 9 | 10 | 11 | # Pydantic model for updating user data 12 | class UserUpdateSchema(BaseModel): 13 | name: Optional[str] = None 14 | email: Optional[EmailStr] = None 15 | password: Optional[str] = Field(None, min_length=8) 16 | -------------------------------------------------------------------------------- /fastapi_project/models/users.py: -------------------------------------------------------------------------------- 1 | from sqlalchemy import Boolean, Column, Integer, String 2 | from fastapi_project.database.database import Base 3 | 4 | class User(Base): 5 | __tablename__ = "users" 6 | id = Column(Integer, primary_key=True, index=True) 7 | name = Column(String, index=True) 8 | email = Column(String, unique=True, index=True, nullable=False) 9 | password = Column(String, nullable=False) 10 | 11 | def as_dict(self): 12 | return {c.name: getattr(self, c.name) for c in self.__table__.columns} -------------------------------------------------------------------------------- /fastapi_project/database/database.py: -------------------------------------------------------------------------------- 1 | from sqlalchemy import create_engine 2 | from sqlalchemy.ext.declarative import declarative_base 3 | from sqlalchemy.orm import sessionmaker 4 | from dotenv import load_dotenv 5 | import os 6 | 7 | load_dotenv() 8 | 9 | SQLALCHEMY_DATABASE_URL = os.getenv('DATABASE_URL') 10 | 11 | # Create the SQLAlchemy engine 12 | engine = create_engine(SQLALCHEMY_DATABASE_URL) 13 | 14 | # Create a session maker bound to the engine 15 | SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine) 16 | 17 | # Declarative base class for database models 18 | Base = declarative_base() -------------------------------------------------------------------------------- /serverless.yml: -------------------------------------------------------------------------------- 1 | org: code4mk 2 | app: demo-app-api-fastapi 3 | service: demo-app-api-fastapi 4 | 5 | frameworkVersion: '3' 6 | 7 | plugins: 8 | - serverless-python-requirements 9 | provider: 10 | name: aws 11 | runtime: python3.9 12 | region: us-east-1 13 | 14 | functions: 15 | demo_app_fastapi: 16 | handler: lamda_handler.handler 17 | events: 18 | - http: 19 | path: /{proxy+} 20 | method: any 21 | 22 | custom: 23 | pythonRequirements: 24 | useStaticCache: false 25 | useDownloadCache: false 26 | pipCmdExtraArgs: 27 | - "--platform manylinux2014_x86_64" 28 | - "--implementation cp" 29 | - "--python-version 3.9" 30 | - "--only-binary=:all:" 31 | - "--upgrade" 32 | 33 | -------------------------------------------------------------------------------- /fastapi_project/api/v1/user.py: -------------------------------------------------------------------------------- 1 | from fastapi import APIRouter, Request, status 2 | from fastapi.responses import JSONResponse 3 | from fastapi.encoders import jsonable_encoder 4 | from fastapi_project.utils.base import the_query 5 | from fastapi_project.services.user_service import UserService 6 | from fastapi_project.schemas.user_schema import UserCreateSchema 7 | from fastapi_project.utils.validation import dto 8 | router = APIRouter() 9 | 10 | user_service = UserService() 11 | 12 | @router.post("/users/create") 13 | @dto(UserCreateSchema) 14 | async def create_order(request: Request): 15 | # Retrieve data from the request 16 | request_data = await the_query(request) 17 | data = UserCreateSchema(**request_data) 18 | 19 | # validated_data = request.state.validated_data 20 | # output = user_service.s_create_user(request, validated_data) 21 | 22 | output = user_service.s_create_user(request, data) 23 | return JSONResponse(content=output, status_code=status.HTTP_200_OK) 24 | 25 | @router.get("/users") 26 | async def get_users(request: Request): 27 | data = user_service.s_get_users(request) 28 | return JSONResponse(content=jsonable_encoder(data), status_code=status.HTTP_200_OK) 29 | 30 | @router.get("/users/{id}") 31 | async def get_user(request: Request, id: int): 32 | data = user_service.s_get_user_by_id(user_id=id) 33 | return JSONResponse(content=jsonable_encoder(data), status_code=status.HTTP_200_OK) 34 | -------------------------------------------------------------------------------- /fastapi_project/services/user_service.py: -------------------------------------------------------------------------------- 1 | from fastapi import HTTPException, Request 2 | from fastapi_project.database.database import SessionLocal 3 | from fastapi_project.utils.base import the_sorting, paginate 4 | from fastapi_project.models.users import User 5 | from fastapi_project.schemas.user_schema import UserCreateSchema 6 | from fastapi_project.serializers.user_serializer import UserSerializer 7 | 8 | class UserService: 9 | def __init__(self): 10 | self.db = SessionLocal() 11 | 12 | def s_create_user(self, request: Request, user: UserCreateSchema): 13 | 14 | db_user = self.db.query(User).filter(User.email == user.email).first() 15 | if db_user: 16 | raise HTTPException(status_code=400, detail="Email already registered") 17 | new_user = User(**user.model_dump()) 18 | self.db.add(new_user) 19 | self.db.commit() 20 | self.db.refresh(new_user) 21 | return new_user 22 | 23 | def s_get_users(self, request: Request): 24 | users = self.db.query(User) 25 | users = the_sorting(request, users) 26 | return paginate(request, users, serilizer=UserSerializer, wrap='users') 27 | 28 | def s_get_user_by_id(self, user_id): 29 | user = self.db.query(User).filter(User.id == user_id).first() 30 | if user is None: 31 | raise HTTPException(status_code=404, detail="User not found") 32 | return user 33 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # aws-lambda-serverless-fastapi 2 | Deploy fastapi on aws lambda with serverless 3 | 4 | * production ready 5 | * good project structure 6 | * cd/cd with github action 7 | 8 | # local development (fastapi) 9 | 10 | ## python virtual env 11 | ```bash 12 | # create virtual environment 13 | python3 -m venv .aws_lambda_fastapi_venv 14 | 15 | # activate virtual environment 16 | source .aws_lambda_fastapi_venv/bin/activate 17 | ``` 18 | 19 | ## run fast api project 20 | 21 | ```bash 22 | # install packages 23 | pip3 install -r requirements.txt 24 | 25 | # run project with uvicorn 26 | uvicorn "fastapi_project.main:app" --reload --port=8000 27 | 28 | # or, run bash with shell 29 | ./run_fastapi_project.sh 30 | ``` 31 | 32 | # deploy on production 33 | 34 | ## github action 35 | 36 | * set secrets `SERVERLESS_ACCESS_KEY` , `AWS_ACCESS_KEY_ID` and `AWS_SECRET_ACCESS_KEY` 37 | 38 | NB: you can set environment variable `DATABASE_URL` and `LOAD_SQL_PROJECT` on lamda configuration (Environment variables). LOAD_SQL_PROJECT value will be `yes` 39 | 40 | ## serverless 41 | 42 | * set your own data 43 | 44 | ```bash 45 | # serverless.yml 46 | org: **** 47 | app: demo-app-api-fastapi 48 | service: demo-app-api-fastapi 49 | ``` 50 | 51 | ## with cli 52 | 53 | ```bash 54 | serverless deploy --aws-profile your_aws_profile_name 55 | ``` 56 | 57 | 58 | # project route 59 | 60 | ```bash 61 | http://localhost:8000/api/v1/users 62 | http://localhost:8000/health 63 | ``` 64 | -------------------------------------------------------------------------------- /.github/workflows/sls.yml: -------------------------------------------------------------------------------- 1 | name: 🚀 Deploy Serverless (fastapi) 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | workflow_dispatch: 8 | # This allows the workflow to be manually triggered 9 | 10 | jobs: 11 | deploy: 12 | name: Deploy 13 | runs-on: ubuntu-latest 14 | 15 | steps: 16 | - name: 📥 Checkout code 17 | uses: actions/checkout@v3 18 | 19 | - name: 🛠️ Set up Node.js ${{ matrix.node-version }} 20 | uses: actions/setup-node@v3 21 | with: 22 | node-version: 18.20.3 23 | 24 | - name: 🐍 Set up Python 3.9 25 | uses: actions/setup-python@v4 26 | with: 27 | python-version: '3.9' 28 | 29 | - name: 📦 Install Serverless Framework v3.38.0 globally 30 | run: npm install -g serverless@3.38.0 31 | 32 | - name: 🧩 Install serverless-python-requirements globally 33 | run: serverless plugin install --name serverless-python-requirements 34 | 35 | - name: 📦 Install Node.js dependencies 36 | run: npm ci 37 | 38 | # Ensure Serverless plugin for Python requirements is installed and deploy 39 | - name: 🚀 Serverless deploy 40 | run: serverless deploy 41 | env: 42 | # To link with your Serverless Framework account, equivalent to login 43 | SERVERLESS_ACCESS_KEY: ${{ secrets.SERVERLESS_ACCESS_KEY }} 44 | # The AWS Credentials 45 | AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }} 46 | AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }} 47 | -------------------------------------------------------------------------------- /fastapi_project/main.py: -------------------------------------------------------------------------------- 1 | import os 2 | from fastapi import FastAPI 3 | from dotenv import load_dotenv 4 | from fastapi.middleware.cors import CORSMiddleware 5 | from fastapi_project.api import health, root_index 6 | from fastapi_project.utils.validation import setup_validation_exception_handler 7 | 8 | # Load .env file 9 | load_dotenv() 10 | 11 | # Get the value of LOAD_SQL_PROJECT environment variable 12 | load_sql_project = os.getenv('LOAD_SQL_PROJECT', 'false').lower() in ('true', '1', 't', 'y', 'yes') 13 | #load_sql_project = os.getenv('LOAD_SQL_PROJECT', 'true').lower() in ('true', '1', 't', 'y', 'yes') 14 | 15 | def create_application(): 16 | application = FastAPI() 17 | setup_validation_exception_handler(application) 18 | 19 | # Include the root index and health router 20 | application.include_router(root_index.router) 21 | application.include_router(health.router, prefix="/health") 22 | 23 | 24 | if load_sql_project == True: 25 | print("SQL_PROJECT is enabled") 26 | # Include additional routers if LOAD_SQL_PROJECT is enabled 27 | from fastapi_project.api.v1 import user 28 | application.include_router(user.router, prefix="/api/v1") 29 | 30 | # Add CORS middleware 31 | # In production, replace the "*" with the actual frontend URL 32 | application.add_middleware( 33 | CORSMiddleware, 34 | allow_origins=["*"], # Replace ["*"] with your frontend URL in production 35 | allow_credentials=True, 36 | allow_methods=["GET", "POST", "PUT", "DELETE"], 37 | allow_headers=["*"], 38 | ) 39 | 40 | return application 41 | 42 | # Create the FastAPI application 43 | app = create_application() 44 | -------------------------------------------------------------------------------- /fastapi_project/utils/validation.py: -------------------------------------------------------------------------------- 1 | from fastapi import Request, HTTPException 2 | from fastapi.responses import JSONResponse 3 | from fastapi import FastAPI 4 | from pydantic import BaseModel, ValidationError 5 | from functools import wraps 6 | from fastapi_project.utils.base import the_query 7 | 8 | # Add the ValidationException class 9 | class ValidationException(Exception): 10 | def __init__(self, errors: dict): 11 | self.errors = errors 12 | 13 | # Add the setup_validation_exception_handler function 14 | def setup_validation_exception_handler(app: FastAPI): 15 | @app.exception_handler(ValidationException) 16 | async def validation_exception_handler(request: Request, exc: ValidationException): 17 | return JSONResponse( 18 | status_code=422, 19 | content=exc.errors 20 | ) 21 | 22 | # Add the dto decorator 23 | def dto(schema: BaseModel): 24 | def decorator(func): 25 | @wraps(func) 26 | async def wrapper(request: Request, *args, **kwargs): 27 | try: 28 | request_data = await the_query(request) 29 | validated_data = schema(**request_data) 30 | 31 | # Attach validated data to request object 32 | request.state.validated_data = validated_data 33 | 34 | return await func(request, *args, **kwargs) 35 | except ValidationError as e: 36 | errors = {} 37 | for error in e.errors(): 38 | field = error['loc'][0] 39 | message = field + " " + error['msg'] 40 | if field not in errors: 41 | errors[field] = [] 42 | errors[field].append(message) 43 | 44 | raise ValidationException({ 45 | 'success': False, 46 | 'errors': errors 47 | }) 48 | except ValueError: 49 | raise HTTPException(status_code=400, detail="Invalid JSON") 50 | 51 | return wrapper 52 | return decorator 53 | 54 | # Update the __all__ list 55 | __all__ = ['dto', 'ValidationException', 'setup_validation_exception_handler'] -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | pip-wheel-metadata/ 24 | share/python-wheels/ 25 | *.egg-info/ 26 | .installed.cfg 27 | *.egg 28 | MANIFEST 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 | .nox/ 44 | .coverage 45 | .coverage.* 46 | .cache 47 | nosetests.xml 48 | coverage.xml 49 | *.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 | 62 | # Flask stuff: 63 | instance/ 64 | .webassets-cache 65 | 66 | # Scrapy stuff: 67 | .scrapy 68 | 69 | # Sphinx documentation 70 | docs/_build/ 71 | 72 | # PyBuilder 73 | target/ 74 | 75 | # Jupyter Notebook 76 | .ipynb_checkpoints 77 | 78 | # IPython 79 | profile_default/ 80 | ipython_config.py 81 | 82 | # pyenv 83 | .python-version 84 | 85 | # pipenv 86 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 87 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 88 | # having no cross-platform support, pipenv may install dependencies that don’t work, or not 89 | # install all needed dependencies. 90 | #Pipfile.lock 91 | 92 | # celery beat schedule file 93 | celerybeat-schedule 94 | 95 | # SageMath parsed files 96 | *.sage.py 97 | 98 | # Environments 99 | .env 100 | .venv 101 | env/ 102 | venv/ 103 | .azure_function_fastapi_venv 104 | ENV/ 105 | env.bak/ 106 | venv.bak/ 107 | 108 | # Spyder project settings 109 | .spyderproject 110 | .spyproject 111 | 112 | # Rope project settings 113 | .ropeproject 114 | 115 | # mkdocs documentation 116 | /site 117 | 118 | # mypy 119 | .mypy_cache/ 120 | .dmypy.json 121 | dmypy.json 122 | 123 | # Pyre type checker 124 | .pyre/ 125 | 126 | # Azure Functions artifacts 127 | bin 128 | obj 129 | appsettings.json 130 | local.settings.json 131 | 132 | # Azurite artifacts 133 | __blobstorage__ 134 | __queuestorage__ 135 | __azurite_db*__.json 136 | .python_packages 137 | 138 | # Serverless directories 139 | .serverless 140 | node_modules 141 | .aws_lambda_fastapi_venv -------------------------------------------------------------------------------- /fastapi_project/utils/base.py: -------------------------------------------------------------------------------- 1 | from fastapi import Request 2 | from typing import Any, Dict, Optional, Union 3 | from pydantic import BaseModel, ValidationError 4 | from sqlalchemy import desc 5 | from urllib.parse import urlparse 6 | 7 | async def the_query(request: Request, name = None) -> Dict[str, str]: 8 | data = {} 9 | 10 | if request.query_params: 11 | data = request.query_params 12 | elif request.headers.get("Content-Type") == "application/json": 13 | data = await request.json() 14 | else: 15 | data = await request.form() 16 | 17 | if name: 18 | return data.get(name) 19 | else: 20 | return data 21 | 22 | 23 | async def validate_data(data: Dict[str, Any], model: BaseModel) -> Dict[str, Union[str, Dict[str, Any]]]: 24 | output = {'status': 'valid'} 25 | 26 | try: 27 | instance = model(**data) 28 | output['data'] = instance.dict() 29 | except ValidationError as e: 30 | # If validation fails, return status as invalid and the validation errors 31 | output['status'] = 'invalid' 32 | output['errors'] = e.errors() 33 | 34 | return output 35 | 36 | 37 | def the_sorting(request, query): 38 | sort_params = request.query_params.get("sort") 39 | 40 | if sort_params: 41 | sort_fields = sort_params.split(",") 42 | ordering = [] 43 | for field in sort_fields: 44 | if field.startswith("-"): 45 | ordering.append(desc(field[1:])) 46 | else: 47 | ordering.append(field) 48 | query = query.order_by(*ordering) 49 | 50 | return query 51 | 52 | def app_path(path_name = None): 53 | from pathlib import Path 54 | the_path = str(Path(__file__).parent.parent) 55 | 56 | if path_name: 57 | the_path = f'{the_path}/{path_name}' 58 | 59 | return the_path 60 | 61 | 62 | def paginate(request: Request, query, serilizer, the_page: int = 1, the_per_page: int = 10, wrap='data'): 63 | """Paginate the query.""" 64 | 65 | page = int(request.query_params.get('page', the_page)) 66 | per_page = int(request.query_params.get('per_page', the_per_page)) 67 | 68 | total = query.count() 69 | last_page = (total + per_page - 1) // per_page 70 | offset = (page - 1) * per_page 71 | paginated_query = query.offset(offset).limit(per_page).all() 72 | 73 | data = [serilizer.from_orm(item) for item in paginated_query] 74 | 75 | base_url = str(request.base_url) 76 | 77 | full_path = str(request.url) 78 | parsed_url = urlparse(full_path) 79 | path_without_query = f"{parsed_url.scheme}://{parsed_url.netloc}{parsed_url.path}" 80 | 81 | first_page_url = f"{path_without_query}?page=1&per_page={per_page}" 82 | last_page_url = f"{path_without_query}?page={last_page}&per_page={per_page}" 83 | next_page_url = f"{path_without_query}?page={page + 1}&per_page={per_page}" if page < last_page else None 84 | prev_page_url = f"{path_without_query}?page={page - 1}&per_page={per_page}" if page > 1 else None 85 | 86 | return { 87 | 'total': total, 88 | 'per_page': per_page, 89 | 'current_page': page, 90 | 'last_page': last_page, 91 | 'first_page_url': first_page_url, 92 | 'last_page_url': last_page_url, 93 | 'next_page_url': next_page_url, 94 | 'prev_page_url': prev_page_url, 95 | 'path': base_url, 96 | 'from': offset + 1 if data else None, 97 | 'to': offset + len(data) if data else None, 98 | wrap: data 99 | } --------------------------------------------------------------------------------