Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
122 changes: 74 additions & 48 deletions backend/app/api/diagrams.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,71 +15,97 @@
@router.get("", response_model=list[dict])
async def list_diagrams() -> list[dict]:
"""List all diagrams (id, title, created_at, updated_at)."""
service = get_diagram_service()
items = await service.list_diagrams()
# Normalize for JSON (id stays string for frontend)
return [
{
"id": r["id"],
"title": r["title"],
"created_at": r["created_at"],
"updated_at": r["updated_at"],
}
for r in items
]
try:
service = get_diagram_service()
items = await service.list_diagrams()
# Normalize for JSON (id stays string for frontend)
return [
{
"id": r["id"],
"title": r["title"],
"created_at": r["created_at"],
"updated_at": r["updated_at"],
}
for r in items
]
except Exception as e:
logger.exception("Failed to list diagrams: %s", e)
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="Database error")


@router.post("", response_model=DiagramResponse, status_code=status.HTTP_201_CREATED)
async def create_diagram(payload: DiagramCreate) -> DiagramResponse:
"""Create a new diagram."""
service = get_diagram_service()
diagram = await service.create_diagram(payload)
return DiagramResponse(
id=UUID(diagram["id"]) if isinstance(diagram["id"], str) else diagram["id"],
title=diagram["title"],
mmd_content=diagram["mmd_content"],
created_at=diagram["created_at"],
updated_at=diagram["updated_at"],
)
try:
service = get_diagram_service()
diagram = await service.create_diagram(payload)
return DiagramResponse(
id=UUID(diagram["id"]) if isinstance(diagram["id"], str) else diagram["id"],
title=diagram["title"],
mmd_content=diagram["mmd_content"],
created_at=diagram["created_at"],
updated_at=diagram["updated_at"],
)
except Exception as e:
logger.exception("Failed to create diagram: %s", e)
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="Database error")


@router.get("/{diagram_id}", response_model=DiagramResponse)
async def get_diagram(diagram_id: UUID) -> DiagramResponse:
"""Get a single diagram by id."""
service = get_diagram_service()
diagram = await service.get_diagram(diagram_id)
if diagram is None:
logger.warning("Diagram not found: %s", diagram_id)
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Diagram not found")
return DiagramResponse(
id=UUID(diagram["id"]) if isinstance(diagram["id"], str) else diagram["id"],
title=diagram["title"],
mmd_content=diagram["mmd_content"],
created_at=diagram["created_at"],
updated_at=diagram["updated_at"],
)
try:
service = get_diagram_service()
diagram = await service.get_diagram(diagram_id)
if diagram is None:
logger.warning("Diagram not found: %s", diagram_id)
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Diagram not found")
return DiagramResponse(
id=UUID(diagram["id"]) if isinstance(diagram["id"], str) else diagram["id"],
title=diagram["title"],
mmd_content=diagram["mmd_content"],
created_at=diagram["created_at"],
updated_at=diagram["updated_at"],
)
except HTTPException:
raise
except Exception as e:
logger.exception("Failed to get diagram %s: %s", diagram_id, e)
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="Database error")


@router.put("/{diagram_id}", response_model=DiagramResponse)
async def update_diagram(diagram_id: UUID, payload: DiagramUpdate) -> DiagramResponse:
"""Update a diagram."""
service = get_diagram_service()
diagram = await service.update_diagram(diagram_id, payload)
if diagram is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Diagram not found")
return DiagramResponse(
id=UUID(diagram["id"]) if isinstance(diagram["id"], str) else diagram["id"],
title=diagram["title"],
mmd_content=diagram["mmd_content"],
created_at=diagram["created_at"],
updated_at=diagram["updated_at"],
)
try:
service = get_diagram_service()
diagram = await service.update_diagram(diagram_id, payload)
if diagram is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Diagram not found")
return DiagramResponse(
id=UUID(diagram["id"]) if isinstance(diagram["id"], str) else diagram["id"],
title=diagram["title"],
mmd_content=diagram["mmd_content"],
created_at=diagram["created_at"],
updated_at=diagram["updated_at"],
)
except HTTPException:
raise
except Exception as e:
logger.exception("Failed to update diagram %s: %s", diagram_id, e)
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="Database error")


@router.delete("/{diagram_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_diagram(diagram_id: UUID) -> None:
"""Delete a diagram."""
service = get_diagram_service()
deleted = await service.delete_diagram(diagram_id)
if not deleted:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Diagram not found")
try:
service = get_diagram_service()
deleted = await service.delete_diagram(diagram_id)
if not deleted:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Diagram not found")
except HTTPException:
raise
except Exception as e:
logger.exception("Failed to delete diagram %s: %s", diagram_id, e)
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="Database error")
14 changes: 13 additions & 1 deletion backend/app/main.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
# Copyright 2025 mmdGenerator Contributors. Licensed under the Apache License 2.0.
"""FastAPI application: diagrams API and export; serves frontend static in production."""
import os
from contextlib import asynccontextmanager

from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
Expand All @@ -9,13 +10,24 @@
from app.core.config import get_settings
from app.core.logging_config import setup_logging
from app.api import diagrams, export
from app.services.diagram_service import get_diagram_service

setup_logging()

@asynccontextmanager
async def lifespan(app: FastAPI):
# Startup: Connect to database
service = get_diagram_service()
await service.connect()
yield
# Shutdown: Close database connection
await service.disconnect()

app = FastAPI(
title="mmdGenerator",
description="Mermaid diagram editor and exporter",
version="0.1.1",
version="2.0.0",
lifespan=lifespan,
)

settings = get_settings()
Expand Down
118 changes: 68 additions & 50 deletions backend/app/services/diagram_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@
"""Diagram persistence: one .mmd content per diagram, stored in SQLite."""
import logging
from datetime import datetime, timezone
from pathlib import Path
from uuid import UUID, uuid4

import aiosqlite
Expand All @@ -16,63 +15,78 @@
class DiagramService:
"""CRUD for diagrams; one mmd file (content) per diagram."""

_db: aiosqlite.Connection | None = None

def __init__(self) -> None:
self._settings = get_settings()
self._data_dir = self._settings.data_dir / self._settings.diagrams_dir_name
self._db_path = self._settings.data_dir / "diagrams.db"

async def _ensure_init(self) -> None:
async def connect(self) -> None:
"""Initialize database connection and schema."""
if self._db is not None:
return

self._settings.data_dir.mkdir(parents=True, exist_ok=True)
async with aiosqlite.connect(self._db_path) as db:
await db.execute(
"""
CREATE TABLE IF NOT EXISTS diagrams (
id TEXT PRIMARY KEY,
title TEXT NOT NULL,
mmd_content TEXT NOT NULL,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
)
"""
self._db = await aiosqlite.connect(self._db_path)
self._db.row_factory = aiosqlite.Row

await self._db.execute(
"""
CREATE TABLE IF NOT EXISTS diagrams (
id TEXT PRIMARY KEY,
title TEXT NOT NULL,
mmd_content TEXT NOT NULL,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
)
await db.commit()
"""
)
await self._db.commit()
logger.info("Connected to database at %s", self._db_path)

async def disconnect(self) -> None:
"""Close database connection."""
if self._db is not None:
await self._db.close()
self._db = None
logger.info("Disconnected from database")

def _get_db(self) -> aiosqlite.Connection:
if self._db is None:
raise RuntimeError("Database not connected. Call connect() first.")
return self._db

async def list_diagrams(self) -> list[dict]:
"""Return list of diagrams (id, title, created_at, updated_at)."""
await self._ensure_init()
async with aiosqlite.connect(self._db_path) as db:
db.row_factory = aiosqlite.Row
async with db.execute(
"SELECT id, title, created_at, updated_at FROM diagrams ORDER BY updated_at DESC"
) as cur:
rows = await cur.fetchall()
db = self._get_db()
async with db.execute(
"SELECT id, title, created_at, updated_at FROM diagrams ORDER BY updated_at DESC"
) as cur:
rows = await cur.fetchall()
return [dict(r) for r in rows]

async def get_diagram(self, diagram_id: UUID) -> dict | None:
"""Get a single diagram by id."""
await self._ensure_init()
async with aiosqlite.connect(self._db_path) as db:
db.row_factory = aiosqlite.Row
async with db.execute(
"SELECT id, title, mmd_content, created_at, updated_at FROM diagrams WHERE id = ?",
(str(diagram_id),),
) as cur:
row = await cur.fetchone()
db = self._get_db()
async with db.execute(
"SELECT id, title, mmd_content, created_at, updated_at FROM diagrams WHERE id = ?",
(str(diagram_id),),
) as cur:
row = await cur.fetchone()
if row is None:
return None
return dict(row)

async def create_diagram(self, payload: DiagramCreate) -> dict:
"""Create a new diagram; returns full diagram dict."""
await self._ensure_init()
db = self._get_db()
diagram_id = uuid4()
now = datetime.now(timezone.utc).isoformat()
async with aiosqlite.connect(self._db_path) as db:
await db.execute(
"INSERT INTO diagrams (id, title, mmd_content, created_at, updated_at) VALUES (?, ?, ?, ?, ?)",
(str(diagram_id), payload.title, payload.mmd_content, now, now),
)
await db.commit()
await db.execute(
"INSERT INTO diagrams (id, title, mmd_content, created_at, updated_at) VALUES (?, ?, ?, ?, ?)",
(str(diagram_id), payload.title, payload.mmd_content, now, now),
)
await db.commit()
logger.info("Created diagram id=%s title=%s", diagram_id, payload.title)
return {
"id": diagram_id,
Expand All @@ -84,19 +98,18 @@ async def create_diagram(self, payload: DiagramCreate) -> dict:

async def update_diagram(self, diagram_id: UUID, payload: DiagramUpdate) -> dict | None:
"""Update diagram; returns updated diagram dict or None if not found."""
await self._ensure_init()
db = self._get_db()
existing = await self.get_diagram(diagram_id)
if existing is None:
return None
title = payload.title if payload.title is not None else existing["title"]
mmd_content = payload.mmd_content if payload.mmd_content is not None else existing["mmd_content"]
now = datetime.now(timezone.utc).isoformat()
async with aiosqlite.connect(self._db_path) as db:
await db.execute(
"UPDATE diagrams SET title = ?, mmd_content = ?, updated_at = ? WHERE id = ?",
(title, mmd_content, now, str(diagram_id)),
)
await db.commit()
await db.execute(
"UPDATE diagrams SET title = ?, mmd_content = ?, updated_at = ? WHERE id = ?",
(title, mmd_content, now, str(diagram_id)),
)
await db.commit()
logger.info("Updated diagram id=%s", diagram_id)
return {
"id": diagram_id,
Expand All @@ -108,12 +121,17 @@ async def update_diagram(self, diagram_id: UUID, payload: DiagramUpdate) -> dict

async def delete_diagram(self, diagram_id: UUID) -> bool:
"""Delete diagram; returns True if deleted."""
await self._ensure_init()
async with aiosqlite.connect(self._db_path) as db:
cur = await db.execute("DELETE FROM diagrams WHERE id = ?", (str(diagram_id),))
await db.commit()
return cur.rowcount > 0
db = self._get_db()
cur = await db.execute("DELETE FROM diagrams WHERE id = ?", (str(diagram_id),))
await db.commit()
return cur.rowcount > 0


_diagram_service_instance: DiagramService | None = None


def get_diagram_service() -> DiagramService:
return DiagramService()
global _diagram_service_instance
if _diagram_service_instance is None:
_diagram_service_instance = DiagramService()
return _diagram_service_instance
7 changes: 6 additions & 1 deletion backend/tests/test_api_diagrams.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,13 @@
@pytest.fixture
def client(tmp_path, monkeypatch):
monkeypatch.setenv("MMD_DATA_DIR", str(tmp_path))
# Clear the singleton instance between tests
from app.services import diagram_service
diagram_service._diagram_service_instance = None

from app.main import app
return TestClient(app)
with TestClient(app) as c:
yield c


def test_create_and_get_diagram(client: TestClient) -> None:
Expand Down
Loading
Loading