377 lines
9.7 KiB
Markdown
377 lines
9.7 KiB
Markdown
# Vein Patterns
|
|
|
|
This document describes the patterns that emerged from building Jira, Slack, and Google veins side-by-side.
|
|
|
|
## Philosophy
|
|
|
|
**Core = Isolated API client** - Can run without FastAPI, framework-agnostic
|
|
**Vein = Corset/wrapper** - Makes the core speak to pawprint ecosystem
|
|
|
|
The vein wrapper is not a literal folder - it's following structural conventions and patterns.
|
|
|
|
## Directory Structure (Standard)
|
|
|
|
```
|
|
vein/{service}/
|
|
├── core/ # ISOLATED - can run standalone
|
|
│ ├── __init__.py
|
|
│ ├── config.py # Pydantic settings from .env
|
|
│ ├── auth.py # Auth logic (optional, for complex auth)
|
|
│ ├── client.py # Main API client
|
|
│ └── {domain}.py # Additional clients (sheets, drive, etc.)
|
|
│
|
|
├── api/ # WRAPPER - FastAPI integration
|
|
│ ├── __init__.py
|
|
│ └── routes.py # APIRouter with endpoints
|
|
│
|
|
├── models/ # Data models
|
|
│ ├── __init__.py
|
|
│ ├── {domain}.py # Pydantic models with from_{service}()
|
|
│ └── formatter.py # Text formatters for LLM output
|
|
│
|
|
├── storage/ # Persistent data (optional, for OAuth tokens)
|
|
│ └── .gitignore
|
|
│
|
|
├── main.py # FastAPI app setup
|
|
├── run.py # Standalone runner
|
|
├── requirements.txt # Dependencies
|
|
├── .env.example # Configuration template
|
|
└── README.md # Service-specific docs
|
|
```
|
|
|
|
## Base Classes
|
|
|
|
### `BaseVein` (vein/base.py)
|
|
|
|
Minimal interface for all veins:
|
|
- `name: str` - Service name
|
|
- `get_client(creds) -> Client` - Create API client
|
|
- `health_check(creds) -> dict` - Test connection
|
|
|
|
Used for simple token-based auth (Jira, Slack, WhatsApp).
|
|
|
|
### `BaseOAuthVein` (vein/oauth.py)
|
|
|
|
Extends BaseVein for OAuth2 services:
|
|
- `get_auth_url(state) -> str` - Generate OAuth URL
|
|
- `exchange_code(code) -> dict` - Code for tokens
|
|
- `refresh_token(refresh_token) -> dict` - Refresh expired tokens
|
|
- `storage: TokenStorage` - Token persistence
|
|
|
|
Used for OAuth2 services (Google, GitHub, GitLab).
|
|
|
|
### `TokenStorage` (vein/oauth.py)
|
|
|
|
File-based token storage (can be overridden for Redis/DB):
|
|
- `save_tokens(user_id, tokens)` - Persist tokens
|
|
- `load_tokens(user_id) -> dict` - Retrieve tokens
|
|
- `is_expired(tokens) -> bool` - Check expiration
|
|
- `delete_tokens(user_id)` - Logout
|
|
|
|
## Core Module Patterns
|
|
|
|
### config.py
|
|
|
|
```python
|
|
from pathlib import Path
|
|
from pydantic_settings import BaseSettings
|
|
|
|
ENV_FILE = Path(__file__).parent.parent / ".env"
|
|
|
|
class {Service}Config(BaseSettings):
|
|
# Service-specific settings
|
|
api_port: int = 800X # Unique port per vein
|
|
|
|
model_config = {
|
|
"env_file": ENV_FILE,
|
|
"env_file_encoding": "utf-8",
|
|
}
|
|
|
|
settings = {Service}Config()
|
|
```
|
|
|
|
**Pattern**: Pydantic BaseSettings with .env file at vein root.
|
|
|
|
### client.py (Simple Auth - Jira, Slack)
|
|
|
|
```python
|
|
from {sdk} import Client
|
|
|
|
def get_client(credentials) -> Client:
|
|
"""Create authenticated client."""
|
|
return Client(credentials)
|
|
```
|
|
|
|
**Pattern**: Simple factory function returning SDK client.
|
|
|
|
### oauth.py (OAuth2 - Google)
|
|
|
|
```python
|
|
class {Service}OAuth:
|
|
"""OAuth2 client."""
|
|
|
|
def get_authorization_url(self, state=None) -> str:
|
|
"""Generate auth URL for user redirect."""
|
|
|
|
def exchange_code_for_tokens(self, code: str) -> dict:
|
|
"""Exchange code for tokens."""
|
|
|
|
def refresh_access_token(self, refresh_token: str) -> dict:
|
|
"""Refresh expired token."""
|
|
|
|
def get_credentials(self, access_token, refresh_token=None):
|
|
"""Create SDK credentials from tokens."""
|
|
```
|
|
|
|
**Pattern**: OAuth client handles full flow, separate from API client.
|
|
|
|
## API Module Patterns
|
|
|
|
### routes.py
|
|
|
|
```python
|
|
from fastapi import APIRouter, HTTPException, Query
|
|
from fastapi.responses import PlainTextResponse
|
|
|
|
router = APIRouter()
|
|
|
|
@router.get("/health")
|
|
async def health(creds = Depends(get_credentials)):
|
|
"""Test connection."""
|
|
return {"status": "ok", "user": "..."}
|
|
|
|
@router.get("/resource")
|
|
async def get_resource(text: bool = False):
|
|
"""Get resource data."""
|
|
# ... fetch data
|
|
return _maybe_text(data, text, formatter)
|
|
```
|
|
|
|
**Standard endpoints**:
|
|
- `/health` - Connection test (required)
|
|
- Service-specific resources
|
|
- `?text=true` query param for text output
|
|
|
|
### Helper Functions
|
|
|
|
```python
|
|
def _maybe_text(data, text: bool, formatter):
|
|
"""Return text or JSON based on query param."""
|
|
if not text:
|
|
return data
|
|
return PlainTextResponse(formatter(data))
|
|
```
|
|
|
|
**Pattern**: Consistent text/JSON toggle across all veins.
|
|
|
|
## Model Patterns
|
|
|
|
### Domain Models
|
|
|
|
```python
|
|
from pydantic import BaseModel
|
|
|
|
class Resource(BaseModel):
|
|
id: str
|
|
name: str
|
|
# ... fields
|
|
|
|
@classmethod
|
|
def from_{service}(cls, raw: dict) -> "Resource":
|
|
"""Parse from service API response."""
|
|
return cls(
|
|
id=raw["id"],
|
|
name=raw["name"],
|
|
# ... mapping
|
|
)
|
|
```
|
|
|
|
**Pattern**: Pydantic models with `from_{service}()` factory methods.
|
|
|
|
### Formatters
|
|
|
|
```python
|
|
def format_{resource}(resource: Resource) -> str:
|
|
"""Format resource as text (LLM-friendly)."""
|
|
return f"{resource.name} (ID: {resource.id})"
|
|
```
|
|
|
|
**Pattern**: Simple functions returning plain text, no fancy tables.
|
|
|
|
## Authentication Patterns
|
|
|
|
### Simple Token Auth (Jira, Slack, WhatsApp)
|
|
|
|
**Headers or .env fallback**:
|
|
|
|
```python
|
|
async def get_{service}_credentials(
|
|
x_{service}_token: str | None = Header(None),
|
|
) -> Credentials:
|
|
# Use header if provided
|
|
if x_{service}_token and x_{service}_token.strip():
|
|
return Credentials(token=x_{service}_token.strip())
|
|
|
|
# Fall back to .env
|
|
if settings.{service}_token:
|
|
return Credentials(token=settings.{service}_token)
|
|
|
|
raise HTTPException(401, "Missing credentials")
|
|
```
|
|
|
|
**Pattern**: Per-request headers for web UI, .env for standalone/API use.
|
|
|
|
### OAuth2 (Google, GitHub, GitLab)
|
|
|
|
**Three-step flow**:
|
|
|
|
1. **Start**: `GET /oauth/start` → Redirect to service
|
|
2. **Callback**: `GET /oauth/callback?code=...` → Exchange code, save tokens
|
|
3. **Use**: Load tokens from storage, auto-refresh if expired
|
|
|
|
**Pattern**: Stateful (requires token storage), user must complete browser flow.
|
|
|
|
## Error Handling
|
|
|
|
```python
|
|
try:
|
|
client = get_client(creds)
|
|
data = client.fetch_something()
|
|
return data
|
|
except {Service}ClientError as e:
|
|
raise HTTPException(500, str(e))
|
|
except Exception as e:
|
|
raise HTTPException(500, f"Unexpected error: {e}")
|
|
```
|
|
|
|
**Pattern**: Catch service-specific errors first, then generic fallback.
|
|
|
|
## Configuration Files
|
|
|
|
### .env.example
|
|
|
|
Include all required settings with placeholder values:
|
|
|
|
```dotenv
|
|
# Service credentials
|
|
SERVICE_API_KEY=your_key_here
|
|
SERVICE_URL=https://api.service.com
|
|
|
|
# Vein config
|
|
API_PORT=8001
|
|
```
|
|
|
|
### requirements.txt
|
|
|
|
Minimal dependencies:
|
|
|
|
```txt
|
|
fastapi>=0.104.0
|
|
uvicorn>=0.24.0
|
|
pydantic>=2.0.0
|
|
pydantic-settings>=2.0.0
|
|
{service-specific-sdk}>=X.Y.Z
|
|
```
|
|
|
|
## Main App Pattern
|
|
|
|
```python
|
|
from fastapi import FastAPI
|
|
from api.routes import router
|
|
from core.config import settings
|
|
|
|
app = FastAPI(title="{Service} Vein", version="0.1.0")
|
|
app.include_router(router)
|
|
|
|
if __name__ == "__main__":
|
|
import uvicorn
|
|
uvicorn.run(app, host="0.0.0.0", port=settings.api_port)
|
|
```
|
|
|
|
**Pattern**: Simple FastAPI app, routes included at root or with prefix.
|
|
|
|
## Testing Isolation
|
|
|
|
Because `core/` is isolated from FastAPI:
|
|
|
|
```python
|
|
# Can test core directly without HTTP
|
|
from vein.google.core.sheets import GoogleSheetsClient
|
|
|
|
def test_sheets_client():
|
|
client = GoogleSheetsClient(mock_credentials)
|
|
data = client.get_sheet_values("sheet_id", "A1:D10")
|
|
assert len(data) > 0
|
|
```
|
|
|
|
**Pattern**: Core modules are testable without spinning up FastAPI.
|
|
|
|
## Port Allocation
|
|
|
|
- **8001**: Jira
|
|
- **8002**: Slack
|
|
- **8003**: Google
|
|
- **8004**: WhatsApp (planned)
|
|
- **8005+**: Future veins
|
|
|
|
**Pattern**: Sequential ports starting from 8001.
|
|
|
|
## Vein Types
|
|
|
|
### Type 1: Simple Token Auth
|
|
**Examples**: Jira, Slack, WhatsApp
|
|
**Auth**: Token in headers or .env
|
|
**Stateless**: No storage needed
|
|
**Inherits**: BaseVein
|
|
|
|
### Type 2: OAuth2
|
|
**Examples**: Google, GitHub, GitLab
|
|
**Auth**: OAuth2 flow with callback
|
|
**Stateful**: Requires token storage
|
|
**Inherits**: BaseOAuthVein
|
|
|
|
### Type 3: Hybrid (Future)
|
|
**Examples**: Services with webhooks + API
|
|
**May need**: Database, Redis, webhook endpoints
|
|
**Consider**: Pulse instead of vein (composed service)
|
|
|
|
## When to Use Pulse vs Vein
|
|
|
|
**Vein**: Pure connector
|
|
- Stateless or minimal state (OAuth tokens)
|
|
- Pull-based (you call the API)
|
|
- No database required
|
|
|
|
**Pulse**: Composed service
|
|
- Stateful (database, message queue)
|
|
- Push-based (webhooks, real-time)
|
|
- Combines vein + storage + processing
|
|
|
|
**Example**: WhatsApp webhook receiver = pulse, WhatsApp API client = vein.
|
|
|
|
## Standardization Checklist
|
|
|
|
When creating a new vein:
|
|
|
|
- [ ] Follow directory structure (core/, api/, models/)
|
|
- [ ] Create .env.example with all settings
|
|
- [ ] Implement /health endpoint
|
|
- [ ] Support ?text=true for all data endpoints
|
|
- [ ] Use from_{service}() factory methods in models
|
|
- [ ] Create text formatters in models/formatter.py
|
|
- [ ] Include README.md with setup instructions
|
|
- [ ] Choose correct base class (BaseVein or BaseOAuthVein)
|
|
- [ ] Allocate unique port (8001+)
|
|
- [ ] Keep core/ isolated from FastAPI
|
|
|
|
## Evolution
|
|
|
|
This document captures patterns as of having 3 veins (Jira, Slack, Google).
|
|
|
|
**Do not** enforce these patterns rigidly - they should evolve as we build more veins.
|
|
**Do** use this as a starting point for consistency.
|
|
**Do** update this document when patterns change.
|
|
|
|
The abstract classes exist to enforce interfaces, not implementations.
|
|
The patterns exist to reduce cognitive load, not to restrict flexibility.
|