This commit is contained in:
2026-04-21 19:00:53 +05:45
commit ebb12e6ab7
20 changed files with 1391 additions and 0 deletions
View File
+26
View File
@@ -0,0 +1,26 @@
from pydantic import BaseModel, ConfigDict, ValidationError, EmailStr
import uuid
class UserResponse(BaseModel):
username : str
id : uuid.UUID
email : EmailStr
type : str
model_config = ConfigDict(from_attributes=True)
class CreateUserRequest(BaseModel):
username : str
email : EmailStr
type : str
password : str
model_config = ConfigDict(from_attributes=True)
class Token(BaseModel):
access_token : str
refresh_token : str
token_type : str
class TokenData(BaseModel):
username : str
scopes : list[str] = []
+185
View File
@@ -0,0 +1,185 @@
"""
Authentication according to the OAuth2 specification.
When user is created it's password is stored in form of hashed_password returned from function hash_password
Authentication flow
Login using username and password, -> returns jwt token if successful
When using other apis that needs authentication, get_current_user is called to get to validate and get the logged in user data
get_current_user -> takes in bearer token and retrives user using oauth2_scheme
"""
import jwt
from datetime import timedelta, datetime
from typing import Annotated
from fastapi import Depends, FastAPI, APIRouter, HTTPException, status
from fastapi.security import OAuth2PasswordBearer, OAuth2PasswordRequestForm, SecurityScopes
from pydantic import BaseModel, ConfigDict, ValidationError
from sqlalchemy import select
from pwdlib import PasswordHash
from ..models import Users
from .schemas import *
from ..db.db import db_dependency
from ..exceptions import ExceptionHandlerRoute
#TODO: env file
SECRET_KEY = "17267606688d2ef63beeb906b8dab8448cb8b1e3d147678ed6d69eb9eb147b26"
ALGORITHM = "HS256"
ACCESS_TOKEN_EXPIRE_MINUTES = 120
router = APIRouter(prefix = '/auth', tags = ['Auth'], route_class = ExceptionHandlerRoute)
oauth2_scheme = OAuth2PasswordBearer(tokenUrl='/auth/login',
scopes= {
"me": "Read information about the current user",
"Items": "Read items"
}
)
password_has = PasswordHash.recommended()
"""
Helper functions to encode and decode and retriver user
"""
def verify_password(plain_password, hashed_password):
return password_has.verify(plain_password, hashed_password)
def get_password_hash(password):
return password_has.hash(password)
def get_user(db, username: str):
query = select(Users).where((Users.username == username) | (Users.email == username))
user = db.execute(query).first()
if user:
return user[0]
return None
"""
Takes in the jwt token and returns the user value from database
Throws exceptions if username doesn't exist or password is incorrect
"""
def get_current_user(db: db_dependency, token : Annotated[str, Depends(oauth2_scheme)]) -> Users:
credentials_exceptions = HTTPException(
status_code = status.HTTP_401_UNAUTHORIZED,
detail = "Could not validate credientals",
headers = {"WWW-Authenticate": "Bearer"}
)
try:
#decode payload
payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])
username = payload.get("sub")
if username is None:
raise credentials_exceptions
token_data = TokenData(username = username)
except jwt.InvalidTokenError:
raise credentials_exceptions
#get user from userid
user = get_user(db, username = token_data.username)
if user is None:
raise credentials_exceptions
return user
@router.get('/users/me')
def get_curr_user(current_user : Annotated[Users, Depends(get_current_user)]):
return current_user
def create_access_token(data: dict, expires_delta: timedelta | None = None):
to_encode = data.copy()
if expires_delta:
expire = datetime.now() + expires_delta
else:
expire = datetime.now() + timedelta(minutes = 15)
to_encode.update({"exp": expire})
encoded_jwt = jwt.encode(payload = to_encode, key = SECRET_KEY, algorithm=ALGORITHM)
return encoded_jwt
def create_refresh_token(data: dict):
to_encode = data.copy()
expire = datetime.now() + timedelta(days = 7)
data.update({"exp": expire})
return jwt.encode(data, SECRET_KEY, algorithm=ALGORITHM)
@router.post("/refresh")
def refresh_token(refresh_token : str):
try:
payload = jwt.decode(refresh_token, SECRET_KEY, algorithms=[ALGORITHM])
except Exception as e:
raise HTTPException(
status_code = status.HTTP_401_UNAUTHORIZED,
detail = "Invlid or expired refresh token"
)
if not payload:
raise HTTPException(
status_code = status.HTTP_401_UNAUTHORIZED,
detail = "Invlid or expired refresh token"
)
username = payload.get("sub")
new_access_token = create_access_token({"sub": username})
new_refresh_token = create_refresh_token({"sub": username})
return {
"access_token" : new_access_token,
"refresh_token": new_refresh_token,
}
def authenticate_user(db, username: str, password: str):
#get user, hash password, verify password return true or false
user = get_user(db, username)
if not user:
return False
if not verify_password(password, user.hashed_password):
return False
return user
#authenticate user using autneticate_user and return token created from create_access_token
@router.post("/login")
def login(form_data: Annotated[OAuth2PasswordRequestForm, Depends()], db: db_dependency):
#get user from database
user = authenticate_user(db, form_data.username, form_data.password)
if not user:
raise HTTPException(
status_code = status.HTTP_401_UNAUTHORIZED,
detail = "Username or password is incorrect",
headers = {"WWW-Authenticate": "Bearer"}
)
#create access tokens
access_token_expires = timedelta(minutes = ACCESS_TOKEN_EXPIRE_MINUTES)
access_token = create_access_token(data = { "sub": user.username }, expires_delta= access_token_expires)
refresh_token = create_access_token(data = { "sub": user.username })
return Token(
access_token = access_token,
refresh_token = refresh_token,
token_type = "bearer"
)
user_dependency = Annotated[Users, Depends(get_current_user)]
+39
View File
@@ -0,0 +1,39 @@
import json
import uuid
from datetime import datetime, UTC
from starlette.responses import StreamingResponse
from .db.db import get_db
from .models import ApiLog
def write_log(req: Request, res: StreamingResponse, req_body : dict, res_body: str, process_time : float):
db = next(get_db())
try:
res_body = json.loads(res_body)
except Exception:
res_body = None
client_ip = req.client.host
if client_ip == 'testclient':
client_ip = '127.0.0.1'
log = ApiLog(
api_key = uuid.UUID(req.headers.get("x-api-key")) if req.headers.get("x-api-key") else None,
ip_address = client_ip,
path = req.url.path,
method = req.method,
status_code = res.status_code,
request_body = req_body,
response_body = res_body,
query_params = dict(req.query_params),
path_params = req.path_params,
process_time = process_time,
created_at = datetime.now(UTC)
)
db.add(log)
db.commit()
db.close()
View File
+27
View File
@@ -0,0 +1,27 @@
from pydantic_settings import BaseSettings, SettingsConfigDict
from pydantic import BaseModel, Field, ConfigDict
class FilterParams(BaseModel):
limit : int = Field(100, gt=0, le=100)
offset : int = Field(0, ge=0)
order_by : str = "created_at"
search : str | None = None
tags : list[str] = []
class ListResponseBase(BaseModel):
total : int
offset : int
limit : int
model_config = ConfigDict(from_attributes=True)
class Settings(BaseSettings):
model_config = SettingsConfigDict(env_file=".env")
DATABASE_URL : str
TEST_DATABASE_URL : str
FIRST_SUPERUSER : str
FIRST_SUPERUSER_PASSWORD : str
FIRST_SUPERUSER_EMAIL : str
settings = Settings()
+49
View File
@@ -0,0 +1,49 @@
from sqlalchemy import create_engine, event
from sqlalchemy.orm import sessionmaker, with_loader_criteria
from sqlalchemy.ext.declarative import declarative_base
from sqlalchemy.exc import SQLAlchemyError
from typing import Annotated
from fastapi import Depends
from sqlalchemy.orm import Session
from dotenv import load_dotenv
import os
load_dotenv()
engine = create_engine(os.getenv("DATABASE_URL", ""))
SessionLocal = sessionmaker(autoflush=False, bind=engine)
def safe_commit(db : Session):
try:
db.commit()
except SQLAlchemyError:
db.rollback()
raise
def get_db():
db = SessionLocal()
try:
yield db
except Exception:
db.rollback()
raise
finally:
db.close()
@event.listens_for(SessionLocal, "do_orm_execute")
def _add_filtering_criterial(execute_state):
skip_filter = execute_state.execution_options.get("skip_filter", False)
if execute_state.is_select and not skip_filter:
from ..models.models import AuditMixin
execute_state.statement = execute_state.statement.options(
with_loader_criteria(
AuditMixin,
lambda cls: cls.is_archived.is_(False),
include_aliases = True,
)
)
db_dependency = Annotated[Session, Depends(get_db)]
+122
View File
@@ -0,0 +1,122 @@
"""
This file defines and handles all the errors
The ExceptionHandlerRoute class is used by all the routes
"""
from enum import Enum
from typing import Any, Coroutine, Type
from fastapi import Request, Response
from fastapi.routing import APIRoute
from pydantic import BaseModel, ConfigDict
from collections.abc import Callable
from sqlalchemy.exc import NoResultFound, SQLAlchemyError
class ProblemType(str, Enum):
ITEM_NOT_FOUND = 'ITEM_NOT_FOUND'
INTERNAL_SERVER_ERROR = 'INTERNAL_SERVER_ERROR'
INVALID_CREDENTIALS = 'INVALID_CREDENTIALS'
UNIQUE_CONSTRAINT = 'UNIQUE_CONSTRAINT'
INVALID_REQUEST = 'INVALID_REQUEST'
BLANK = 'BLANK'
class ProblemDetail(BaseModel):
""" RFC 7807 Problem Details for HTTP APIs """
type : ProblemType = ProblemType.BLANK
status : int | None = None
title : str | None = None
detail : str | None = None
model_config = ConfigDict(from_attributes=True)
class ItemNotFoundException(Exception):
def __init__(self, model: Type, key : Any = ""):
self.model = model.__name__
self.key = str(key)
class ExceptionHandlerRoute(APIRoute):
def get_route_handler(self) -> Callable:
original_route_handler = super().get_route_handler()
async def custom_route_handler(request: Request) -> Response:
try:
return await original_route_handler(request)
except SQLAlchemyError as exc:
problem = ProblemDetail(status = 422)
if type(exc) == NoResultFound:
problem.type = ProblemType.ITEM_NOT_FOUND
problem.title = f'{exc.model} Not Found'
problem.detail = { 'key' : exc.key}
problem.status = 404
elif type(exc.orig) == UniqueViolation:
problem.detail = str(exc.orig).split('\n')[1].split(': ')[1]
problem.type = ProblemType.UNIQUE_VALIDATION
problem.title = 'Unique violation'
elif type(exc.orig) == ForeignKeyViolation:
problem.type = ProblemType.FOREIGN_KEY_VIOLATION
problem.title = 'Foreign Key Violation'
problem.detail = str(exc.orig).split('\n')[1].split(': ')[1]
return JSONResponse(
status_code = problem.status,
content = problem.model_dump(exclude_none = True),
headers = {'Content-Type': 'application/problem+json'}
)
raise HTTPException(status_code = 422, detail = detail)
except Exception as exc:
problem = ProblemDetail(status = 500)
problem.title = 'Internal Server Error please report the bug'
problem.type = ProblemType.INTERNAL_SERVER_ERROR
problem.detail = str(exc)
if type(exc) == ItemNotFoundException:
problem.type = ProblemType.ITEM_NOT_FOUND
problem.title = f'{exc.model} Not Found'
problem.detail = { 'key' : exc.key}
problem.status = 404
if type(exc) == RequestValidationError:
problem.type = ProblemType.INVALID_REQUEST
problem.title = "Input validation error"
problem.detail = exc.errors()
problem.status = 422
if type(exc) == HTTPException:
print(exc.status_code)
problem.title = "Credientials error"
problem.detail = exc.detail
problem.status = exc.status_code
if problem.status == 401:
problem.type = ProblemType.INVALID_CREDENTIALS
return JSONResponse(
status_code = problem.status,
content = problem.model_dump(exclude_none = True),
headers = {'Content-Type': 'application/problem+json'}
)
return custom_route_handler
+174
View File
@@ -0,0 +1,174 @@
from contextlib import asynccontextmanager
from fastapi import FastAPI
from .exceptions import ExceptionHandlerRoute
from fastapi.middleware.cors import CORSMiddleware
from sqlalchemy.orm import Session
from fastapi.staticfiles import StaticFiles
from .models import Users
from .db.db import engine
from .db.config import settings
from .middlewares import app_middleware
from .auth.views import get_password_hash
from .routes import routers, docs
@asynccontextmanager
async def init_db(app: FastAPI):
with Session(engine) as session:
from sqlalchemy import select, insert
user = session.execute(select(Users).where(Users.username == settings.FIRST_SUPERUSER)).first()
if not user:
session.execute(insert(Users).values(
username = settings.FIRST_SUPERUSER,
hashed_password = get_password_hash(settings.FIRST_SUPERUSER_PASSWORD),
full_name = "admin",
type = 'ADMIN',
email = settings.FIRST_SUPERUSER_EMAIL
))
session.commit()
yield
description = """
# Description
This part of the documentation includes overview of the backend, backend internals and common attributes shared by all the apis.
The backend is written using [FastAPI](https://fastapi.tiangolo.com/), [SQLAlchemy](https://www.sqlalchemy.org/) and [Postgresql](https://www.postgresql.org/).
## Documentation
Redoc with openapi will serve as primary documentation, which will be available publicly in development and staging environment and not for production evnrionment for security reasons.
## Authorization
Authorization is implemended using OAuth2 using password and username, logging with external providers like google, github etc. is not supported.
Each type of user i.e. doctor, patient, staff is required to create their own username and password while registering, so that will be able to login to the system.
The auth token expiry duration is 120 minutes currently
## Common columns
All tables in the backend shares some common columns, these columns will be described here and not in the respective API as it has the same meaning everywhere.
1. __created_at, updated_at__ : represents the datetime when the row was added, updated respectively
2. __create_by_id, updated_by_id__ : foreign key to user's id, it's extracted from the logged in user who created or updated the row.
3. __is_archived__ : It represents if the row is deleted, allows us to implement soft delete.
## Audit
Each table on the backend has it's respective audit table with suffix ___audit__. i.e __appointment__ table has __appointment_audit__.
It stores all the changes done in each row including which operation it was, which columns were changed and what's it's old and new values.
APIs will be created to access the audit for each table.
It's implemented using sqlalchemy_declarative_extention library
## Internals
### Primary, Foreign Key
All primary keys are [UUID](https://en.wikipedia.org/wiki/Universally_unique_identifier), which makes it possible to avoid alot of security risks and implement dynamic features.
Due to this all the foreign keys are also UUIDs
UUIDs can be generated in postgres or in python easy and fast
## Errors
Errors are implemented using [RFC9457](https://www.rfc-editor.org/rfc/rfc9457.html) with some slight modification. Here is a sample error response.
```json
{
"type": "UNIQUE_VALIDATION",
"status": 422,
"detail": "Key (username)=(banana) already exists.",
"title": "Unique violation"
}
```
The type will always be a standard enum value. the status will always be the http status code.
The type, status, title and detail will __always__ exist,
For now in case of pydantic validation error, the raw pydantic message will be set in details
In case of internal server error the type will be 'INTERNAL_SERVER_ERROR' and it's detail will contain the error message.
In this way all the errors will be well documented and handled in a central module, It will also make writing automated tests easier.
# Testing
This section covers the steps for testing and guidelines for reporting and verifying bugs for backend api.
In the future the manual for QA for frontend will also be included here.
## Automated testing
Automated testing is done using fastapi's testclient, pytest and faker
This test creates new database on startup, runs the tests and deletes everything, so the errors i can be reproduceable and deterministic.
The test interacts to the backend only thru API
### Prerequisites
1. Python
2. Postgresql server with a clean database (You can avoid it by using test database on the server)
### Steps
1. Clone the [repository](https://git.aayutech.dev/fourleaf/backend)
2. Create .env file using .env.example as reference
3. Create virtual environment and install packages from requirements.txt
4. Run tests using pytest (lookup pytest's documentation for more information)
5. Create a test inside the tests folder
## Manual testing
Manual testing is done using postman or similar api client.
Url for testing in dev environment is `https://api-dev.aayutech.dev`
### Steps
1. Download postmand collection from the git server (optional)
2. Create and run requests from postman
## Reporting bug
All the bug reporting will be done using kanban `https://project.aayutech.dev`
### Bug criteria
1. All __INTERNAL_SERVER_ERROR__
2. All about:none errors
3. All errors that consists of raw sql messages
4. Errors that doesn't match the description of the api
5. Bug should be able to be reproduced
### Bug reporting steps
1. Create new task in the `Bug Report` column in kanboard
2. Write short description about the bug and how it can be reproduced
3. Optionally take screenshot of postman or terminal where the bug can be seen
4. Add related user as assigne
### Bug resolve steps
1. After a bug is reported a QA or Developer will approve the bug and put it in the `BUG` column.
2. After the bug is verified, the respective developer will fix the bug and put it in the `Read for QA` column.
3. A QA will reverify the bug and put it in the `DONE` column.
"""
app = FastAPI(
version = "0.0.1",
lifespan = init_db,
title = "Fastapi template",
openapi_tags = docs,
description = description
)
for router in routers: app.include_router(router)
app.middleware('http')(app_middleware)
app.add_middleware(
CORSMiddleware
,allow_origins=["*"]
,allow_credentials=True # Set to True if you need to support cookies/authorization headers
,allow_methods=["*"] # Allows all methods (GET, POST, PUT, DELETE, OPTIONS, etc.)
,allow_headers=["*"] # Allows all headers
)
+36
View File
@@ -0,0 +1,36 @@
from datetime import datetime
import time
from collections.abc import Callable
from typing import Annotated
from fastapi import Body, FastAPI, HTTPException, Response, Request
from fastapi.exceptions import RequestValidationError as exc
from fastapi.routing import APIRoute
from sqlalchemy.exc import SQLAlchemyError, IntegrityError
from psycopg.errors import UniqueViolation
from starlette.concurrency import iterate_in_threadpool
from starlette.background import BackgroundTask
from .background import write_log
async def app_middleware(request: Request, call_next):
try:
req_body = await request.json()
except Exception:
req_body = None
start_time = time.perf_counter()
response = await call_next(request)
process_time = time.perf_counter() - start_time
if request.url.path.split('/')[1] != 'static':
res_body = [section async for section in response.body_iterator]
response.body_iterator = iterate_in_threadpool(iter(res_body))
if res_body != None and len(res_body) > 0:
res_body = res_body[0].decode()
else:
res_body = True
#add the background task to the background response object to queue the job
response.background = BackgroundTask(write_log, request, response, req_body, res_body, process_time)
return response
+93
View File
@@ -0,0 +1,93 @@
from datetime import datetime
from typing import NewType
from sqlalchemy import MetaData, Uuid, Index
from sqlalchemy_declarative_extensions import declarative_database
from sqlalchemy_declarative_extensions.audit import audit
from sqlalchemy.orm import declarative_base, declared_attr, Mapped, mapped_column
from sqlalchemy.dialects.postgresql import INET, JSONB, ARRAY
import uuid
nameing_metadata = MetaData(naming_convention= {
"ix": "ix_%(column_0_label)s",
"uq": "uq_%(table_name)s_%(column_0_name)s",
"ck": "ck_%(table_name)s_%(constraint_name)s",
"fk": "fk_%(table_name)s_%(column_0_name)s_%(referred_table_name)s",
"pk": "pk_%(table_name)s",
})
nstr30 = NewType("nstr30", str)
nstr50 = NewType("nstr50", str)
nstr100 = NewType("nstr100", str)
nstr255 = NewType("nstr255", str)
tz_date = NewType("tz_date", datetime)
Base = declarative_database(declarative_base(metadata=nameing_metadata))
class ApiLog(Base):
__tablename__ = 'api_log'
id : Mapped[uuid.UUID] = mapped_column(primary_key=True, default = uuid.uuid7)
api_key : Mapped[uuid.UUID | None]
ip_address : Mapped[str] = mapped_column(INET)
path : Mapped[str]
method : Mapped[str]
status_code : Mapped[int]
request_body : Mapped[dict | None] = mapped_column(JSONB)
response_body : Mapped[dict | None] = mapped_column(JSONB)
query_params : Mapped[dict | None] = mapped_column(JSONB)
path_params : Mapped[dict | None] = mapped_column(JSONB)
process_time : Mapped[float]
created_at : Mapped[datetime]
class AuditMixin:
@declared_attr
def created_at(cls) -> Mapped[tz_date]:
return mapped_column()
@declared_attr
def updated_at(cls) -> Mapped[tz_date]:
return mapped_column()
@declared_attr
def created_by(cls) -> Mapped[Users | None]:
return mapped_column(foreign_key=[cls.created_by_id], remote_side=[Users.id])
@declared_attr
def updated_by(cls) -> Mapped[Users | None]:
return mapped_column(foreign_key=[cls.updated_by_id], remote_side=[Users.id])
@declared_attr
def updated_by_id(cls) -> Mapped[Uuid]:
return mapped_column()
@declared_attr
def created_by_id(cls) -> Mapped[Uuid]:
return mapped_column()
@audit()
class Users(Base):
__tablename__ = "users"
id : Mapped[Uuid] = mapped_column(Uuid, primary_key=True, default=uuid.uuid4)
username : Mapped[nstr50] = mapped_column()
full_name : Mapped[nstr100]
email : Mapped[nstr255] = mapped_column()
type : Mapped[nstr50] = mapped_column(default="staff")
is_archived : Mapped[bool] = mapped_column(default=False)
hashed_password: Mapped[nstr255]
__table_args__ = (
Index("ix_unique_active_email", email, unique=True, postgresql_where="is_archived = false"),
Index("ix_unique_active_username", username, unique=True, postgresql_where="is_archived = false"),
)
+21
View File
@@ -0,0 +1,21 @@
import importlib
from .exceptions import ExceptionHandlerRoute
module_path = [
'.auth.views'
]
routers = []
docs = []
for path in module_path:
module = importlib.import_module(path, package = 'app')
if hasattr(module, 'router'):
assert(type(module.router.route_class == ExceptionHandlerRoute))
routers.append(module.router)
if hasattr(module, 'docs'):
tag = module.router.tags[0]
docs.append({"name" : tag, "description" : module.docs})