feat: scaffold FastAPI backend and Vue 3 frontend
Backend (backend/):
- FastAPI app with layered app/{api,core,crud,db,models,schemas} structure
- TiDB connection via SQLAlchemy. The URL is built with URL.create rather
than string formatting: the password contains '@', which would otherwise be
parsed as the user/host separator and silently truncate the credential.
- Alembic environment wired to Base.metadata and the app settings, so
backend/.env stays the single source of truth for credentials
- /api/health endpoint reporting database reachability
- models/ and crud/ are intentionally empty: no model classes are defined and
no tables are created, at import time or otherwise
Frontend (frontend/):
- Vue 3 + Vite + TypeScript scaffold (create-vue, --bare)
- axios instance with a normalized error shape
- vue-router with a home route and a catch-all 404
- pinia + pinia-plugin-persistedstate; the app store persists selected keys
- Element Plus with its icons registered globally
- dev proxy forwards /api to the FastAPI service on port 8000
The database paper_doc was created in TiDB out of band; no table exists yet.
This commit is contained in:
@@ -0,0 +1 @@
|
||||
"""paper-doc API application package."""
|
||||
@@ -0,0 +1 @@
|
||||
"""HTTP layer: routers and their dependencies."""
|
||||
@@ -0,0 +1,8 @@
|
||||
"""Aggregate router mounted under the configured API prefix."""
|
||||
|
||||
from fastapi import APIRouter
|
||||
|
||||
from app.api.routes import health
|
||||
|
||||
api_router = APIRouter()
|
||||
api_router.include_router(health.router)
|
||||
@@ -0,0 +1 @@
|
||||
"""Route modules, one per resource. Each exposes ``router``."""
|
||||
@@ -0,0 +1,33 @@
|
||||
"""Health endpoint — also the smoke test for database connectivity."""
|
||||
|
||||
from fastapi import APIRouter, Depends
|
||||
from sqlalchemy import text
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.config import get_settings
|
||||
from app.db.session import get_db
|
||||
from app.schemas.health import HealthResponse
|
||||
|
||||
router = APIRouter(tags=["health"])
|
||||
|
||||
|
||||
@router.get("/health", response_model=HealthResponse, summary="Liveness and database probe")
|
||||
def health(db: Session = Depends(get_db)) -> HealthResponse:
|
||||
"""Report process liveness and whether TiDB answered a trivial query.
|
||||
|
||||
A database failure is reported in the body instead of raising, so that the
|
||||
endpoint stays useful for diagnosing an unreachable database.
|
||||
"""
|
||||
settings = get_settings()
|
||||
try:
|
||||
db.execute(text("SELECT 1"))
|
||||
database = "ok"
|
||||
except Exception: # noqa: BLE001 - the failure mode is the payload
|
||||
database = "unavailable"
|
||||
|
||||
return HealthResponse(
|
||||
status="ok" if database == "ok" else "degraded",
|
||||
app=settings.app_name,
|
||||
database=database,
|
||||
database_target=settings.safe_database_url,
|
||||
)
|
||||
@@ -0,0 +1 @@
|
||||
"""Cross-cutting configuration and infrastructure."""
|
||||
@@ -0,0 +1,86 @@
|
||||
"""Application settings, loaded from ``backend/.env`` and the environment.
|
||||
|
||||
The database URL is assembled with :func:`sqlalchemy.engine.URL.create` rather
|
||||
than string formatting. That matters here: the TiDB password contains ``@``,
|
||||
and interpolating it into a DSN by hand silently truncates the password at the
|
||||
first ``@`` (it gets parsed as the user/host separator). ``URL.create``
|
||||
percent-encodes each component correctly.
|
||||
"""
|
||||
|
||||
from functools import lru_cache
|
||||
from pathlib import Path
|
||||
from typing import Union
|
||||
|
||||
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||
from sqlalchemy import URL
|
||||
|
||||
# backend/app/core/config.py -> backend/
|
||||
BASE_DIR = Path(__file__).resolve().parents[2]
|
||||
|
||||
|
||||
class Settings(BaseSettings):
|
||||
"""Runtime configuration for the API service."""
|
||||
|
||||
model_config = SettingsConfigDict(
|
||||
env_file=BASE_DIR / ".env",
|
||||
env_file_encoding="utf-8",
|
||||
extra="ignore",
|
||||
)
|
||||
|
||||
app_name: str = "paper-doc API"
|
||||
api_prefix: str = "/api"
|
||||
debug: bool = True
|
||||
|
||||
# The Vite dev server proxies /api to this service, so CORS is normally not
|
||||
# exercised during development; it is configured for direct-origin setups.
|
||||
cors_origins: list[str] = [
|
||||
"http://127.0.0.1:5173",
|
||||
"http://localhost:5173",
|
||||
]
|
||||
|
||||
# --- Database (TiDB) -------------------------------------------------
|
||||
db_host: str = "127.0.0.1"
|
||||
db_port: int = 4000
|
||||
db_user: str = "root"
|
||||
db_password: str = ""
|
||||
db_name: str = "paper_doc"
|
||||
|
||||
# When set, this wins over the DB_* parts above and is used verbatim.
|
||||
database_url: str | None = None
|
||||
|
||||
# --- Connection pool -------------------------------------------------
|
||||
db_echo: bool = False
|
||||
db_pool_pre_ping: bool = True
|
||||
db_pool_recycle: int = 3600
|
||||
|
||||
@property
|
||||
def sqlalchemy_url(self) -> Union[URL, str]:
|
||||
"""The SQLAlchemy connection URL.
|
||||
|
||||
Returns a :class:`~sqlalchemy.engine.URL` unless ``DATABASE_URL`` was
|
||||
supplied explicitly, in which case that string is passed through.
|
||||
"""
|
||||
if self.database_url:
|
||||
return self.database_url
|
||||
return URL.create(
|
||||
drivername="mysql+pymysql",
|
||||
username=self.db_user,
|
||||
password=self.db_password,
|
||||
host=self.db_host,
|
||||
port=self.db_port,
|
||||
database=self.db_name,
|
||||
)
|
||||
|
||||
@property
|
||||
def safe_database_url(self) -> str:
|
||||
"""The URL rendered with the password masked, for logs and diagnostics."""
|
||||
url = self.sqlalchemy_url
|
||||
if isinstance(url, str):
|
||||
return URL.make_url(url).render_as_string(hide_password=True)
|
||||
return url.render_as_string(hide_password=True)
|
||||
|
||||
|
||||
@lru_cache
|
||||
def get_settings() -> Settings:
|
||||
"""Return the process-wide settings singleton."""
|
||||
return Settings()
|
||||
@@ -0,0 +1,7 @@
|
||||
"""Data-access helpers.
|
||||
|
||||
Functions here take a :class:`sqlalchemy.orm.Session` and operate on ORM
|
||||
models. There are none yet — this package is a placeholder for that layer.
|
||||
"""
|
||||
|
||||
__all__: list[str] = []
|
||||
@@ -0,0 +1 @@
|
||||
"""Database engine, session factory, and declarative base."""
|
||||
@@ -0,0 +1,16 @@
|
||||
"""Declarative base for ORM models.
|
||||
|
||||
No model classes are defined in this project yet, by design: the database
|
||||
schema is introduced deliberately through Alembic migrations rather than being
|
||||
created implicitly at import time.
|
||||
"""
|
||||
|
||||
from sqlalchemy.orm import DeclarativeBase
|
||||
|
||||
|
||||
class Base(DeclarativeBase):
|
||||
"""Common base class for every ORM model.
|
||||
|
||||
Subclass this to declare a model. New models must also be imported by
|
||||
``app.models`` so that ``alembic revision --autogenerate`` can see them.
|
||||
"""
|
||||
@@ -0,0 +1,35 @@
|
||||
"""Database engine, session factory, and the FastAPI session dependency."""
|
||||
|
||||
from collections.abc import Generator
|
||||
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.orm import Session, sessionmaker
|
||||
|
||||
from app.core.config import get_settings
|
||||
|
||||
settings = get_settings()
|
||||
|
||||
engine = create_engine(
|
||||
settings.sqlalchemy_url,
|
||||
echo=settings.db_echo,
|
||||
pool_pre_ping=settings.db_pool_pre_ping,
|
||||
pool_recycle=settings.db_pool_recycle,
|
||||
future=True,
|
||||
)
|
||||
|
||||
SessionLocal = sessionmaker(
|
||||
bind=engine,
|
||||
class_=Session,
|
||||
autoflush=False,
|
||||
autocommit=False,
|
||||
expire_on_commit=False,
|
||||
)
|
||||
|
||||
|
||||
def get_db() -> Generator[Session, None, None]:
|
||||
"""Yield a session per request and always close it."""
|
||||
db = SessionLocal()
|
||||
try:
|
||||
yield db
|
||||
finally:
|
||||
db.close()
|
||||
@@ -0,0 +1,29 @@
|
||||
"""FastAPI application entry point.
|
||||
|
||||
Run locally with::
|
||||
|
||||
uvicorn app.main:app --reload --port 8000
|
||||
"""
|
||||
|
||||
from fastapi import FastAPI
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
|
||||
from app.api.router import api_router
|
||||
from app.core.config import get_settings
|
||||
|
||||
settings = get_settings()
|
||||
|
||||
app = FastAPI(
|
||||
title=settings.app_name,
|
||||
debug=settings.debug,
|
||||
)
|
||||
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=settings.cors_origins,
|
||||
allow_credentials=True,
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
)
|
||||
|
||||
app.include_router(api_router, prefix=settings.api_prefix)
|
||||
@@ -0,0 +1,15 @@
|
||||
"""SQLAlchemy ORM models.
|
||||
|
||||
This package is intentionally empty.
|
||||
|
||||
No model classes exist yet, and no tables are created by this project at
|
||||
import time. The schema will be introduced through Alembic migrations:
|
||||
|
||||
alembic revision --autogenerate -m "describe the change"
|
||||
alembic upgrade head
|
||||
|
||||
When the first model is written, add it here (or in a submodule imported from
|
||||
here) so that ``Base.metadata`` — and therefore autogenerate — can see it.
|
||||
"""
|
||||
|
||||
__all__: list[str] = []
|
||||
@@ -0,0 +1 @@
|
||||
"""Pydantic models describing request and response payloads."""
|
||||
@@ -0,0 +1,12 @@
|
||||
"""Response models for the health endpoint."""
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class HealthResponse(BaseModel):
|
||||
"""Liveness report, including whether the database answered."""
|
||||
|
||||
status: str = Field(description="'ok' when the database responded, else 'degraded'")
|
||||
app: str = Field(description="Configured application name")
|
||||
database: str = Field(description="'ok' or 'unavailable'")
|
||||
database_target: str = Field(description="Connection target with the password masked")
|
||||
Reference in New Issue
Block a user