# 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 ```python # 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: ```json {"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](url), #h1-######, > quotes, ```code blocks```, ---. ### API key auth dual-mode - **Header mode**: `Authorization: Bearer ` — 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 ```bash # 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