add init new files

This commit is contained in:
jigoong
2026-02-13 17:29:01 +07:00
commit ce75555958
23 changed files with 497 additions and 0 deletions

15
.env.example Normal file
View File

@@ -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

10
.gitignore vendored Normal file
View File

@@ -0,0 +1,10 @@
.env
__pycache__/
*.pyc
.venv/
venv/
.python-version
.pytest_cache/
.mypy_cache/
ruff_cache/
.windsurf/

17
Dockerfile Normal file
View File

@@ -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","-"]

27
README.md Normal file
View File

@@ -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`

0
app/__init__.py Normal file
View File

107
app/admin.py Normal file
View File

@@ -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()

0
app/api/__init__.py Normal file
View File

0
app/api/v1/__init__.py Normal file
View File

67
app/api/v1/routes.py Normal file
View File

@@ -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}

15
app/api/v1/schemas.py Normal file
View File

@@ -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

0
app/core/__init__.py Normal file
View File

25
app/core/config.py Normal file
View File

@@ -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()

0
app/db/__init__.py Normal file
View File

5
app/db/base.py Normal file
View File

@@ -0,0 +1,5 @@
from sqlalchemy.orm import DeclarativeBase
class Base(DeclarativeBase):
pass

21
app/db/engine.py Normal file
View File

@@ -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)

12
app/db/init_db.py Normal file
View File

@@ -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)

56
app/db/models.py Normal file
View File

@@ -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")

21
app/main.py Normal file
View File

@@ -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)

0
app/security/__init__.py Normal file
View File

22
app/security/api_key.py Normal file
View File

@@ -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"))

View File

@@ -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

11
docker-compose.yml Normal file
View File

@@ -0,0 +1,11 @@
services:
apiservice:
build: .
container_name: apiservice
env_file:
- .env
environment:
- TZ=Asia/Bangkok
ports:
- "8002:8000"
restart: unless-stopped

12
requirements.txt Normal file
View File

@@ -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