From ce755559582b3d4e37c0c76ffe2ad56bc2b92e9f Mon Sep 17 00:00:00 2001 From: jigoong Date: Fri, 13 Feb 2026 17:29:01 +0700 Subject: [PATCH] add init new files --- .env.example | 15 +++++ .gitignore | 10 ++++ Dockerfile | 17 ++++++ README.md | 27 +++++++++ app/__init__.py | 0 app/admin.py | 107 +++++++++++++++++++++++++++++++++++ app/api/__init__.py | 0 app/api/v1/__init__.py | 0 app/api/v1/routes.py | 67 ++++++++++++++++++++++ app/api/v1/schemas.py | 15 +++++ app/core/__init__.py | 0 app/core/config.py | 25 ++++++++ app/db/__init__.py | 0 app/db/base.py | 5 ++ app/db/engine.py | 21 +++++++ app/db/init_db.py | 12 ++++ app/db/models.py | 56 ++++++++++++++++++ app/main.py | 21 +++++++ app/security/__init__.py | 0 app/security/api_key.py | 22 +++++++ app/security/dependencies.py | 54 ++++++++++++++++++ docker-compose.yml | 11 ++++ requirements.txt | 12 ++++ 23 files changed, 497 insertions(+) create mode 100644 .env.example create mode 100644 .gitignore create mode 100644 Dockerfile create mode 100644 README.md create mode 100644 app/__init__.py create mode 100644 app/admin.py create mode 100644 app/api/__init__.py create mode 100644 app/api/v1/__init__.py create mode 100644 app/api/v1/routes.py create mode 100644 app/api/v1/schemas.py create mode 100644 app/core/__init__.py create mode 100644 app/core/config.py create mode 100644 app/db/__init__.py create mode 100644 app/db/base.py create mode 100644 app/db/engine.py create mode 100644 app/db/init_db.py create mode 100644 app/db/models.py create mode 100644 app/main.py create mode 100644 app/security/__init__.py create mode 100644 app/security/api_key.py create mode 100644 app/security/dependencies.py create mode 100644 docker-compose.yml create mode 100644 requirements.txt diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..43de171 --- /dev/null +++ b/.env.example @@ -0,0 +1,15 @@ +DB_HOST=localhost +DB_PORT=5432 +DB_USER=postgres +DB_PASSWORD=postgres +DB_NAME=postgres + +DB_SSLMODE=prefer + +ROOT_PATH=/apiservice + +APP_NAME=APIsService + +ADMIN_SECRET_KEY=change-me +ADMIN_USERNAME=admin +ADMIN_PASSWORD=change-me diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..8d1428b --- /dev/null +++ b/.gitignore @@ -0,0 +1,10 @@ +.env +__pycache__/ +*.pyc +.venv/ +venv/ +.python-version +.pytest_cache/ +.mypy_cache/ +ruff_cache/ +.windsurf/ diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..bc4e268 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,17 @@ +FROM python:3.12-slim + +ENV PYTHONDONTWRITEBYTECODE=1 +ENV PYTHONUNBUFFERED=1 + +WORKDIR /app + +COPY requirements.txt /app/requirements.txt +RUN pip install --no-cache-dir -r /app/requirements.txt + +COPY ./app /app/app + +ENV TZ=Asia/Bangkok + +EXPOSE 8000 + +CMD ["gunicorn","-k","uvicorn.workers.UvicornWorker","app.main:app","--bind","0.0.0.0:8000","--workers","2","--access-logfile","-","--error-logfile","-"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..71e0fb0 --- /dev/null +++ b/README.md @@ -0,0 +1,27 @@ +# apiservice + +## Run + +1. Copy env + +```bash +cp .env.example .env +``` + +2. Update DB connection env values + +3. Start + +```bash +docker compose up --build +``` + +## Base path + +Set `ROOT_PATH=/apiservice` when running behind reverse proxy. + +## Permissions + +The checkpoint endpoint requires permission: + +- `feed.checkpoint:write` diff --git a/app/__init__.py b/app/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/admin.py b/app/admin.py new file mode 100644 index 0000000..77128fc --- /dev/null +++ b/app/admin.py @@ -0,0 +1,107 @@ +from __future__ import annotations + +from fastapi import HTTPException, Request, status +from sqladmin import Admin, ModelView +from sqladmin.authentication import AuthenticationBackend +from starlette.responses import RedirectResponse +from sqlalchemy.orm import sessionmaker +from wtforms import StringField +from wtforms.validators import Optional + +from app.core.config import settings +from app.db.engine import engine +from app.db.models import ApiClient, ApiKey +from app.security.api_key import generate_api_key, get_prefix, hash_api_key + + +class AdminAuth(AuthenticationBackend): + async def login(self, request: Request) -> bool: + form = await request.form() + username = form.get("username") + password = form.get("password") + + if username == settings.ADMIN_USERNAME and password == settings.ADMIN_PASSWORD: + request.session.update({"admin": True}) + return True + return False + + async def logout(self, request: Request) -> bool: + request.session.clear() + return True + + async def authenticate(self, request: Request) -> bool: + return bool(request.session.get("admin")) + + +class ApiClientAdmin(ModelView, model=ApiClient): + column_list = [ApiClient.id, ApiClient.name, ApiClient.is_active] + + +class ApiKeyAdmin(ModelView, model=ApiKey): + form_excluded_columns = [ApiKey.key_hash, ApiKey.key_prefix, ApiKey.created_at] + + form_extra_fields = { + "plain_key": StringField("Plain Key", validators=[Optional()]), + "permissions_csv": StringField("Permissions (comma)", validators=[Optional()]), + } + + async def on_model_change(self, data: dict, model: ApiKey, is_created: bool, request: Request) -> None: + plain_key = data.get("plain_key") + if plain_key: + model.key_prefix = get_prefix(plain_key) + model.key_hash = hash_api_key(plain_key) + + permissions_csv = data.get("permissions_csv") + if permissions_csv is not None: + perms = [p.strip() for p in permissions_csv.split(",") if p.strip()] + model.permissions = perms + + +def mount_admin(app): + auth_backend = AdminAuth(secret_key=settings.ADMIN_SECRET_KEY) + admin = Admin(app=app, engine=engine, authentication_backend=auth_backend) + + SessionLocal = sessionmaker(bind=engine, autoflush=False, autocommit=False) + + admin.add_view(ApiClientAdmin) + admin.add_view(ApiKeyAdmin) + + @app.get("/admin") + async def _admin_redirect(request: Request): + root_path = request.scope.get("root_path") or "" + return RedirectResponse(url=f"{root_path}/admin/") + + @app.post("/admin/api-keys/generate") + async def _admin_generate_api_key( + request: Request, + client_id: int, + permissions: str = "", + name: str | None = None, + ): + if not request.session.get("admin"): + raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Not authenticated") + + perms = [p.strip() for p in permissions.split(",") if p.strip()] + plain_key = generate_api_key() + + db = SessionLocal() + try: + client = db.get(ApiClient, client_id) + if not client: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Client not found") + + api_key = ApiKey( + client_id=client_id, + name=name, + key_prefix=get_prefix(plain_key), + key_hash=hash_api_key(plain_key), + permissions=perms, + is_active=True, + ) + db.add(api_key) + db.commit() + db.refresh(api_key) + + return {"key_id": api_key.id, "api_key": plain_key, "permissions": perms} + finally: + db.close() diff --git a/app/api/__init__.py b/app/api/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/api/v1/__init__.py b/app/api/v1/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/api/v1/routes.py b/app/api/v1/routes.py new file mode 100644 index 0000000..d944984 --- /dev/null +++ b/app/api/v1/routes.py @@ -0,0 +1,67 @@ +from __future__ import annotations + +from typing import Annotated +from zoneinfo import ZoneInfo + +from fastapi import APIRouter, Depends +from sqlalchemy.dialects.postgresql import insert +from sqlalchemy.orm import Session + +from app.api.v1.schemas import FeedCheckpointIn +from app.core.config import settings +from app.db.models import RawOpdCheckpoint +from app.security.dependencies import get_db, require_permission + + +router = APIRouter(prefix="/api/v1") + +PERM_FEED_CHECKPOINT_WRITE = "feed.checkpoint:write" + + +def _to_tz(dt): + if dt is None: + return None + if dt.tzinfo is None: + return dt.replace(tzinfo=ZoneInfo(settings.TIMEZONE)) + return dt.astimezone(ZoneInfo(settings.TIMEZONE)) + + +@router.post("/feed/checkpoint") +def upsert_feed_checkpoint( + payload: list[FeedCheckpointIn], + _: Annotated[object, Depends(require_permission(PERM_FEED_CHECKPOINT_WRITE))], + db: Annotated[Session, Depends(get_db)], +): + rows = [] + for item in payload: + rows.append( + { + "id": item.id, + "hn": item.hn, + "vn": item.vn, + "location": item.location, + "type": item.type, + "timestamp_in": _to_tz(item.timestamp_in), + "timestamp_out": _to_tz(item.timestamp_out), + "waiting_time": item.waiting_time, + "bu": item.bu, + } + ) + + stmt = insert(RawOpdCheckpoint).values(rows) + update_cols = { + "hn": stmt.excluded.hn, + "vn": stmt.excluded.vn, + "location": stmt.excluded.location, + "type": stmt.excluded.type, + "timestamp_in": stmt.excluded.timestamp_in, + "timestamp_out": stmt.excluded.timestamp_out, + "waiting_time": stmt.excluded.waiting_time, + "bu": stmt.excluded.bu, + } + + stmt = stmt.on_conflict_do_update(index_elements=[RawOpdCheckpoint.id], set_=update_cols) + result = db.execute(stmt) + db.commit() + + return {"upserted": len(rows), "rowcount": result.rowcount} diff --git a/app/api/v1/schemas.py b/app/api/v1/schemas.py new file mode 100644 index 0000000..cb67e1d --- /dev/null +++ b/app/api/v1/schemas.py @@ -0,0 +1,15 @@ +from datetime import datetime + +from pydantic import BaseModel + + +class FeedCheckpointIn(BaseModel): + id: int + hn: int + vn: int + location: str + type: str + timestamp_in: datetime + timestamp_out: datetime | None = None + waiting_time: int | None = None + bu: str | None = None diff --git a/app/core/__init__.py b/app/core/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/core/config.py b/app/core/config.py new file mode 100644 index 0000000..1e503a0 --- /dev/null +++ b/app/core/config.py @@ -0,0 +1,25 @@ +from pydantic_settings import BaseSettings, SettingsConfigDict + + +class Settings(BaseSettings): + model_config = SettingsConfigDict(env_file=".env", extra="ignore") + + APP_NAME: str = "APIsService" + + DB_HOST: str + DB_PORT: int = 5432 + DB_USER: str + DB_PASSWORD: str + DB_NAME: str + DB_SSLMODE: str = "prefer" + + ROOT_PATH: str = "" + + TIMEZONE: str = "Asia/Bangkok" + + ADMIN_SECRET_KEY: str + ADMIN_USERNAME: str + ADMIN_PASSWORD: str + + +settings = Settings() diff --git a/app/db/__init__.py b/app/db/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/db/base.py b/app/db/base.py new file mode 100644 index 0000000..fa2b68a --- /dev/null +++ b/app/db/base.py @@ -0,0 +1,5 @@ +from sqlalchemy.orm import DeclarativeBase + + +class Base(DeclarativeBase): + pass diff --git a/app/db/engine.py b/app/db/engine.py new file mode 100644 index 0000000..62d5465 --- /dev/null +++ b/app/db/engine.py @@ -0,0 +1,21 @@ +from urllib.parse import quote_plus + +from sqlalchemy import create_engine + +from app.core.config import settings + + +def build_db_url() -> str: + user = quote_plus(settings.DB_USER) + password = quote_plus(settings.DB_PASSWORD) + host = settings.DB_HOST + port = settings.DB_PORT + db = quote_plus(settings.DB_NAME) + + return ( + f"postgresql+psycopg://{user}:{password}@{host}:{port}/{db}" + f"?sslmode={quote_plus(settings.DB_SSLMODE)}" + ) + + +engine = create_engine(build_db_url(), pool_pre_ping=True) diff --git a/app/db/init_db.py b/app/db/init_db.py new file mode 100644 index 0000000..3b2887d --- /dev/null +++ b/app/db/init_db.py @@ -0,0 +1,12 @@ +from sqlalchemy import text + +from app.db.base import Base +from app.db.engine import engine + + +def init_db() -> None: + with engine.begin() as conn: + conn.execute(text("CREATE SCHEMA IF NOT EXISTS fastapi")) + conn.execute(text("CREATE SCHEMA IF NOT EXISTS operationbi")) + + Base.metadata.create_all(bind=conn) diff --git a/app/db/models.py b/app/db/models.py new file mode 100644 index 0000000..b1a5135 --- /dev/null +++ b/app/db/models.py @@ -0,0 +1,56 @@ +from __future__ import annotations + +from datetime import datetime + +from sqlalchemy import BigInteger, Boolean, DateTime, ForeignKey, Integer, String, Text, func +from sqlalchemy.dialects.postgresql import JSONB +from sqlalchemy.orm import Mapped, mapped_column, relationship + +from app.db.base import Base + + +class RawOpdCheckpoint(Base): + __tablename__ = "raw_opd_checkpoint" + __table_args__ = {"schema": "operationbi"} + + id: Mapped[int] = mapped_column(BigInteger, primary_key=True) + hn: Mapped[int] = mapped_column(BigInteger, nullable=False) + vn: Mapped[int] = mapped_column(BigInteger, nullable=False) + location: Mapped[str] = mapped_column(Text, nullable=False) + type: Mapped[str] = mapped_column(String(64), nullable=False) + timestamp_in: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False) + timestamp_out: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) + waiting_time: Mapped[int | None] = mapped_column(Integer, nullable=True) + bu: Mapped[str | None] = mapped_column(String(128), nullable=True) + + +class ApiClient(Base): + __tablename__ = "api_client" + __table_args__ = {"schema": "fastapi"} + + id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) + name: Mapped[str] = mapped_column(String(128), unique=True, nullable=False) + is_active: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True) + + api_keys: Mapped[list[ApiKey]] = relationship(back_populates="client") + + +class ApiKey(Base): + __tablename__ = "api_key" + __table_args__ = {"schema": "fastapi"} + + id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) + client_id: Mapped[int] = mapped_column(ForeignKey("fastapi.api_client.id"), nullable=False) + name: Mapped[str | None] = mapped_column(String(128), nullable=True) + + key_prefix: Mapped[str] = mapped_column(String(12), nullable=False) + key_hash: Mapped[str] = mapped_column(Text, nullable=False) + + permissions: Mapped[list[str]] = mapped_column(JSONB, nullable=False, default=list) + is_active: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True) + + created_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), nullable=False, server_default=func.now() + ) + + client: Mapped[ApiClient] = relationship(back_populates="api_keys") diff --git a/app/main.py b/app/main.py new file mode 100644 index 0000000..a52d0f2 --- /dev/null +++ b/app/main.py @@ -0,0 +1,21 @@ +from contextlib import asynccontextmanager + +from fastapi import FastAPI +from starlette.middleware.sessions import SessionMiddleware + +from app.admin import mount_admin +from app.api.v1.routes import router as v1_router +from app.core.config import settings +from app.db.init_db import init_db + + +@asynccontextmanager +async def lifespan(_: FastAPI): + init_db() + yield + + +app = FastAPI(title=settings.APP_NAME, root_path=settings.ROOT_PATH, lifespan=lifespan) +app.add_middleware(SessionMiddleware, secret_key=settings.ADMIN_SECRET_KEY) +app.include_router(v1_router) +mount_admin(app) diff --git a/app/security/__init__.py b/app/security/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/security/api_key.py b/app/security/api_key.py new file mode 100644 index 0000000..9a28d98 --- /dev/null +++ b/app/security/api_key.py @@ -0,0 +1,22 @@ +import secrets + +import bcrypt + + +def generate_api_key(prefix_len: int = 8, token_bytes: int = 32) -> str: + prefix = secrets.token_urlsafe(prefix_len)[:prefix_len] + token = secrets.token_urlsafe(token_bytes) + return f"{prefix}.{token}" + + +def get_prefix(api_key: str) -> str: + return api_key.split(".", 1)[0] + + +def hash_api_key(api_key: str) -> str: + hashed = bcrypt.hashpw(api_key.encode("utf-8"), bcrypt.gensalt()) + return hashed.decode("utf-8") + + +def verify_api_key(api_key: str, api_key_hash: str) -> bool: + return bcrypt.checkpw(api_key.encode("utf-8"), api_key_hash.encode("utf-8")) diff --git a/app/security/dependencies.py b/app/security/dependencies.py new file mode 100644 index 0000000..b7bc910 --- /dev/null +++ b/app/security/dependencies.py @@ -0,0 +1,54 @@ +from typing import Annotated + +from fastapi import Depends, HTTPException, Request, status +from sqlalchemy import select +from sqlalchemy.orm import Session, sessionmaker + +from app.db.engine import engine +from app.db.models import ApiKey +from app.security.api_key import get_prefix, verify_api_key + + +SessionLocal = sessionmaker(bind=engine, autoflush=False, autocommit=False) + + +def get_db(): + db = SessionLocal() + try: + yield db + finally: + db.close() + + +def get_bearer_token(request: Request) -> str: + auth = request.headers.get("authorization") + if not auth: + raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Missing Authorization") + + parts = auth.split(" ", 1) + if len(parts) != 2 or parts[0].lower() != "bearer" or not parts[1].strip(): + raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid Authorization") + + return parts[1].strip() + + +def require_permission(permission: str): + def _dep( + token: Annotated[str, Depends(get_bearer_token)], + db: Annotated[Session, Depends(get_db)], + ) -> ApiKey: + prefix = get_prefix(token) + stmt = select(ApiKey).where(ApiKey.key_prefix == prefix, ApiKey.is_active.is_(True)) + api_key = db.execute(stmt).scalar_one_or_none() + if not api_key: + raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid API key") + + if not verify_api_key(token, api_key.key_hash): + raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid API key") + + if permission not in (api_key.permissions or []): + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Permission denied") + + return api_key + + return _dep diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..a86b6c8 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,11 @@ +services: + apiservice: + build: . + container_name: apiservice + env_file: + - .env + environment: + - TZ=Asia/Bangkok + ports: + - "8002:8000" + restart: unless-stopped diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..86fb344 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,12 @@ +fastapi==0.115.8 +uvicorn==0.34.0 +gunicorn==23.0.0 +SQLAlchemy==2.0.38 +psycopg==3.2.5 +pydantic==2.10.6 +pydantic-settings==2.7.1 +sqladmin==0.20.1 +itsdangerous==2.2.0 +bcrypt==4.3.0 +python-multipart==0.0.20 +WTForms==3.2.1