diff --git a/.env.example b/.env.example index 786352a..4ed390b 100644 --- a/.env.example +++ b/.env.example @@ -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 diff --git a/.gitignore b/.gitignore index 0dcccfd..06b059d 100644 --- a/.gitignore +++ b/.gitignore @@ -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/ diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000..09e7e04 --- /dev/null +++ b/.pre-commit-config.yaml @@ -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] diff --git a/README.md b/README.md index 7c79724..c9a2235 100644 --- a/README.md +++ b/README.md @@ -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 @@ -21,3 +38,4 @@ This repository demonstrates a production-grade backend architecture coupled wit --- *Professional-grade engineering for long-term scalability.* +>>>>>>> main diff --git a/alembic/env.py b/alembic/env.py index 140eff9..631b001 100644 --- a/alembic/env.py +++ b/alembic/env.py @@ -1,3 +1,5 @@ +import os +import sys from logging.config import fileConfig from sqlalchemy import engine_from_config @@ -5,40 +7,32 @@ 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, @@ -52,12 +46,6 @@ 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.", @@ -65,9 +53,7 @@ def run_migrations_online() -> None: ) 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() diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..55070d0 --- /dev/null +++ b/docker-compose.yml @@ -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 diff --git a/requirements.txt b/requirements.txt index 37a9486..7150c6d 100644 --- a/requirements.txt +++ b/requirements.txt @@ -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 diff --git a/src/__init__.py b/src/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/api/__init__.py b/src/api/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/api/v1/auth/__init__.py b/src/api/v1/auth/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/api/v1/auth/router.py b/src/api/v1/auth/router.py new file mode 100644 index 0000000..3ffb678 --- /dev/null +++ b/src/api/v1/auth/router.py @@ -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", + } diff --git a/src/core/config.py b/src/core/config.py index 4001434..4b256e1 100644 --- a/src/core/config.py +++ b/src/core/config.py @@ -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 diff --git a/src/core/database.py b/src/core/database.py new file mode 100644 index 0000000..5d79d57 --- /dev/null +++ b/src/core/database.py @@ -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() diff --git a/src/core/security/__init__.py b/src/core/security/__init__.py new file mode 100644 index 0000000..090a56f --- /dev/null +++ b/src/core/security/__init__.py @@ -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") diff --git a/src/core/security/dependencies.py b/src/core/security/dependencies.py new file mode 100644 index 0000000..36dcc3a --- /dev/null +++ b/src/core/security/dependencies.py @@ -0,0 +1,39 @@ +from fastapi import Depends, HTTPException, status +from fastapi.security import OAuth2PasswordBearer +from sqlalchemy.orm import Session + +from src.core.database import get_db +from src.core.security import decode_token +from src.models.user import User + +oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/auth/login") + + +async def get_current_user( + token: str = Depends(oauth2_scheme), db: Session = Depends(get_db) +) -> User: + credentials_exception = HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Could not validate credentials", + headers={"WWW-Authenticate": "Bearer"}, + ) + try: + payload = decode_token(token) + email: str = payload.get("sub") + if email is None: + raise credentials_exception + except Exception: + raise credentials_exception + + user = db.query(User).filter(User.email == email).first() + if user is None: + raise credentials_exception + return user + + +async def get_current_active_user( + current_user: User = Depends(get_current_user), +) -> User: + if not current_user.is_active: + raise HTTPException(status_code=400, detail="Inactive user") + return current_user diff --git a/src/main.py b/src/main.py index dcbe13c..f0a4e18 100644 --- a/src/main.py +++ b/src/main.py @@ -1,10 +1,28 @@ -from fastapi import FastAPI -from src.api.v1 import health +from fastapi import FastAPI, Depends +from src.api.v1.auth.router import router as auth_router +from src.core.security.dependencies import get_current_active_user +from src.models.user import User +from src.schemas.user import UserRead +from src.core.config import get_settings + +settings = get_settings() app = FastAPI( - title="Enterprise Core API", - description="High-performance backend with automated cloud provisioning.", - version="1.0.0" + title=settings.PROJECT_NAME, + description="Enterprise-grade FastAPI backend", + version="0.1.0", ) -app.include_router(health.router) +# Include routers +app.include_router(auth_router) + + +# Example protected route +@app.get("/users/me", response_model=UserRead) +async def read_users_me(current_user: User = Depends(get_current_active_user)): + return current_user + + +@app.get("/health") +async def health_check(): + return {"status": "healthy"} diff --git a/src/models/user.py b/src/models/user.py new file mode 100644 index 0000000..614fc5b --- /dev/null +++ b/src/models/user.py @@ -0,0 +1,16 @@ +from sqlalchemy import Column, Integer, String, Boolean, DateTime +from sqlalchemy.sql import func +from src.core.database import Base + + +class User(Base): + __tablename__ = "users" + + id = Column(Integer, primary_key=True, index=True) + email = Column(String, unique=True, index=True, nullable=False) + hashed_password = Column(String, nullable=False) + full_name = Column(String, nullable=True) + is_active = Column(Boolean, default=True) + is_superuser = Column(Boolean, default=False) + created_at = Column(DateTime(timezone=True), server_default=func.now()) + updated_at = Column(DateTime(timezone=True), onupdate=func.now()) diff --git a/src/schemas/__init__.py b/src/schemas/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/schemas/etims.py b/src/schemas/etims.py new file mode 100644 index 0000000..26bbbe1 --- /dev/null +++ b/src/schemas/etims.py @@ -0,0 +1,20 @@ +from pydantic import BaseModel, Field +from typing import List +from datetime import datetime + + +class ETIMSItem(BaseModel): + item_nm: str = Field(..., description="Item Name") + qty: float = Field(..., gt=0) + prc: float = Field(..., gt=0) + tax_ty_cd: str = "A" # A = 16% VAT + + +class ETIMSInvoice(BaseModel): + invc_no: str + spt_amt: float + tax_bl_amt: float + vatt_amt: float + tot_amt: float + items: List[ETIMSItem] + regr_dt: datetime = Field(default_factory=datetime.now) diff --git a/src/schemas/user.py b/src/schemas/user.py new file mode 100644 index 0000000..5255e53 --- /dev/null +++ b/src/schemas/user.py @@ -0,0 +1,28 @@ +from pydantic import BaseModel, EmailStr +from datetime import datetime +from typing import Optional + + +class UserBase(BaseModel): + email: EmailStr + full_name: Optional[str] = None + + +class UserCreate(UserBase): + password: str + + +class UserRead(UserBase): + id: int + is_active: bool + is_superuser: bool + created_at: datetime + + class Config: + from_attributes = True + + +class Token(BaseModel): + access_token: str + refresh_token: str + token_type: str = "bearer" diff --git a/src/services/etims.py b/src/services/etims.py new file mode 100644 index 0000000..08cb557 --- /dev/null +++ b/src/services/etims.py @@ -0,0 +1,21 @@ +import httpx +from src.core.config import settings +from src.schemas.etims import ETIMSInvoice + + +class ETIMSService: + """Handles secure transmission of invoices to KRA eTIMS portal.""" + + def __init__(self): + self.url = f"{settings.ETIMS_BASE_URL}/invoice/save" + self.device_serial = settings.ETIMS_DEVICE_SERIAL + + async def transmit_invoice(self, invoice: ETIMSInvoice): + async with httpx.AsyncClient() as client: + # Expert Tip: Add custom headers for device authentication + headers = {"X-Device-Serial": self.device_serial} + response = await client.post( + self.url, json=invoice.model_dump(), headers=headers + ) + response.raise_for_status() + return response.json() diff --git a/src/services/mpesa.py b/src/services/mpesa.py new file mode 100644 index 0000000..2f2a7c8 --- /dev/null +++ b/src/services/mpesa.py @@ -0,0 +1,57 @@ +import httpx +import base64 +from datetime import datetime +from src.core.config import settings + + +class MpesaService: + """ + Expert-level service for handling Safaricom Daraja API integrations. + Supports async STK Push and OAuth2 token management. + """ + + def __init__(self): + self.base_url = settings.MPESA_BASE_URL + self.consumer_key = settings.MPESA_CONSUMER_KEY + self.consumer_secret = settings.MPESA_CONSUMER_SECRET + + async def get_access_token(self) -> str: + """Fetch and return a valid OAuth2 token.""" + url = f"{self.base_url}/oauth/v1/generate?grant_type=client_credentials" + async with httpx.AsyncClient() as client: + response = await client.get( + url, auth=(self.consumer_key, self.consumer_secret) + ) + response.raise_for_status() + return response.json()["access_token"] + + async def stk_push(self, phone: str, amount: int, description: str): + """Initiate an STK Push (Lipa na M-Pesa Online).""" + token = await self.get_access_token() + timestamp = datetime.now().strftime("%Y%m%d%H%M%S") + password = base64.b64encode( + f"{settings.MPESA_SHORTCODE}{settings.MPESA_PASSKEY}{timestamp}".encode() + ).decode() + + payload = { + "BusinessShortCode": settings.MPESA_SHORTCODE, + "Password": password, + "Timestamp": timestamp, + "TransactionType": "CustomerPayBillOnline", + "Amount": amount, + "PartyA": phone, + "PartyB": settings.MPESA_SHORTCODE, + "PhoneNumber": phone, + "CallBackURL": settings.MPESA_CALLBACK_URL, + "AccountReference": "EnterpriseCoreAPI", + "TransactionDesc": description, + } + + headers = {"Authorization": f"Bearer {token}"} + async with httpx.AsyncClient() as client: + response = await client.post( + f"{self.base_url}/mpesa/stkpush/v1/processrequest", + json=payload, + headers=headers, + ) + return response.json()