277 lines
9.0 KiB
Markdown
277 lines
9.0 KiB
Markdown
# OpenAPI Client Generator Implementation - 2026-01-23
|
|
|
|
## Summary
|
|
Implemented automatic Python client generation from the Attune API's OpenAPI specification to replace the manual `AttuneClient` class. This eliminates field name mismatches and keeps the test client in sync with the API automatically.
|
|
|
|
## Problem Statement
|
|
|
|
The manual `AttuneClient` class in `tests/helpers/client.py` had several issues:
|
|
1. **Field name mismatches** - Manual mapping from legacy field names (name, type, runner_type) to new API schema (ref, label, runtime)
|
|
2. **API changes not reflected** - When API changes, client must be manually updated
|
|
3. **Missing endpoints** - `/api/v1/runtimes` endpoint doesn't exist but client tried to use it
|
|
4. **Type safety issues** - No type checking, lots of `Dict[str, Any]`
|
|
5. **Maintenance burden** - Every API change requires client updates
|
|
|
|
## Solution: OpenAPI Client Generator
|
|
|
|
### Implementation
|
|
|
|
Created `scripts/generate-python-client.sh` that:
|
|
1. Downloads OpenAPI spec from running API (`/api-spec/openapi.json`)
|
|
2. Generates Python client using `openapi-python-client`
|
|
3. Installs client into E2E venv as `attune-client` package
|
|
4. Creates usage documentation
|
|
|
|
### Benefits
|
|
|
|
✅ **Automatic sync** - Regenerate when API changes
|
|
✅ **Type safety** - Pydantic models with validation
|
|
✅ **No field mapping** - Uses exact API schema
|
|
✅ **Complete coverage** - All 71 API endpoints included
|
|
✅ **Async support** - Both sync and async methods
|
|
✅ **Better errors** - Type checking catches issues early
|
|
|
|
### Generated Client Structure
|
|
|
|
```
|
|
tests/generated_client/
|
|
├── pyproject.toml # Package configuration
|
|
├── client.py # Base client with auth
|
|
├── errors.py # Error types
|
|
├── types.py # Type aliases
|
|
├── models/ # Pydantic models (71 files)
|
|
│ ├── login_request.py
|
|
│ ├── token_response.py
|
|
│ ├── create_trigger_request.py
|
|
│ └── ...
|
|
└── api/ # API endpoints organized by tag
|
|
├── auth/
|
|
│ ├── login.py
|
|
│ ├── register.py
|
|
│ └── ...
|
|
├── packs/
|
|
├── triggers/
|
|
├── actions/
|
|
└── ...
|
|
```
|
|
|
|
## Usage Example
|
|
|
|
### Old Manual Client (Before)
|
|
```python
|
|
from helpers.client import AttuneClient
|
|
|
|
client = AttuneClient(base_url="http://localhost:8080")
|
|
client.login("test@attune.local", "TestPass123!")
|
|
|
|
# Field name mapping issues
|
|
trigger = client.create_trigger(
|
|
name="my_trigger", # Maps to ref internally
|
|
trigger_type="webhook", # Not used by API
|
|
pack_ref="my_pack"
|
|
)
|
|
|
|
# Runtime ID lookup workaround
|
|
action = client.create_action(
|
|
name="my_action",
|
|
runner_type="python3", # Manual ID lookup
|
|
pack_ref="my_pack"
|
|
)
|
|
```
|
|
|
|
### New Generated Client (After)
|
|
```python
|
|
from attune_client import Client
|
|
from attune_client.api.auth import login
|
|
from attune_client.api.triggers import create_trigger
|
|
from attune_client.api.actions import create_action
|
|
from attune_client.models import (
|
|
LoginRequest,
|
|
CreateTriggerRequest,
|
|
CreateActionRequest,
|
|
)
|
|
|
|
# Create client
|
|
client = Client(base_url="http://localhost:8080")
|
|
|
|
# Login with type-safe request
|
|
login_req = LoginRequest(login="test@attune.local", password="TestPass123!")
|
|
response = login.sync(client=client, json_body=login_req)
|
|
token = response.data.access_token
|
|
|
|
# Use authenticated client
|
|
client = Client(base_url="http://localhost:8080", token=token)
|
|
|
|
# Create trigger with exact API schema
|
|
trigger_req = CreateTriggerRequest(
|
|
ref="my_pack.my_trigger",
|
|
label="My Trigger",
|
|
description="Test trigger",
|
|
pack_ref="my_pack",
|
|
enabled=True,
|
|
)
|
|
trigger = create_trigger.sync(client=client, json_body=trigger_req)
|
|
|
|
# Create action with exact API schema
|
|
action_req = CreateActionRequest(
|
|
ref="my_pack.my_action",
|
|
label="My Action",
|
|
description="Test action",
|
|
pack_ref="my_pack",
|
|
entrypoint="actions/my_action.py",
|
|
runtime=None, # Optional, not required
|
|
)
|
|
action = create_action.sync(client=client, json_body=action_req)
|
|
```
|
|
|
|
## Migration Plan
|
|
|
|
### Phase 1: Wrapper Layer (Immediate)
|
|
Create compatibility wrapper that uses generated client internally but maintains old interface:
|
|
|
|
```python
|
|
# tests/helpers/client_wrapper.py
|
|
from attune_client import Client as GeneratedClient
|
|
from attune_client.api.auth import login as api_login
|
|
from attune_client.api.triggers import create_trigger as api_create_trigger
|
|
from attune_client.models import LoginRequest, CreateTriggerRequest
|
|
|
|
class AttuneClient:
|
|
"""Wrapper around generated client for backward compatibility"""
|
|
|
|
def __init__(self, base_url: str, timeout: int = 60):
|
|
self.client = GeneratedClient(base_url=base_url, timeout=timeout)
|
|
self.token = None
|
|
|
|
def login(self, username: str, password: str):
|
|
req = LoginRequest(login=username, password=password)
|
|
response = api_login.sync(client=self.client, json_body=req)
|
|
self.token = response.data.access_token
|
|
self.client = GeneratedClient(
|
|
base_url=self.client.base_url,
|
|
token=self.token,
|
|
timeout=self.client.timeout
|
|
)
|
|
return response.data
|
|
|
|
def create_trigger(self, ref=None, label=None, pack_ref=None,
|
|
name=None, trigger_type=None, **kwargs):
|
|
# Handle legacy parameters
|
|
if not ref and name:
|
|
ref = f"{pack_ref}.{name}" if pack_ref else name
|
|
if not label:
|
|
label = name or ref
|
|
|
|
req = CreateTriggerRequest(
|
|
ref=ref,
|
|
label=label,
|
|
pack_ref=pack_ref,
|
|
description=kwargs.get('description', label),
|
|
enabled=kwargs.get('enabled', True),
|
|
)
|
|
response = api_create_trigger.sync(client=self.client, json_body=req)
|
|
return response.data.to_dict()
|
|
```
|
|
|
|
### Phase 2: Update Test Fixtures (Short-term)
|
|
Update `tests/helpers/fixtures.py` to use generated client:
|
|
- Keep helper functions (create_interval_timer, etc.)
|
|
- Use generated models internally
|
|
- Return dicts for compatibility
|
|
|
|
### Phase 3: Migrate Tests Directly (Medium-term)
|
|
Update tests to use generated client directly:
|
|
- Remove wrapper layer
|
|
- Use Pydantic models in tests
|
|
- Get full type safety benefits
|
|
|
|
### Phase 4: Remove Manual Client (Long-term)
|
|
- Delete `tests/helpers/client.py`
|
|
- Document new pattern in test README
|
|
- Update all documentation
|
|
|
|
## Current Status
|
|
|
|
✅ **Completed:**
|
|
- Script to generate client from OpenAPI spec
|
|
- Generated client installed in E2E venv
|
|
- Package configuration (pyproject.toml)
|
|
- Usage documentation
|
|
|
|
🔄 **In Progress:**
|
|
- Database migrations applied (webhook_enabled column)
|
|
- Tests still using manual client
|
|
|
|
📋 **TODO:**
|
|
1. Create wrapper layer for backward compatibility
|
|
2. Test wrapper with existing tests
|
|
3. Gradually migrate tests to use wrapper
|
|
4. Eventually migrate to direct generated client usage
|
|
|
|
## Running the Generator
|
|
|
|
```bash
|
|
# Start API service first
|
|
cd tests
|
|
./start_e2e_services.sh
|
|
|
|
# Generate client
|
|
cd ..
|
|
./scripts/generate-python-client.sh
|
|
|
|
# Client is automatically installed in tests/venvs/e2e
|
|
```
|
|
|
|
## Files Created/Modified
|
|
|
|
**New Files:**
|
|
- `scripts/generate-python-client.sh` - Generator script
|
|
- `tests/generated_client/` - Generated Python client (71 endpoints)
|
|
- `tests/generated_client/pyproject.toml` - Package config
|
|
- `work-summary/2026-01-23-openapi-client-generator.md` - This document
|
|
|
|
**To Be Modified:**
|
|
- `tests/helpers/client.py` - Replace with wrapper or deprecate
|
|
- `tests/helpers/fixtures.py` - Update to use generated client
|
|
- `tests/conftest.py` - Import from generated client
|
|
- All E2E test files - Eventually migrate to direct usage
|
|
|
|
## Benefits Realized
|
|
|
|
1. **No more field name issues** - Uses exact API schema
|
|
2. **Type safety** - Pydantic validates all requests/responses
|
|
3. **Auto-completion** - IDE knows all available fields
|
|
4. **API changes tracked** - Regenerate to get new endpoints
|
|
5. **Less maintenance** - No manual client updates needed
|
|
|
|
## Next Steps
|
|
|
|
1. ✅ Apply database migrations (DONE - webhook_enabled exists)
|
|
2. ✅ Generate Python client (DONE - 71 endpoints)
|
|
3. 🔄 Create backward-compatible wrapper (IN PROGRESS)
|
|
4. 🔄 Update fixtures to use wrapper
|
|
5. 🔄 Run tests with new client
|
|
6. 📋 Migrate tests gradually to direct usage
|
|
7. 📋 Remove manual client code
|
|
|
|
## Testing the Generated Client
|
|
|
|
```bash
|
|
# Quick test
|
|
tests/venvs/e2e/bin/python3 << 'EOF'
|
|
from attune_client import Client
|
|
from attune_client.api.auth import login
|
|
from attune_client.models import LoginRequest
|
|
|
|
client = Client(base_url="http://localhost:8080")
|
|
req = LoginRequest(login="test@attune.local", password="TestPass123!")
|
|
response = login.sync(client=client, json_body=req)
|
|
print(f"Login successful! Token: {response.data.access_token[:20]}...")
|
|
EOF
|
|
```
|
|
|
|
## Conclusion
|
|
|
|
The OpenAPI client generator eliminates the root cause of field name mismatches and keeps our test client automatically in sync with the API. This is a much better long-term solution than manually maintaining client code.
|
|
|
|
The migration can be done gradually using a wrapper layer, so existing tests continue working while we transition to the new approach. |