Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions Backend/app/core/config.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,12 @@
"""Configuration objects and helpers for the FastAPI application."""

import os

from pydantic_settings import BaseSettings, SettingsConfigDict


class Settings(BaseSettings):
"""Application settings loaded from environment variables or ``.env`` files."""
# --- App Settings ---
APP_NAME: str = "Inquiro API"
ENVIRONMENT: str = "dev"
Expand Down
17 changes: 10 additions & 7 deletions Backend/app/core/database.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,10 @@
"""Database configuration and session utilities for the API."""

import logging
from importlib import import_module

from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker, declarative_base
from sqlalchemy.orm import declarative_base, sessionmaker

from app.core.config import settings

Expand All @@ -14,20 +17,20 @@
echo=(settings.ENVIRONMENT == "dev"),
)

SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
SESSION_LOCAL = sessionmaker(autocommit=False, autoflush=False, bind=engine)


def init_db():
"""Automatically create or update tables based on models."""
import app.models # noqa: F401 (ensures models are imported)
def init_db() -> None:
"""Automatically create or update tables based on SQLAlchemy models."""
import_module("app.models.user")
logger.info("🔄 Creating / updating database schema...")
Base.metadata.create_all(bind=engine)
logger.info("✅ Database schema up to date.")


def get_db():
"""Provide a database session for FastAPI routes."""
db = SessionLocal()
"""Yield a database session for FastAPI routes."""
db = SESSION_LOCAL()
try:
yield db
finally:
Expand Down
44 changes: 31 additions & 13 deletions Backend/app/core/security.py
Original file line number Diff line number Diff line change
@@ -1,38 +1,53 @@
import secrets
"""Security utilities for creating and validating JWT tokens."""

from datetime import datetime, timedelta, timezone
from typing import Optional
from typing import Any, Dict, Optional

from fastapi import Depends, HTTPException, status
from fastapi.security import OAuth2PasswordBearer
from jose import jwt, JWTError
from jose import JWTError, jwt

from app.core.config import settings

ALGORITHM = "HS256"

oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/auth/login")

def create_access_token(data: dict, expires_delta: Optional[timedelta] = None) -> str:

def create_access_token(data: Dict[str, Any], expires_delta: Optional[timedelta] = None) -> str:
"""Create a signed JWT access token."""

to_encode = data.copy()
expire = datetime.now(timezone.utc) + (expires_delta or timedelta(minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES))
expire = datetime.now(timezone.utc) + (
expires_delta or timedelta(minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES)
)
to_encode.update({"exp": expire})
return jwt.encode(to_encode, settings.JWT_SECRET_KEY, algorithm=ALGORITHM)


def create_refresh_token(data: dict, expires_delta: Optional[timedelta] = None) -> str:
def create_refresh_token(data: Dict[str, Any], expires_delta: Optional[timedelta] = None) -> str:
"""Create a signed JWT refresh token."""

to_encode = data.copy()
expire = datetime.now(timezone.utc) + (expires_delta or timedelta(days=settings.REFRESH_TOKEN_EXPIRE_DAYS))
expire = datetime.now(timezone.utc) + (
expires_delta or timedelta(days=settings.REFRESH_TOKEN_EXPIRE_DAYS)
)
to_encode.update({"exp": expire, "type": "refresh"})
return jwt.encode(to_encode, settings.JWT_SECRET_KEY, algorithm=ALGORITHM)


def verify_token(token: str) -> Optional[dict]:
def verify_token(token: str) -> Optional[Dict[str, Any]]:
"""Decode a JWT token and return its payload, if valid."""

try:
payload = jwt.decode(token, settings.JWT_SECRET_KEY, algorithms=[ALGORITHM])
return payload
return jwt.decode(token, settings.JWT_SECRET_KEY, algorithms=[ALGORITHM])
except JWTError:
return None

def get_current_user(token: str = Depends(oauth2_scheme)):

def get_current_user(token: str = Depends(oauth2_scheme)) -> str:
"""Return the username encoded in the access token."""

payload = verify_token(token)
if not payload:
raise HTTPException(
Expand All @@ -43,6 +58,9 @@ def get_current_user(token: str = Depends(oauth2_scheme)):

username = payload.get("sub")
if not username:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid token payload.")
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid token payload.",
)

return username
return username
12 changes: 8 additions & 4 deletions Backend/app/main.py
Original file line number Diff line number Diff line change
@@ -1,11 +1,13 @@
"""Application entry point for the Inquiro FastAPI service."""

import logging
from contextlib import asynccontextmanager

from fastapi import FastAPI

from app.core.config import settings
from app.core.database import init_db
from app.routes import user_routes, auth_routes
from app.routes import auth_routes, user_routes

# ---------------------------------------------------------
# Configure Logging
Expand All @@ -21,8 +23,10 @@
# Lifespan Event Handlers (Modern FastAPI)
# ---------------------------------------------------------
@asynccontextmanager
async def lifespan(app: FastAPI):
logger.info(f"🚀 Starting Inquiro API in '{settings.ENVIRONMENT}' mode...")
async def lifespan(_app: FastAPI):
"""Initialize and tear down application resources."""

logger.info("🚀 Starting Inquiro API in '%s' mode...", settings.ENVIRONMENT)
if settings.ENVIRONMENT == "dev":
init_db() # Auto-create tables only in dev
logger.info("✅ Startup complete.")
Expand All @@ -40,7 +44,7 @@ async def lifespan(app: FastAPI):
title="Inquiro API",
description="AI-powered research discovery backend for Inquiro.",
version="0.1.0",
lifespan=lifespan
lifespan=lifespan,
)


Expand Down
10 changes: 9 additions & 1 deletion Backend/app/models/user.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,16 @@
from sqlalchemy import String, Integer
"""SQLAlchemy models used by the application."""

# pylint: disable=too-few-public-methods

from sqlalchemy import Integer, String
from sqlalchemy.orm import Mapped, mapped_column

from app.core.database import Base


class User(Base):
"""Database representation of an application user."""

__tablename__ = "users"

id: Mapped[int] = mapped_column(Integer, primary_key=True, index=True)
Expand Down
6 changes: 6 additions & 0 deletions Backend/app/routes/auth_routes.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
"""Routes related to authentication and JWT management."""

from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.orm import Session

Expand All @@ -22,6 +24,8 @@
summary="Authenticate a user and return JWT tokens",
)
def login(request: LoginRequest, db: Session = Depends(get_db)) -> LoginResponse:
"""Authenticate a user and return access plus refresh tokens."""

user = db.query(User).filter(User.username == request.username).first()
if not user:
raise HTTPException(
Expand Down Expand Up @@ -49,6 +53,8 @@ def login(request: LoginRequest, db: Session = Depends(get_db)) -> LoginResponse
summary="Generate a new access token using a refresh token",
)
def refresh_access_token(request: RefreshRequest) -> RefreshResponse:
"""Validate a refresh token and return a new access token."""

payload = verify_token(request.refresh_token)
if not payload or payload.get("type") != "refresh":
raise HTTPException(
Expand Down
22 changes: 17 additions & 5 deletions Backend/app/routes/user_routes.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
"""Routes for managing and retrieving user information."""

from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel
from sqlalchemy.orm import Session
Expand All @@ -12,26 +14,36 @@


class UserCreate(BaseModel):
"""Payload used when creating a new user."""

username: str


@router.post("/", response_model=UserResponse, status_code=201)
def create_user(request: UserCreate, db: Session = Depends(get_db)):
@router.post("/", response_model=UserResponse, status_code=status.HTTP_201_CREATED)
def create_user(request: UserCreate, db: Session = Depends(get_db)) -> User:
"""Create a new user record if the username is available."""

if db.query(User).filter(User.username == request.username).first():
raise HTTPException(status_code=400, detail="Username already exists")
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Username already exists",
)
user = User(username=request.username)
db.add(user)
db.commit()
db.refresh(user)
return user


@router.get("/me", response_model=UserResponse, status_code=status.HTTP_200_OK)
def get_current_user_profile(
current_username: str = Depends(get_current_user),
db: Session = Depends(get_db),
):
) -> User:
"""Return the user information for the authenticated principal."""

user = db.query(User).filter(User.username == current_username).first()
if not user:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="User not found.")

return user
return user
14 changes: 13 additions & 1 deletion Backend/app/schemas/auth_dto.py
Original file line number Diff line number Diff line change
@@ -1,21 +1,33 @@
"""Pydantic models for authentication requests and responses."""

from typing import Any, Dict

from pydantic import BaseModel


class LoginRequest(BaseModel):
"""Payload for authenticating a user."""

username: str


class LoginResponse(BaseModel):
"""Response returned after a successful login."""

access_token: str
refresh_token: str
token_type: str
user: dict
user: Dict[str, Any]


class RefreshRequest(BaseModel):
"""Payload containing the refresh token."""

refresh_token: str


class RefreshResponse(BaseModel):
"""Response containing a fresh access token."""

access_token: str
token_type: str
11 changes: 9 additions & 2 deletions Backend/app/schemas/user_dto.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,15 @@
"""Pydantic models for user-facing responses."""

from pydantic import BaseModel


class UserResponse(BaseModel):
"""Representation of a user returned by the API."""

id: int
username: str

class Config:
from_attributes = True # For Pydantic v2
class Config: # pylint: disable=too-few-public-methods
"""Pydantic configuration options."""

from_attributes = True # For Pydantic v2