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,19 @@
|
||||
# Copy to .env and fill in real values. .env is git-ignored.
|
||||
|
||||
# --- Application ---
|
||||
APP_NAME=paper-doc API
|
||||
DEBUG=true
|
||||
|
||||
# --- Database (TiDB) ---
|
||||
# Local development reaches TiDB through its k3s NodePort.
|
||||
# Inside the cluster, use the Service instead: DB_HOST=tidb-tidb.tidb, DB_PORT=4000
|
||||
DB_HOST=192.168.1.88
|
||||
DB_PORT=32738
|
||||
DB_USER=Govin
|
||||
DB_PASSWORD=change-me
|
||||
DB_NAME=paper_doc
|
||||
|
||||
# Optional: bypass the DB_* parts above with a full SQLAlchemy URL.
|
||||
# The password must be percent-encoded (a raw '@' would be parsed as the
|
||||
# user/host separator). Prefer the DB_* parts above and let the app encode it.
|
||||
# DATABASE_URL=mysql+pymysql://Govin:change-me%40123@192.168.1.88:32738/paper_doc
|
||||
@@ -0,0 +1,45 @@
|
||||
[alembic]
|
||||
script_location = alembic
|
||||
prepend_sys_path = .
|
||||
path_separator = os
|
||||
version_path_separator = os
|
||||
|
||||
# The database URL is deliberately NOT set here. It is resolved at runtime by
|
||||
# alembic/env.py from the application settings (backend/.env), so that there is
|
||||
# exactly one source of truth and no credential is stored in a tracked file.
|
||||
|
||||
[post_write_hooks]
|
||||
|
||||
[loggers]
|
||||
keys = root,sqlalchemy,alembic
|
||||
|
||||
[handlers]
|
||||
keys = console
|
||||
|
||||
[formatters]
|
||||
keys = generic
|
||||
|
||||
[logger_root]
|
||||
level = WARNING
|
||||
handlers = console
|
||||
qualname =
|
||||
|
||||
[logger_sqlalchemy]
|
||||
level = WARNING
|
||||
handlers =
|
||||
qualname = sqlalchemy.engine
|
||||
|
||||
[logger_alembic]
|
||||
level = INFO
|
||||
handlers =
|
||||
qualname = alembic
|
||||
|
||||
[handler_console]
|
||||
class = StreamHandler
|
||||
args = (sys.stderr,)
|
||||
level = NOTSET
|
||||
formatter = generic
|
||||
|
||||
[formatter_generic]
|
||||
format = %(levelname)-5.5s [%(name)s] %(message)s
|
||||
datefmt = %H:%M:%S
|
||||
@@ -0,0 +1,75 @@
|
||||
"""Alembic migration environment.
|
||||
|
||||
The connection URL comes from the application settings rather than from
|
||||
``alembic.ini``, so ``backend/.env`` stays the single source of truth. The URL
|
||||
is passed to SQLAlchemy as a :class:`~sqlalchemy.engine.URL` object: rendering
|
||||
it back to a string would re-introduce the escaping problem around the ``@``
|
||||
in the TiDB password.
|
||||
|
||||
``target_metadata`` is wired to :class:`app.db.base.Base`, so
|
||||
``alembic revision --autogenerate`` will pick up models as soon as they are
|
||||
defined and imported by ``app.models``. Today that package is empty, so an
|
||||
autogenerated revision is expected to contain no operations.
|
||||
"""
|
||||
|
||||
from logging.config import fileConfig
|
||||
|
||||
from alembic import context
|
||||
from sqlalchemy import create_engine, pool
|
||||
|
||||
from app.core.config import get_settings
|
||||
from app.db.base import Base
|
||||
|
||||
# Import the models package so that every model module is registered on
|
||||
# Base.metadata before autogenerate inspects it. The package defines no models
|
||||
# yet; keeping the import here means the first model added is picked up with no
|
||||
# further change to this file.
|
||||
from app import models # noqa: F401
|
||||
|
||||
config = context.config
|
||||
|
||||
if config.config_file_name is not None:
|
||||
fileConfig(config.config_file_name)
|
||||
|
||||
target_metadata = Base.metadata
|
||||
|
||||
settings = get_settings()
|
||||
|
||||
|
||||
def run_migrations_offline() -> None:
|
||||
"""Emit SQL to stdout without connecting to the database."""
|
||||
context.configure(
|
||||
url=settings.sqlalchemy_url,
|
||||
target_metadata=target_metadata,
|
||||
literal_binds=True,
|
||||
dialect_opts={"paramstyle": "named"},
|
||||
compare_type=True,
|
||||
)
|
||||
|
||||
with context.begin_transaction():
|
||||
context.run_migrations()
|
||||
|
||||
|
||||
def run_migrations_online() -> None:
|
||||
"""Connect to TiDB and apply migrations."""
|
||||
connectable = create_engine(
|
||||
settings.sqlalchemy_url,
|
||||
poolclass=pool.NullPool,
|
||||
future=True,
|
||||
)
|
||||
|
||||
with connectable.connect() as connection:
|
||||
context.configure(
|
||||
connection=connection,
|
||||
target_metadata=target_metadata,
|
||||
compare_type=True,
|
||||
)
|
||||
|
||||
with context.begin_transaction():
|
||||
context.run_migrations()
|
||||
|
||||
|
||||
if context.is_offline_mode():
|
||||
run_migrations_offline()
|
||||
else:
|
||||
run_migrations_online()
|
||||
@@ -0,0 +1,26 @@
|
||||
"""${message}
|
||||
|
||||
Revision ID: ${up_revision}
|
||||
Revises: ${down_revision | comma,n}
|
||||
Create Date: ${create_date}
|
||||
|
||||
"""
|
||||
from collections.abc import Sequence
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
${imports if imports else ""}
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = ${repr(up_revision)}
|
||||
down_revision: str | None = ${repr(down_revision)}
|
||||
branch_labels: str | Sequence[str] | None = ${repr(branch_labels)}
|
||||
depends_on: str | Sequence[str] | None = ${repr(depends_on)}
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
${upgrades if upgrades else "pass"}
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
${downgrades if downgrades else "pass"}
|
||||
@@ -0,0 +1,3 @@
|
||||
# Migration scripts are generated here by:
|
||||
# alembic revision --autogenerate -m "message"
|
||||
# No revision exists yet — the database schema is intentionally still empty.
|
||||
@@ -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")
|
||||
@@ -0,0 +1,7 @@
|
||||
fastapi>=0.115
|
||||
uvicorn[standard]>=0.32
|
||||
sqlalchemy>=2.0
|
||||
alembic>=1.14
|
||||
pymysql>=1.1
|
||||
cryptography>=43.0
|
||||
pydantic-settings>=2.6
|
||||
Reference in New Issue
Block a user