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
12 changes: 9 additions & 3 deletions .env.example
Original file line number Diff line number Diff line change
@@ -1,3 +1,9 @@
APP_ENV=development
DATABASE_URL=postgresql://user:pass@localhost:5432/db
SECRET_KEY=yoursecretkey
# Database settings
POSTGRES_USER=postgres
POSTGRES_PASSWORD=postgres
POSTGRES_HOST=db
POSTGRES_PORT=5432
POSTGRES_DB=enterprise_db

# JWT settings (change SECRET_KEY in production!)
SECRET_KEY=supersecretkeychangethisinproduction2026
20 changes: 7 additions & 13 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,17 +1,11 @@
# Python
__pycache__/
*.py[cod]
*.class
.env
venv/

# Terraform
.venv
env/
dist/
*.log
.terraform/
*.tfstate
*.tfstate.backup
*.tfvars
.terraform.lock.hcl

# Security
*.pem
*.key
doctl
terraform.tfstate*
venv/
15 changes: 15 additions & 0 deletions .pre-commit-config.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
repos:
- repo: https://github.com/pre-commit/pre-commit-hooks
rev: v4.4.0
hooks:
- id: trailing-whitespace
- id: end-of-file-fixer
- repo: https://github.com/psf/black
rev: 23.3.0
hooks:
- id: black
- repo: https://github.com/charliermarsh/ruff-pre-commit
rev: v0.0.263
hooks:
- id: ruff
args: [--fix]
20 changes: 19 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
@@ -1,8 +1,25 @@
<<<<<<< HEAD
# Enterprise Core API
![CI/CD](https://github.com/Daniel815266/enterprise-core-api/actions/workflows/main.yml/badge.svg)

Production-grade FastAPI backend architecture.
=======
![Build Status](https://github.com/Daniel815266/enterprise-core-api/actions/workflows/main.yml/badge.svg)
# Enterprise Core API & Infrastructure
>>>>>>> main

This repository demonstrates a production-grade backend architecture coupled with automated cloud infrastructure. It is designed for scalability, security, and developer productivity.
## 🛠 Tech Stack
- **Framework:** FastAPI (Python 3.11+)
- **ORM:** SQLAlchemy + Alembic
- **Infra:** Terraform + Docker
- **Security:** JWT + OAuth2 + Bandit Scanning

<<<<<<< HEAD
## 🚀 Setup
```bash
docker-compose up --build
```
=======
## System Architecture
* **Backend:** FastAPI (Async Python 3.11+)
* **Database:** PostgreSQL with SQLAlchemy ORM
Expand All @@ -21,3 +38,4 @@ This repository demonstrates a production-grade backend architecture coupled wit

---
*Professional-grade engineering for long-term scalability.*
>>>>>>> main
52 changes: 19 additions & 33 deletions alembic/env.py
Original file line number Diff line number Diff line change
@@ -1,44 +1,38 @@
import os
import sys
from logging.config import fileConfig

from sqlalchemy import engine_from_config
from sqlalchemy import pool

from alembic import context

# this is the Alembic Config object, which provides
# access to the values within the .ini file in use.
# IMPORTANT: Add the project root to Python path
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))

# Alembic Config object
config = context.config

# Interpret the config file for Python logging.
# This line sets up loggers basically.
# Setup logging
if config.config_file_name is not None:
fileConfig(config.config_file_name)

# add your model's MetaData object here
# for 'autogenerate' support
# from myapp import mymodel
# target_metadata = mymodel.Base.metadata
from src.core.config import PROJECT_NAME
target_metadata = None

# other values from the config, defined by the needs of env.py,
# can be acquired:
# my_important_option = config.get_main_option("my_important_option")
# ... etc.
# Import settings and models
from src.core.config import get_settings # noqa: E402
from src.core.database import Base # noqa: E402

# Target metadata for autogenerate
target_metadata = Base.metadata

def run_migrations_offline() -> None:
"""Run migrations in 'offline' mode.
# Get settings and build DB URL
settings = get_settings()
database_url = f"postgresql://{settings.POSTGRES_USER}:{settings.POSTGRES_PASSWORD}@{settings.POSTGRES_HOST}:{settings.POSTGRES_PORT}/{settings.POSTGRES_DB}"

This configures the context with just a URL
and not an Engine, though an Engine is acceptable
here as well. By skipping the Engine creation
we don't even need a DBAPI to be available.
# Override sqlalchemy.url
config.set_main_option("sqlalchemy.url", database_url)

Calls to context.execute() here emit the given string to the
script output.

"""
def run_migrations_offline() -> None:
url = config.get_main_option("sqlalchemy.url")
context.configure(
url=url,
Expand All @@ -52,22 +46,14 @@ def run_migrations_offline() -> None:


def run_migrations_online() -> None:
"""Run migrations in 'online' mode.

In this scenario we need to create an Engine
and associate a connection with the context.

"""
connectable = engine_from_config(
config.get_section(config.config_ini_section, {}),
prefix="sqlalchemy.",
poolclass=pool.NullPool,
)

with connectable.connect() as connection:
context.configure(
connection=connection, target_metadata=target_metadata
)
context.configure(connection=connection, target_metadata=target_metadata)

with context.begin_transaction():
context.run_migrations()
Expand Down
26 changes: 26 additions & 0 deletions docker-compose.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@

services:
db:
image: postgres:15
container_name: enterprise_postgres
environment:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: password
POSTGRES_DB: enterprise_db
ports:
- "5432:5432"
healthcheck:
test: ["CMD-SHELL", "pg_isready -U postgres -d enterprise_db"]
interval: 5s
timeout: 5s
retries: 5

app:
build: .
volumes:
- .:/app
environment:
- DATABASE_URL=postgresql://postgres:password@db:5432/enterprise_db
depends_on:
db:
condition: service_healthy
17 changes: 17 additions & 0 deletions requirements.txt
Original file line number Diff line number Diff line change
Expand Up @@ -5,3 +5,20 @@ psycopg2-binary==2.9.9
pytest==8.0.0
alembic==1.13.1
httpx==0.27.0

# Authentication
python-jose[cryptography]
passlib[bcrypt]
python-multipart

# Authentication dependencies
python-jose[cryptography]
passlib[bcrypt]
python-multipart

# Settings & Auth
pydantic-settings
python-jose[cryptography]
passlib[bcrypt]
python-multipart
httpx
Empty file added src/__init__.py
Empty file.
Empty file added src/api/__init__.py
Empty file.
Empty file added src/api/v1/auth/__init__.py
Empty file.
58 changes: 58 additions & 0 deletions src/api/v1/auth/router.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
from fastapi import APIRouter, Depends, HTTPException, status
from fastapi.security import OAuth2PasswordRequestForm
from sqlalchemy.orm import Session

from src.core.database import get_db
from src.core.security import (
verify_password,
get_password_hash,
create_access_token,
create_refresh_token,
)
from src.models.user import User
from src.schemas.user import UserCreate, UserRead, Token

router = APIRouter(prefix="/auth", tags=["auth"])


@router.post("/register", response_model=UserRead, status_code=status.HTTP_201_CREATED)
def register(user_in: UserCreate, db: Session = Depends(get_db)):
if db.query(User).filter(User.email == user_in.email).first():
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST, detail="Email already registered"
)

user = User(
email=user_in.email,
hashed_password=get_password_hash(user_in.password),
full_name=user_in.full_name,
)
db.add(user)
db.commit()
db.refresh(user)
return user


@router.post("/login", response_model=Token)
def login(
form_data: OAuth2PasswordRequestForm = Depends(), db: Session = Depends(get_db)
):
user = db.query(User).filter(User.email == form_data.username).first()
if not user or not verify_password(form_data.password, user.hashed_password):
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Incorrect email or password",
headers={"WWW-Authenticate": "Bearer"},
)

if not user.is_active:
raise HTTPException(status_code=400, detail="Inactive user")

access_token = create_access_token(subject=user.email)
refresh_token = create_refresh_token(subject=user.email)

return {
"access_token": access_token,
"refresh_token": refresh_token,
"token_type": "bearer",
}
40 changes: 37 additions & 3 deletions src/core/config.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,37 @@
PROJECT_NAME = "Enterprise Core API"
VERSION = "1.0.0"
DESCRIPTION = "Production-grade API with automated infrastructure."
import os
from pydantic_settings import BaseSettings
from functools import lru_cache


class Settings(BaseSettings):
PROJECT_NAME: str = "Enterprise Core API"

# Database
POSTGRES_USER: str = "postgres"
POSTGRES_PASSWORD: str = "postgres"
POSTGRES_HOST: str = "db" # Default for Docker
POSTGRES_PORT: str = "5432"
POSTGRES_DB: str = "enterprise_db"

# JWT Settings
SECRET_KEY: str = "supersecretkeychangethisinproduction2026"
ALGORITHM: str = "HS256"
ACCESS_TOKEN_EXPIRE_MINUTES: int = 30
REFRESH_TOKEN_EXPIRE_DAYS: int = 7

class Config:
env_file = os.path.join(
os.path.dirname(os.path.dirname(os.path.abspath(__file__))), ".env"
)
env_file_encoding = "utf-8"
case_sensitive = True
extra = "ignore"


@lru_cache()
def get_settings() -> Settings:
# Allow overriding host for local migrations (set to localhost)
settings = Settings()
if os.getenv("ALEMBIC_HOST_OVERRIDE") == "localhost":
settings.POSTGRES_HOST = "localhost"
return settings
22 changes: 22 additions & 0 deletions src/core/database.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
from sqlalchemy import create_engine
from sqlalchemy.ext.declarative import declarative_base
from sqlalchemy.orm import sessionmaker, Session
from src.core.config import get_settings
from typing import Generator

settings = get_settings()

DATABASE_URL = f"postgresql://{settings.POSTGRES_USER}:{settings.POSTGRES_PASSWORD}@{settings.POSTGRES_HOST}:{settings.POSTGRES_PORT}/{settings.POSTGRES_DB}"

engine = create_engine(DATABASE_URL, pool_pre_ping=True)
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)

Base = declarative_base()


def get_db() -> Generator[Session, None, None]:
db = SessionLocal()
try:
yield db
finally:
db.close()
43 changes: 43 additions & 0 deletions src/core/security/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
from datetime import datetime, timedelta
from typing import Any
from jose import JWTError, jwt
from passlib.context import CryptContext
from src.core.config import get_settings

settings = get_settings()

pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")


def verify_password(plain_password: str, hashed_password: str) -> bool:
return pwd_context.verify(plain_password, hashed_password)


def get_password_hash(password: str) -> str:
return pwd_context.hash(password)


def create_access_token(
subject: str | Any, expires_delta: timedelta | None = None
) -> str:
expire = datetime.utcnow() + (
expires_delta or timedelta(minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES)
)
to_encode = {"exp": expire, "sub": str(subject)}
return jwt.encode(to_encode, settings.SECRET_KEY, algorithm=settings.ALGORITHM)


def create_refresh_token(subject: str | Any) -> str:
expire = datetime.utcnow() + timedelta(days=settings.REFRESH_TOKEN_EXPIRE_DAYS)
to_encode = {"exp": expire, "sub": str(subject), "type": "refresh"}
return jwt.encode(to_encode, settings.SECRET_KEY, algorithm=settings.ALGORITHM)


def decode_token(token: str) -> dict:
try:
payload = jwt.decode(
token, settings.SECRET_KEY, algorithms=[settings.ALGORITHM]
)
return payload
except JWTError:
raise ValueError("Could not validate credentials")
Loading
Loading