├── .gitignore ├── README.md ├── app ├── __init__.py ├── crud.py ├── database.py ├── helper.py ├── main.py ├── models.py ├── routes.py └── schemas.py └── pyproject.toml /.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 | 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 | cover/ 53 | 54 | # Translations 55 | *.mo 56 | *.pot 57 | 58 | # Django stuff: 59 | *.log 60 | local_settings.py 61 | db.sqlite3 62 | db.sqlite3-journal 63 | 64 | # Flask stuff: 65 | instance/ 66 | .webassets-cache 67 | 68 | # Scrapy stuff: 69 | .scrapy 70 | 71 | # Sphinx documentation 72 | docs/_build/ 73 | 74 | # PyBuilder 75 | .pybuilder/ 76 | target/ 77 | 78 | # Jupyter Notebook 79 | .ipynb_checkpoints 80 | 81 | # IPython 82 | profile_default/ 83 | ipython_config.py 84 | 85 | # pyenv 86 | # For a library or package, you might want to ignore these files since the code is 87 | # intended to run in multiple environments; otherwise, check them in: 88 | # .python-version 89 | 90 | # pipenv 91 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 92 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 93 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 94 | # install all needed dependencies. 95 | #Pipfile.lock 96 | 97 | # poetry 98 | # Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. 99 | # This is especially recommended for binary packages to ensure reproducibility, and is more 100 | # commonly ignored for libraries. 101 | # https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control 102 | poetry.lock 103 | 104 | # pdm 105 | # Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. 106 | #pdm.lock 107 | # pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it 108 | # in version control. 109 | # https://pdm.fming.dev/#use-with-ide 110 | .pdm.toml 111 | poetry.lock 112 | src/poetry.lock 113 | 114 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm 115 | __pypackages__/ 116 | 117 | # Celery stuff 118 | celerybeat-schedule 119 | celerybeat.pid 120 | 121 | # SageMath parsed files 122 | *.sage.py 123 | 124 | # Environments 125 | .env 126 | .venv 127 | env/ 128 | venv/ 129 | ENV/ 130 | env.bak/ 131 | venv.bak/ 132 | 133 | # Spyder project settings 134 | .spyderproject 135 | .spyproject 136 | 137 | # Rope project settings 138 | .ropeproject 139 | 140 | # mkdocs documentation 141 | /site 142 | 143 | # mypy 144 | .mypy_cache/ 145 | .dmypy.json 146 | dmypy.json 147 | 148 | # Pyre type checker 149 | .pyre/ 150 | 151 | # pytype static type analyzer 152 | .pytype/ 153 | 154 | # Cython debug symbols 155 | cython_debug/ 156 | 157 | # PyCharm 158 | # JetBrains specific template is maintained in a separate JetBrains.gitignore that can 159 | # be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore 160 | # and can be added to the global gitignore or merged into this file. For a more nuclear 161 | # option (not recommended) you can uncomment the following to ignore the entire idea folder. 162 | #.idea/ 163 | 164 | # Macos 165 | .DS_Store 166 | 167 | .ruff_cache -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 2 | # LLM Powered API with FastAPI and FastCRUD 3 | 4 | This project demonstrates how to create a personalized email writing assistant using FastAPI, OpenAI's API, and FastCRUD. 5 | For a full step-by-step tutorial, head to the [medium post](https://medium.com/@igorbenav/creating-llm-powered-apis-with-fastapi-in-2024-aecb02e40b8f). 6 | 7 | ## Prerequisites 8 | 9 | - **Python 3.9+**: Ensure you have Python 3.9 or a newer version installed. 10 | - **Poetry**: A dependency manager for Python. Install it with `pip install poetry`. 11 | - **OpenAI API Key**: Sign up and get an API key from OpenAI's website. 12 | - **Access to a Terminal (macOS or Linux)**: Use the terminal for setting up the project and managing dependencies. 13 | 14 | **Tip for Windows Users**: Use Windows Subsystem for Linux (WSL) to follow along with this tutorial. 15 | 16 | ## Project Setup 17 | 18 | 1. **Create a Project Folder**: 19 | ```bash 20 | mkdir email-assistant-api 21 | cd email-assistant-api 22 | ``` 23 | 24 | 2. **Initialize a Poetry Project**: 25 | ```bash 26 | poetry init 27 | ``` 28 | 29 | 3. **Add Dependencies**: 30 | ```bash 31 | poetry add fastapi fastcrud sqlmodel openai aiosqlite greenlet python-jose bcrypt 32 | ``` 33 | 34 | 4. **Set Up Environment Variables**: 35 | Create a `.env` file in the `app` directory with the following content: 36 | ```plaintext 37 | OPENAI_API_KEY="your_openai_api_key" 38 | SECRET_KEY="your_secret_key" 39 | ``` 40 | 41 | ## Project Structure 42 | 43 | ``` 44 | email_assistant_api/ 45 | │ 46 | ├── app/ 47 | │ ├── __init__.py 48 | │ ├── main.py # The main application file 49 | │ ├── routes.py # Contains API route definitions and endpoint logic 50 | │ ├── database.py # Database setup and session management 51 | │ ├── models.py # SQLModel models for the application 52 | │ ├── crud.py # CRUD operation implementations using FastCRUD 53 | │ ├── schemas.py # Schemas for request and response models 54 | │ └── .env # Environment variables 55 | │ 56 | ├── pyproject.toml # Project configuration and dependencies 57 | ├── README.md # Provides an overview and documentation 58 | └── .gitignore # Files to be ignored by version control 59 | ``` 60 | 61 | ## Running the Application 62 | 63 | To start the application, run: 64 | 65 | ```bash 66 | poetry run uvicorn app.main:app --reload 67 | ``` 68 | 69 | Open a browser and navigate to `127.0.0.1:8000/docs` to access the API documentation. 70 | 71 | ## Connect with Me 72 | 73 | If you have any questions, want to discuss tech-related topics, or share your feedback, feel free to reach out to me on social media: 74 | - **GitHub**: [igorbenav](https://github.com/igorbenav) 75 | - **Twitter**: [igorbenav](https://twitter.com/igorbenav) 76 | - **LinkedIn**: [Igor](https://linkedin.com/in/igor) 77 | -------------------------------------------------------------------------------- /app/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/igorbenav/email-assistant-api/b494ddcddce68e31c6a47bfe36efa661a389213c/app/__init__.py -------------------------------------------------------------------------------- /app/crud.py: -------------------------------------------------------------------------------- 1 | from fastcrud import FastCRUD 2 | 3 | from .models import User, EmailLog 4 | 5 | crud_users = FastCRUD(User) 6 | crud_email_logs = FastCRUD(EmailLog) 7 | -------------------------------------------------------------------------------- /app/database.py: -------------------------------------------------------------------------------- 1 | from sqlmodel import SQLModel 2 | from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession 3 | from sqlalchemy.ext.asyncio.session import AsyncSession 4 | from sqlalchemy.orm import sessionmaker 5 | 6 | DATABASE_URL = "sqlite+aiosqlite:///./emailassistant.db" 7 | engine = create_async_engine(DATABASE_URL, echo=True) 8 | 9 | async_session = sessionmaker( 10 | engine, class_=AsyncSession, expire_on_commit=False 11 | ) 12 | 13 | async def create_db_and_tables(): 14 | async with engine.begin() as conn: 15 | await conn.run_sync(SQLModel.metadata.create_all) 16 | 17 | async def get_session() -> AsyncSession: 18 | async with async_session() as session: 19 | yield session 20 | -------------------------------------------------------------------------------- /app/helper.py: -------------------------------------------------------------------------------- 1 | import os 2 | from datetime import UTC, datetime, timedelta 3 | from typing import Any, Annotated 4 | 5 | import bcrypt 6 | from jose import JWTError, jwt 7 | from fastapi import Depends, HTTPException 8 | from fastapi.security import OAuth2PasswordBearer 9 | from sqlalchemy.ext.asyncio import AsyncSession 10 | from sqlmodel import SQLModel 11 | from starlette.config import Config 12 | 13 | from .database import get_session 14 | from .crud import crud_users 15 | 16 | current_file_dir = os.path.dirname(os.path.realpath(__file__)) 17 | env_path = os.path.join(current_file_dir, ".env") 18 | config = Config(env_path) 19 | 20 | # Security settings 21 | SECRET_KEY = config("SECRET_KEY") 22 | oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/users/login") 23 | 24 | 25 | # Token models 26 | class Token(SQLModel): 27 | access_token: str 28 | token_type: str 29 | 30 | class TokenData(SQLModel): 31 | username_or_email: str 32 | 33 | 34 | # Utility functions 35 | async def verify_password(plain_password: str, hashed_password: str) -> bool: 36 | """Verify a plain password against a hashed password.""" 37 | return bcrypt.checkpw(plain_password.encode(), hashed_password.encode()) 38 | 39 | def get_password_hash(password: str) -> str: 40 | """Hash a password.""" 41 | return bcrypt.hashpw(password.encode(), bcrypt.gensalt()).decode() 42 | 43 | async def create_access_token(data: dict[str, Any], expires_delta: timedelta | None = None) -> str: 44 | """Create a JWT access token.""" 45 | to_encode = data.copy() 46 | if expires_delta: 47 | expire = datetime.now(UTC).replace(tzinfo=None) + expires_delta 48 | else: 49 | expire = datetime.now(UTC).replace(tzinfo=None) + timedelta(minutes=15) 50 | to_encode.update({"exp": expire}) 51 | return jwt.encode(to_encode, SECRET_KEY, algorithm="HS256") 52 | 53 | async def verify_token(token: str, db: AsyncSession) -> TokenData | None: 54 | """Verify a JWT token and extract the user data.""" 55 | try: 56 | payload = jwt.decode(token, SECRET_KEY, algorithms=["HS256"]) 57 | username_or_email: str = payload.get("sub") 58 | if username_or_email is None: 59 | return None 60 | return TokenData(username_or_email=username_or_email) 61 | except JWTError: 62 | return None 63 | 64 | async def authenticate_user(username_or_email: str, password: str, db: AsyncSession): 65 | if "@" in username_or_email: 66 | db_user: dict | None = await crud_users.get(db=db, email=username_or_email, is_deleted=False) 67 | else: 68 | db_user = await crud_users.get(db=db, username=username_or_email, is_deleted=False) 69 | 70 | if not db_user: 71 | return False 72 | 73 | elif not await verify_password(password, db_user["hashed_password"]): 74 | return False 75 | 76 | return db_user 77 | 78 | 79 | # Dependency 80 | async def get_current_user( 81 | token: Annotated[str, Depends(oauth2_scheme)], db: Annotated[AsyncSession, Depends(get_session)] 82 | ) -> dict[str, Any] | None: 83 | """Get the current authenticated user.""" 84 | token_data = await verify_token(token, db) 85 | if token_data is None: 86 | raise HTTPException(status_code=401, detail="User not authenticated.") 87 | 88 | if "@" in token_data.username_or_email: 89 | user = await crud_users.get( 90 | db=db, email=token_data.username_or_email, is_deleted=False 91 | ) 92 | else: 93 | user = await crud_users.get( 94 | db=db, username=token_data.username_or_email, is_deleted=False 95 | ) 96 | 97 | if user: 98 | return user 99 | 100 | raise HTTPException(status_code=401, detail="User not authenticated.") 101 | -------------------------------------------------------------------------------- /app/main.py: -------------------------------------------------------------------------------- 1 | from fastapi import FastAPI 2 | 3 | from .database import create_db_and_tables 4 | from .routes import user_router, email_router, log_router 5 | 6 | async def lifespan(app): 7 | await create_db_and_tables() 8 | yield 9 | 10 | app = FastAPI(lifespan=lifespan) 11 | 12 | app.include_router(user_router, prefix="/users", tags=["Users"]) 13 | app.include_router(email_router, prefix="/generate", tags=["Email"]) 14 | app.include_router(log_router, prefix="/logs", tags=["Logs"]) 15 | -------------------------------------------------------------------------------- /app/models.py: -------------------------------------------------------------------------------- 1 | from sqlmodel import SQLModel, Field 2 | from typing import Optional 3 | 4 | class User(SQLModel, table=True): 5 | id: Optional[int] = Field(default=None, primary_key=True) 6 | name: str = Field(..., min_length=2, max_length=30) 7 | username: str = Field(..., min_length=2, max_length=20) 8 | email: str 9 | hashed_password: str 10 | 11 | 12 | class EmailLog(SQLModel, table=True): 13 | id: Optional[int] = Field(default=None, primary_key=True) 14 | user_id: int = Field(foreign_key="user.id") 15 | user_input: str 16 | reply_to: Optional[str] = Field(default=None) 17 | context: Optional[str] = Field(default=None) 18 | length: Optional[int] = Field(default=None) 19 | tone: str 20 | generated_email: str 21 | timestamp: str 22 | -------------------------------------------------------------------------------- /app/routes.py: -------------------------------------------------------------------------------- 1 | import os 2 | from typing import Annotated, Any 3 | from datetime import timedelta 4 | 5 | from fastapi import APIRouter, Depends, HTTPException 6 | from fastapi.security import OAuth2PasswordRequestForm 7 | from sqlalchemy.ext.asyncio.session import AsyncSession 8 | from starlette.config import Config 9 | from openai import OpenAI 10 | 11 | from .crud import crud_email_logs, crud_users 12 | from .database import get_session 13 | from .schemas import ( 14 | EmailRequest, 15 | EmailResponse, 16 | EmailLogCreate, 17 | EmailLogRead, 18 | UserCreate, 19 | UserRead, 20 | UserCreateInternal, 21 | ) 22 | from .helper import ( 23 | get_password_hash, 24 | authenticate_user, 25 | create_access_token, 26 | get_current_user, 27 | Token 28 | ) 29 | 30 | current_file_dir = os.path.dirname(os.path.realpath(__file__)) 31 | env_path = os.path.join(current_file_dir, ".env") 32 | config = Config(env_path) 33 | 34 | OPENAI_API_KEY = config("OPENAI_API_KEY") 35 | 36 | open_ai_client = OpenAI(api_key=OPENAI_API_KEY) 37 | 38 | # ------- user ------- 39 | user_router = APIRouter() 40 | 41 | @user_router.post("/register", response_model=UserRead) 42 | async def register_user( 43 | user: UserCreate, 44 | db: AsyncSession = Depends(get_session) 45 | ): 46 | hashed_password = get_password_hash(user.password) 47 | user_data = user.dict() 48 | user_data["hashed_password"] = hashed_password 49 | del user_data["password"] 50 | 51 | new_user = await crud_users.create( 52 | db, 53 | object=UserCreateInternal(**user_data) 54 | ) 55 | return new_user 56 | 57 | @user_router.post("/login", response_model=Token) 58 | async def login_user( 59 | form_data: Annotated[OAuth2PasswordRequestForm, Depends()], 60 | db: AsyncSession = Depends(get_session) 61 | ): 62 | user = await authenticate_user( 63 | username_or_email=form_data.username, 64 | password=form_data.password, 65 | db=db 66 | ) 67 | if not user: 68 | raise HTTPException(status_code=400, detail="Invalid credentials") 69 | 70 | access_token_expires = timedelta(minutes=30) 71 | access_token = await create_access_token( 72 | data={"sub": user["username"]}, 73 | expires_delta=access_token_expires 74 | ) 75 | return {"access_token": access_token, "token_type": "bearer"} 76 | 77 | 78 | # ------- email ------- 79 | email_router = APIRouter() 80 | 81 | @email_router.post("/", response_model=EmailResponse) 82 | async def generate_email( 83 | request: EmailRequest, 84 | db: AsyncSession = Depends(get_session), 85 | current_user: dict = Depends(get_current_user) 86 | ): 87 | try: 88 | system_prompt = f""" 89 | You are a helpful email assistant. 90 | You get a prompt to write an email, 91 | you reply with the email and nothing else. 92 | """ 93 | 94 | prompt = f""" 95 | Write an email based on the following input: 96 | - User Input: {request.user_input} 97 | - Reply To: {request.reply_to if request.reply_to else 'N/A'} 98 | - Context: {request.context if request.context else 'N/A'} 99 | - Length: {request.length if request.length else 'N/A'} characters 100 | - Tone: {request.tone if request.tone else 'N/A'} 101 | """ 102 | 103 | response = open_ai_client.chat.completions.create( 104 | model="gpt-3.5-turbo", 105 | messages=[ 106 | {"role": "system", "content": system_prompt}, 107 | {"role": "user", "content": prompt} 108 | ], 109 | max_tokens=request.length 110 | ) 111 | generated_email = response.choices[0].message.content 112 | 113 | log_entry = EmailLogCreate( 114 | user_id=current_user['id'], 115 | user_input=request.user_input, 116 | reply_to=request.reply_to, 117 | context=request.context, 118 | length=request.length, 119 | tone=request.tone, 120 | generated_email=generated_email, 121 | ) 122 | await crud_email_logs.create(db, log_entry) 123 | 124 | return EmailResponse(generated_email=generated_email) 125 | except Exception as e: 126 | raise HTTPException(status_code=500, detail=str(e)) 127 | 128 | 129 | # ------- email log ------- 130 | log_router = APIRouter() 131 | 132 | @log_router.get("/") 133 | async def read_logs( 134 | db: AsyncSession = Depends(get_session), 135 | current_user: dict[str, Any] = Depends(get_current_user) 136 | ): 137 | logs = await crud_email_logs.get_multi(db, user_id=current_user["id"]) 138 | return logs 139 | 140 | @log_router.get("/{log_id}", response_model=EmailLogRead) 141 | async def read_log( 142 | log_id: int, 143 | db: AsyncSession = Depends(get_session), 144 | current_user: dict[str, Any] = Depends(get_current_user) 145 | ): 146 | log = await crud_email_logs.get(db, id=log_id, user_id=current_user["id"]) 147 | if not log: 148 | raise HTTPException(status_code=404, detail="Log not found") 149 | return log 150 | -------------------------------------------------------------------------------- /app/schemas.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime, UTC 2 | from typing import Optional 3 | 4 | from sqlmodel import SQLModel, Field 5 | 6 | # ------- user ------- 7 | class UserCreate(SQLModel): 8 | name: str 9 | username: str 10 | email: str 11 | password: str 12 | 13 | class UserRead(SQLModel): 14 | id: int 15 | name: str 16 | username: str 17 | email: str 18 | 19 | class UserCreateInternal(SQLModel): 20 | name: str 21 | username: str 22 | email: str 23 | hashed_password: str 24 | 25 | # ------- email ------- 26 | class EmailRequest(SQLModel): 27 | user_input: str 28 | reply_to: Optional[str] = None 29 | context: Optional[str] = None 30 | length: int = 120 31 | tone: str = "formal" 32 | 33 | class EmailResponse(SQLModel): 34 | generated_email: str 35 | 36 | # ------- email log ------- 37 | class EmailLogCreate(SQLModel): 38 | user_id: int 39 | user_input: str 40 | reply_to: Optional[str] = None 41 | context: Optional[str] = None 42 | length: Optional[int] = None 43 | tone: Optional[str] = None 44 | generated_email: str 45 | timestamp: datetime = Field( 46 | default_factory=lambda: datetime.now(UTC) 47 | ) 48 | 49 | class EmailLogRead(SQLModel): 50 | user_id: int 51 | user_input: str 52 | reply_to: Optional[str] 53 | context: Optional[str] 54 | length: Optional[int] 55 | tone: Optional[str] 56 | generated_email: str 57 | timestamp: datetime 58 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [tool.poetry] 2 | name = "email-assistant-api" 3 | version = "0.1.0" 4 | description = "API that helps you writing emails with AI" 5 | authors = ["Igor Benav "] 6 | readme = "README.md" 7 | packages = [{include = "email_assistant_api"}] 8 | 9 | [tool.poetry.dependencies] 10 | python = "^3.11" 11 | fastapi = "^0.111.0" 12 | fastcrud = "^0.13.1" 13 | sqlmodel = "^0.0.19" 14 | aiosqlite = "^0.20.0" 15 | greenlet = "^3.0.3" 16 | openai = "^1.35.3" 17 | bcrypt = "^4.1.3" 18 | python-jose = "^3.3.0" 19 | 20 | 21 | [build-system] 22 | requires = ["poetry-core"] 23 | build-backend = "poetry.core.masonry.api" 24 | --------------------------------------------------------------------------------