migrated all pawprint work
This commit is contained in:
376
artery/veins/PATTERNS.md
Normal file
376
artery/veins/PATTERNS.md
Normal file
@@ -0,0 +1,376 @@
|
||||
# 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.
|
||||
Reference in New Issue
Block a user