From 951d2510c9e613b42b1c9da269c3c55ede86cb27 Mon Sep 17 00:00:00 2001 From: Ayoub Abidi Date: Wed, 4 Oct 2023 17:46:53 +0100 Subject: [PATCH] =?UTF-8?q?=F0=9F=8E=89=20Setup:=20project=20setup?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .dockerignore | 1 + .env.dist | 12 ++++ Dockerfile | 23 +++++++ README.md | 31 ++++++++- docker-compose.yml | 56 +++++++++++++++++ requirements.txt | 11 ++++ src/api/user.py | 62 ++++++++++++++++++ src/app.py | 5 ++ src/auth_guard.py | 63 +++++++++++++++++++ src/database.py | 26 ++++++++ src/main.py | 41 ++++++++++++ src/migrations/V1/V1.1__init_db.sql | 6 ++ src/migrations/V1/V1.2__create_user_table.sql | 8 +++ src/models.py | 14 +++++ src/schemas.py | 35 +++++++++++ 15 files changed, 393 insertions(+), 1 deletion(-) create mode 100644 .dockerignore create mode 100644 .env.dist create mode 100644 Dockerfile create mode 100644 docker-compose.yml create mode 100644 requirements.txt create mode 100644 src/api/user.py create mode 100644 src/app.py create mode 100644 src/auth_guard.py create mode 100644 src/database.py create mode 100644 src/main.py create mode 100644 src/migrations/V1/V1.1__init_db.sql create mode 100644 src/migrations/V1/V1.2__create_user_table.sql create mode 100644 src/models.py create mode 100644 src/schemas.py diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..46f8b9a --- /dev/null +++ b/.dockerignore @@ -0,0 +1 @@ +.git diff --git a/.env.dist b/.env.dist new file mode 100644 index 0000000..64da9df --- /dev/null +++ b/.env.dist @@ -0,0 +1,12 @@ +VERSION=unknown +APP_ENV=local +POSTGRES_HOST_AUTH_METHOD = changeit +POSTGRES_PASSWORD = mercury +POSTGRES_HOST = mercury_db +POSTGRES_PORT = 5432 +POSTGRES_USER = mercury +POSTGRES_DB = mercury +POSTGRES_HOST_AUTH_METHOD = trust +JWT_SECRET_KEY = "mysecretkey" +JWT_ALGORITHM = "HS256" +ACCESS_TOKEN_EXPIRE_MINUTES = 30 diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..022ca6a --- /dev/null +++ b/Dockerfile @@ -0,0 +1,23 @@ +ARG PYTHON_VERSION=3.9.16 + +FROM python:${PYTHON_VERSION} as api + +ENV WERKZEUNG_RUN_MAIN=true \ + PYTHONUNBUFFERED=1 \ + PYTHONIOENCODING=UTF-8 \ + LISTEN_ADDR="0.0.0.0" \ + LISTEN_PORT=8000 + +WORKDIR /app + +COPY ./requirements.txt /app/requirements.txt + +RUN find . -name '*.pyc' -type f -delete && \ + pip install --upgrade pip && \ + pip install --no-cache-dir -r requirements.txt + +COPY . /app/ + +EXPOSE 8000 + +CMD ["python", "src/app.py"] diff --git a/README.md b/README.md index c5ea2c1..1449543 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,31 @@ # ⚡️ Mercury ⚡️ -A FastApi template with PostgreSQL database. \ No newline at end of file +A FastApi template with PostgreSQL & Redis. + +## Prepare configurations + +```shell +cp .env.dist .env # replace your variables in the .env file you just created +``` + +## Run the containers + +```shell +docker-compose up --build --force-recreate +``` + +## Test the database + +```shell +$ docker exec -it mercury_db psql -U mercury mercury +psql (13.9 (Debian 13.9-1.pgdg110+1)) +Type "help" for help. +``` + +## Test the API + +You can print the Swagger doc here: http://localhost:8000 + +```shell +$ curl localhost:5002/v1/health +{"alive":true,"ip":"172.21.0.1","status":"ok"} +``` \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..cfee07d --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,56 @@ +version: '3.3' +services: + mercury_api: + restart: always + image: mercury_api:latest + networks: + - mercury_api + build: + context: . + dockerfile: ./Dockerfile + target: api + env_file: + - .env + ports: + - "8000:8000" + depends_on: + - mercury_cache + - mercury_db + - mercury_migrate + mercury_cache: + image: redis:6.2.6 + restart: always + container_name: mercury_cache + networks: + - mercury_api + mercury_migrate: + image: flyway/flyway:7.15.0 + container_name: mercury_migrate + command: -mixed=true -url="jdbc:postgresql://${POSTGRES_HOST}:${POSTGRES_PORT}/${POSTGRES_DB}" -user="${POSTGRES_USER}" -password="${POSTGRES_PASSWORD}" migrate + volumes: + - ./src/migrations:/flyway/sql + env_file: + - .env + depends_on: + - mercury_db + networks: + - mercury_api + mercury_db: + image: postgres:13 + container_name: mercury_db + ports: + - 5432:5432 + restart: always + volumes: + - mercurydb:/var/lib/postgresql/data + env_file: + - .env + networks: + - mercury_api + +volumes: + mercurydb: + +networks: + mercury_api: + driver: bridge diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..f776f28 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,11 @@ +fastapi[all] +requests +uvicorn[standard] +redis +SQLAlchemy +fastapi-utils +psycopg2 +bcrypt +python-jose[cryptography] +passlib[bcrypt] +python-multipart \ No newline at end of file diff --git a/src/api/user.py b/src/api/user.py new file mode 100644 index 0000000..2e528cb --- /dev/null +++ b/src/api/user.py @@ -0,0 +1,62 @@ +from datetime import datetime, timedelta +from typing import Annotated, Union +import models +import schemas +from sqlalchemy.orm import Session +from fastapi import Depends, HTTPException, status, APIRouter +from fastapi.security import OAuth2PasswordBearer, OAuth2PasswordRequestForm +from database import get_db +import os +from passlib.context import CryptContext +from jose import JWTError, jwt + +router = APIRouter() + +JWT_SECRET_KEY = os.getenv('JWT_SECRET_KEY') +JWT_ALGORITHM = os.getenv('JWT_ALGORITHM') +ACCESS_TOKEN_EXPIRE_MINUTES = os.getenv('ACCESS_TOKEN_EXPIRE_MINUTES') + +pwd_context = CryptContext(schemes=["sha256_crypt"], deprecated="auto") + +def verify_password(plain_password, hashed_password): + return pwd_context.verify(plain_password, hashed_password) + +def get_password_hash(password): + return pwd_context.hash(password) + +def create_access_token(data: dict, expires_delta: Union[timedelta, None] = None): + to_encode = data.copy() + if expires_delta: + expire = datetime.utcnow() + expires_delta + else: + expire = datetime.utcnow() + timedelta(minutes=15) + to_encode.update({"exp": expire}) + encoded_jwt = jwt.encode(to_encode, JWT_SECRET_KEY, algorithm=JWT_ALGORITHM) + return encoded_jwt + +def authenticate_user(payload, db): + user = db.query(models.User).filter(models.User.email == payload.email).first() + if not user: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Invalid credentials") + if not verify_password(payload.password, user.password): + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Incorrect password") + return user + +@router.post("/register", status_code=status.HTTP_201_CREATED) +def register_user(payload: schemas.UserSchema, db: Session = Depends(get_db)): + user = db.query(models.User).filter(models.User.email == payload.email).first() + if user: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Email already registered") + payload.password = get_password_hash(payload.password) + new_user = models.User(**payload.dict()) + db.add(new_user) + db.commit() + db.refresh(new_user) + return {"user": new_user} + +@router.post("/login", status_code=status.HTTP_200_OK) +def login_user(payload: schemas.UserLoginSchema, db: Session = Depends(get_db)): + user = authenticate_user(payload, db) + access_token_expires = timedelta(minutes=int(ACCESS_TOKEN_EXPIRE_MINUTES)) + access_token = create_access_token(data={"sub": user.email}, expires_delta=access_token_expires) + return {"user": user, "token": {"access_token": access_token, "token_type": "bearer"}} \ No newline at end of file diff --git a/src/app.py b/src/app.py new file mode 100644 index 0000000..fe4b02d --- /dev/null +++ b/src/app.py @@ -0,0 +1,5 @@ +import uvicorn +import os + +if __name__ == "__main__": + uvicorn.run("main:app", host=os.environ['LISTEN_ADDR'], port=int(os.environ['LISTEN_PORT']), reload=True) diff --git a/src/auth_guard.py b/src/auth_guard.py new file mode 100644 index 0000000..dd1593a --- /dev/null +++ b/src/auth_guard.py @@ -0,0 +1,63 @@ +from datetime import timedelta +from typing import Annotated +from api.user import authenticate_user, create_access_token, verify_password +import models +import schemas +from sqlalchemy.orm import Session +from fastapi import Depends, HTTPException, status +from database import get_db +import os +from jose import JWTError, jwt +from fastapi import Depends, HTTPException, status, APIRouter +from fastapi.security import OAuth2PasswordBearer, OAuth2PasswordRequestForm + +router = APIRouter() + +JWT_SECRET_KEY = os.getenv('JWT_SECRET_KEY') +JWT_ALGORITHM = os.getenv('JWT_ALGORITHM') +ACCESS_TOKEN_EXPIRE_MINUTES = os.getenv('ACCESS_TOKEN_EXPIRE_MINUTES') + +oauth2_scheme = OAuth2PasswordBearer(tokenUrl="v1/token") + +async def get_current_user(token: str = Depends(oauth2_scheme), db: Session = Depends(get_db)): + credentials_exception = HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Could not validate credentials", + headers={"WWW-Authenticate": "Bearer"}, + ) + try: + payload = jwt.decode(token, JWT_SECRET_KEY, algorithms=[JWT_ALGORITHM]) + email: str = payload.get("sub") + if email is None: + raise credentials_exception + token_data = schemas.TokenData(email=email) + except JWTError: + raise credentials_exception + user = db.query(models.User).filter(models.User.email == token_data.email).first() + if user is None: + raise credentials_exception + return user + +async def get_current_active_user(current_user: schemas.UserSchema = Depends(get_current_user)): + if current_user.disabled: + raise HTTPException(status_code=400, detail="Inactive user") + return current_user + +@router.post("/token", response_model=schemas.Token) +def login_for_access_token(form_data: Annotated[OAuth2PasswordRequestForm, Depends()], db: Session = Depends(get_db)): + user = db.query(models.User).filter(models.User.email == form_data.username).first() + if not user: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Invalid credentials") + if not verify_password(form_data.password, user.password): + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Incorrect password") + if not user: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Incorrect email or password", + headers={"WWW-Authenticate": "Bearer"}, + ) + access_token_expires = timedelta(minutes=int(ACCESS_TOKEN_EXPIRE_MINUTES)) + access_token = create_access_token( + data={"sub": user.email}, expires_delta=access_token_expires + ) + return {"access_token": access_token, "token_type": "bearer"} \ No newline at end of file diff --git a/src/database.py b/src/database.py new file mode 100644 index 0000000..e925200 --- /dev/null +++ b/src/database.py @@ -0,0 +1,26 @@ +from sqlalchemy import create_engine +from sqlalchemy.ext.declarative import declarative_base +from sqlalchemy.orm import sessionmaker +import os + +postgres_db_name = os.getenv('POSTGRES_DB') +postgres_user = os.getenv('POSTGRES_USER') +postgres_password = os.getenv('POSTGRES_PASSWORD') +postgres_port = os.getenv('POSTGRES_PORT') +postgres_host = os.getenv('POSTGRES_HOST') + +POSTGRES_URL = f"postgresql://{postgres_user}:{postgres_password}@{postgres_host}:{postgres_port}/{postgres_db_name}" + +dbEngine = create_engine(POSTGRES_URL, echo=True) +SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=dbEngine) + +Base = declarative_base() + + +def get_db(): + db = SessionLocal() + try: + yield db + finally: + db.close() + diff --git a/src/main.py b/src/main.py new file mode 100644 index 0000000..c1285a2 --- /dev/null +++ b/src/main.py @@ -0,0 +1,41 @@ +from api import user +import auth_guard +from fastapi import FastAPI +from fastapi.middleware.cors import CORSMiddleware +import os +import redis +from fastapi.openapi.utils import get_openapi +import models +from database import dbEngine + +def custom_openapi(): + if app.openapi_schema: + return app.openapi_schema + openapi_schema = get_openapi( + title="Mercury API Docs", + version=os.environ['VERSION'], + description="This is the Swagger documentation of the Mercury API", + routes=app.routes, + ) + app.openapi_schema = openapi_schema + return app.openapi_schema + +models.Base.metadata.create_all(bind=dbEngine) +redis_host = os.getenv('REDIS_HOST') +redis_client = redis.Redis(host=redis_host, port=6379) + +app = FastAPI(docs_url="/") +app.openapi = custom_openapi + +if os.getenv('APP_ENV') == 'local': + app.add_middleware( + CORSMiddleware, + allow_origins=["*"], + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], + ) + +# app.include_router(health.router, tags=['Information'], prefix='/v1') +app.include_router(auth_guard.router, tags=['Access Token'], prefix='/v1') +app.include_router(user.router, tags=['User'], prefix='/v1/user') \ No newline at end of file diff --git a/src/migrations/V1/V1.1__init_db.sql b/src/migrations/V1/V1.1__init_db.sql new file mode 100644 index 0000000..6f7ecd3 --- /dev/null +++ b/src/migrations/V1/V1.1__init_db.sql @@ -0,0 +1,6 @@ +CREATE EXTENSION IF NOT EXISTS "uuid-ossp"; +CREATE TABLE TEMPORARY( + TMP_ID SERIAL +); + +DROP TABLE TEMPORARY; diff --git a/src/migrations/V1/V1.2__create_user_table.sql b/src/migrations/V1/V1.2__create_user_table.sql new file mode 100644 index 0000000..c34ed25 --- /dev/null +++ b/src/migrations/V1/V1.2__create_user_table.sql @@ -0,0 +1,8 @@ +CREATE TABLE IF NOT EXISTS public.user ( + id uuid DEFAULT uuid_generate_v4() NOT NULL PRIMARY KEY, + username VARCHAR(200), + email VARCHAR(200), + password VARCHAR(200), + is_admin BOOLEAN, + disabled BOOLEAN +); \ No newline at end of file diff --git a/src/models.py b/src/models.py new file mode 100644 index 0000000..3e47b04 --- /dev/null +++ b/src/models.py @@ -0,0 +1,14 @@ +import uuid +from database import Base +from sqlalchemy import Column, Integer, String, Boolean, Numeric +from sqlalchemy.sql import func +from fastapi_utils.guid_type import GUID, GUID_SERVER_DEFAULT_POSTGRESQL + +class User(Base): + __tablename__ = 'user' + id = Column(GUID, primary_key=True, server_default=GUID_SERVER_DEFAULT_POSTGRESQL) + username = Column(String, nullable=False) + email = Column(String, nullable=False) + password = Column(String, nullable=False) + is_admin = Column(Boolean, nullable=True, default=False) + disabled = Column(Boolean, nullable=True, default=False) \ No newline at end of file diff --git a/src/schemas.py b/src/schemas.py new file mode 100644 index 0000000..f17d3d5 --- /dev/null +++ b/src/schemas.py @@ -0,0 +1,35 @@ +from datetime import datetime +from typing import List, Optional, Union +from pydantic import BaseModel + +class Token(BaseModel): + access_token: str + token_type: str + +class TokenData(BaseModel): + email: Union[str, None] = None + +class UserSchema(BaseModel): + username: str + email: str + password: str + is_admin: Optional[bool] = False + disabled: Optional[bool] = False + + class Config: + orm_mode = True + allow_population_by_field_name = True + arbitrary_types_allowed = True + +class UserLoginSchema(BaseModel): + email: str + password: str + + class Config: + orm_mode = True + allow_population_by_field_name = True + arbitrary_types_allowed = True + +class UserResponse(BaseModel): + status: str + user: UserSchema \ No newline at end of file