Files
2026-09-06 13:51:25 +00:00

3.7 KiB

md2vk — FastAPI microservice example

Project: VK wall publisher — converts Markdown to VK format_data, publishes via VK API. Pattern: FastAPI + async SQLAlchemy + SQLite + Fernet-encrypted tokens + external API client.

Architecture decisions

Decision Choice Why
Database SQLite + aiosqlite Lightweight, no external deps, fits Docker
ORM SQLAlchemy 2.0 async Async-first, well-typed, portable
Token encryption Fernet (cryptography) AES-128-CBC + HMAC, simple API, key from file
Auth API key (sha256 hash) API key !== VK token, token never leaves encrypted storage
Markdown conversion Custom parser VK format_data is a niche JSON format, no lib exists
HTTP client httpx Async, modern, timeouts by default
VK API scope wall only Minimal permissions — can't read DMs, photos, etc.

Layer structure

app/config.py          → pydantic-settings, reads TOKEN_ENCRYPTION_KEY_FILE
app/security.py        → Fernet encrypt/decrypt + sha256 API key hash
app/database.py        → create_async_engine, async_sessionmaker, init_db()
app/models.py          → User, VkAccount, Publication (SQLAlchemy 2.0 mapped)
app/vk_client.py       → VkClient class: wall.post, users.get, check_token()
app/converters/        → markdown_to_vk(): returns list of chunks with FormatItem[]
app/api/schemas.py     → 12 Pydantic models (Request/Response)
app/api/deps.py        → get_current_user_from_header(), get_user_by_api_key()
app/api/v1.py          → 6 endpoints: health, accounts CRUD, publish, convert, publications
app/main.py            → FastAPI() + lifespan (init_db on startup)

Key implementation details

Fernet key loading

# app/config.py
@field_validator("fernet_key", mode="before")
@classmethod
def load_key(cls, v: Any, info: ValidationInfo) -> str:
    key_file = info.data.get("token_encryption_key_file")
    if key_file and Path(key_file).exists():
        return Path(key_file).read_text().strip()
    raise ValueError("...")

VK format_data converter

The converter parses Markdown AST into VK's format_data JSON which uses offset-based formatting:

{"version": 1, "items": [
  {"type": "bold", "offset": 0, "length": 5},
  {"type": "link", "offset": 12, "length": 4, "url": "https://vk.com"}
]}

Handles: bold, italic, inline_code, links, #h1-######, > quotes, code blocks, ---.

API key auth dual-mode

  • Header mode: Authorization: Bearer <key> — for GET/DELETE (accounts list, delete)
  • Body mode: api_key field in POST body — for publish, create account, list publications
  • Both call get_user_by_api_key() which does sha256 → query User table

Verification commands from session

# Generate key
python3 -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())" > secrets/token_encryption_key

# Verify imports
TOKEN_ENCRYPTION_KEY_FILE=secrets/token_encryption_key \
  python3 -c "from app import config, database, models, security, vk_client; from app.converters import markdown_to_vk; from app.api import schemas, deps, v1; print('OK')"

# Start server
TOKEN_ENCRYPTION_KEY_FILE=secrets/token_encryption_key \
  uvicorn app.main:app --host 0.0.0.0 --port 8000

# Test
curl http://localhost:8000/api/v1/health
curl -X POST http://localhost:8000/api/v1/convert \
  -H "Content-Type: application/json" \
  -d '{"message_md":"**bold** text"}'

Key counts (from session)

  • 9 source files (excluding __init__.py, config, and infrastructure)
  • ~422 lines in converter (largest module)
  • ~310 lines in API v1 router (6 endpoints)
  • 3 ORM models: User, VkAccount, Publication
  • 4 infrastructure files: Dockerfile, docker-compose.yml, Makefile, .gitignore