add init new files
This commit is contained in:
15
.env.example
Normal file
15
.env.example
Normal 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
10
.gitignore
vendored
Normal file
@@ -0,0 +1,10 @@
|
||||
.env
|
||||
__pycache__/
|
||||
*.pyc
|
||||
.venv/
|
||||
venv/
|
||||
.python-version
|
||||
.pytest_cache/
|
||||
.mypy_cache/
|
||||
ruff_cache/
|
||||
.windsurf/
|
||||
17
Dockerfile
Normal file
17
Dockerfile
Normal 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
27
README.md
Normal 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
0
app/__init__.py
Normal file
107
app/admin.py
Normal file
107
app/admin.py
Normal 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
0
app/api/__init__.py
Normal file
0
app/api/v1/__init__.py
Normal file
0
app/api/v1/__init__.py
Normal file
67
app/api/v1/routes.py
Normal file
67
app/api/v1/routes.py
Normal 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
15
app/api/v1/schemas.py
Normal 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
0
app/core/__init__.py
Normal file
25
app/core/config.py
Normal file
25
app/core/config.py
Normal 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
0
app/db/__init__.py
Normal file
5
app/db/base.py
Normal file
5
app/db/base.py
Normal file
@@ -0,0 +1,5 @@
|
||||
from sqlalchemy.orm import DeclarativeBase
|
||||
|
||||
|
||||
class Base(DeclarativeBase):
|
||||
pass
|
||||
21
app/db/engine.py
Normal file
21
app/db/engine.py
Normal 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
12
app/db/init_db.py
Normal 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
56
app/db/models.py
Normal 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
21
app/main.py
Normal 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
0
app/security/__init__.py
Normal file
22
app/security/api_key.py
Normal file
22
app/security/api_key.py
Normal 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"))
|
||||
54
app/security/dependencies.py
Normal file
54
app/security/dependencies.py
Normal 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
11
docker-compose.yml
Normal 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
12
requirements.txt
Normal 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
|
||||
Reference in New Issue
Block a user