Skip to content

Commit

Permalink
🎉 Setup: project setup
Browse files Browse the repository at this point in the history
  • Loading branch information
ayoub3bidi committed Oct 4, 2023
1 parent 300398b commit 951d251
Show file tree
Hide file tree
Showing 15 changed files with 393 additions and 1 deletion.
1 change: 1 addition & 0 deletions .dockerignore
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
.git
12 changes: 12 additions & 0 deletions .env.dist
Original file line number Diff line number Diff line change
@@ -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
23 changes: 23 additions & 0 deletions Dockerfile
Original file line number Diff line number Diff line change
@@ -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"]
31 changes: 30 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
@@ -1,2 +1,31 @@
# ⚡️ Mercury ⚡️
A FastApi template with PostgreSQL database.
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"}
```
56 changes: 56 additions & 0 deletions docker-compose.yml
Original file line number Diff line number Diff line change
@@ -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
11 changes: 11 additions & 0 deletions requirements.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
fastapi[all]
requests
uvicorn[standard]
redis
SQLAlchemy
fastapi-utils
psycopg2
bcrypt
python-jose[cryptography]
passlib[bcrypt]
python-multipart
62 changes: 62 additions & 0 deletions src/api/user.py
Original file line number Diff line number Diff line change
@@ -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"}}
5 changes: 5 additions & 0 deletions src/app.py
Original file line number Diff line number Diff line change
@@ -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)
63 changes: 63 additions & 0 deletions src/auth_guard.py
Original file line number Diff line number Diff line change
@@ -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"}
26 changes: 26 additions & 0 deletions src/database.py
Original file line number Diff line number Diff line change
@@ -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()

41 changes: 41 additions & 0 deletions src/main.py
Original file line number Diff line number Diff line change
@@ -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')
6 changes: 6 additions & 0 deletions src/migrations/V1/V1.1__init_db.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
CREATE EXTENSION IF NOT EXISTS "uuid-ossp";
CREATE TABLE TEMPORARY(
TMP_ID SERIAL
);

DROP TABLE TEMPORARY;
8 changes: 8 additions & 0 deletions src/migrations/V1/V1.2__create_user_table.sql
Original file line number Diff line number Diff line change
@@ -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
);
14 changes: 14 additions & 0 deletions src/models.py
Original file line number Diff line number Diff line change
@@ -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)
Loading

0 comments on commit 951d251

Please sign in to comment.