Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 39 additions & 2 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -139,6 +139,29 @@ def test_[model]_adcp_compliance(self):
- Adapters can provide custom configuration UIs via Flask routes
- Adapter-specific validation and field definitions

#### Google Ad Manager (GAM) Modular Architecture
The GAM adapter has been refactored into a clean modular architecture:

**Main Orchestrator (`google_ad_manager.py`)**:
- 250-line clean orchestrator class (reduced from 2800+ lines)
- Delegates operations to specialized manager classes
- Maintains full backward compatibility
- Focus on initialization and method orchestration

**Modular Components (`src/adapters/gam/`)**:
- `auth.py`: **GAMAuthManager** - OAuth and service account authentication
- `client.py`: **GAMClientManager** - API client lifecycle and service access
- `managers/targeting.py`: **GAMTargetingManager** - AdCP→GAM targeting translation
- `managers/orders.py`: **GAMOrdersManager** - Order creation and lifecycle management
- `managers/creatives.py`: **GAMCreativesManager** - Creative upload and association

**Architectural Benefits**:
- **Single Responsibility**: Each manager handles one functional area
- **Independent Testing**: Managers can be unit tested in isolation
- **Maintainable**: Bug fixes and features isolated to specific areas
- **Clean Interfaces**: Clear APIs between components
- **Shared Resources**: Client and auth management shared across operations

### 3. FastMCP Integration
- Uses FastMCP for the server framework
- HTTP transport with header-based authentication (`x-adcp-auth`)
Expand Down Expand Up @@ -177,7 +200,11 @@ def test_[model]_adcp_compliance(self):
### `adapters/` - Ad Server Integrations
- `base.py`: Abstract base class defining the interface
- `mock_ad_server.py`: Mock implementation with realistic simulation
- `google_ad_manager.py`: GAM integration with detailed API logging
- `google_ad_manager.py`: Clean GAM orchestrator (250 lines) delegating to modular components
- `gam/`: Modular GAM implementation with specialized managers
- `auth.py`: Authentication and credential management
- `client.py`: API client initialization and lifecycle
- `managers/`: Business logic managers for targeting, orders, and creatives
- Each adapter accepts a `Principal` object for cleaner architecture

### `src/a2a_server/adcp_a2a_server.py` - A2A Server
Expand All @@ -191,7 +218,17 @@ def test_[model]_adcp_compliance(self):

## Recent Major Changes

### AdCP Testing Specification Implementation (Latest - Aug 2025)
### Google Ad Manager Adapter Refactoring (Latest - Sep 2025)
- **Complete Modular Refactoring**: Broke down monolithic 2800+ line GAM adapter into focused manager classes
- **90% Code Reduction**: Main orchestrator reduced to 250 lines with clear delegation patterns
- **Modular Architecture**: Separated authentication, client management, targeting, orders, and creatives into distinct managers
- **Backward Compatibility**: All public methods and properties preserved for existing code
- **Clean Interfaces**: Each manager has single responsibility and focused API
- **Testing Benefits**: Managers can be unit tested independently with mock clients
- **Development Efficiency**: New GAM features can be added to appropriate managers without touching orchestrator
- **Maintenance Improvements**: Bug fixes and enhancements isolated to specific functional areas

### AdCP Testing Specification Implementation (Aug 2025)
- **Full Testing Backend**: Complete implementation of AdCP Testing Specification (https://adcontextprotocol.org/docs/media-buy/testing/)
- **Testing Hooks System**: All 9 request headers (X-Dry-Run, X-Mock-Time, X-Jump-To-Event, etc.) with session isolation
- **Response Headers**: Required AdCP response headers (X-Next-Event, X-Next-Event-Time, X-Simulated-Spend)
Expand Down
26 changes: 26 additions & 0 deletions src/adapters/gam/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
"""
Google Ad Manager (GAM) Adapter Modules

This package contains the modular components of the Google Ad Manager adapter:

- auth: Authentication and OAuth credential management
- client: API client initialization and management
- managers: Core business logic managers (orders, line items, creatives, targeting)
- utils: Shared utilities and helpers
"""

from .auth import GAMAuthManager
from .client import GAMClientManager
from .managers import (
GAMCreativesManager,
GAMOrdersManager,
GAMTargetingManager,
)

__all__ = [
"GAMAuthManager",
"GAMClientManager",
"GAMCreativesManager",
"GAMOrdersManager",
"GAMTargetingManager",
]
99 changes: 99 additions & 0 deletions src/adapters/gam/auth.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
"""
Google Ad Manager Authentication Manager

Handles OAuth credentials, service account authentication, and credential management
for Google Ad Manager API access.
"""

import logging
from typing import Any

import google.oauth2.service_account
from googleads import oauth2

logger = logging.getLogger(__name__)


class GAMAuthManager:
"""Manages authentication credentials for Google Ad Manager API."""

def __init__(self, config: dict[str, Any]):
"""Initialize authentication manager with configuration.

Args:
config: Dictionary containing authentication configuration:
- refresh_token: OAuth refresh token (preferred)
- service_account_key_file: Path to service account JSON file (legacy)
"""
self.config = config
self.refresh_token = config.get("refresh_token")
self.key_file = config.get("service_account_key_file")

# Validate that we have at least one authentication method
if not self.refresh_token and not self.key_file:
raise ValueError("GAM config requires either 'refresh_token' or 'service_account_key_file'")

def get_credentials(self):
"""Get authenticated credentials for GAM API.

Returns:
Authenticated credentials object for use with GAM client.

Raises:
ValueError: If authentication configuration is invalid
Exception: If credential creation fails
"""
try:
if self.refresh_token:
return self._get_oauth_credentials()
elif self.key_file:
return self._get_service_account_credentials()
else:
raise ValueError("No valid authentication method configured")
except Exception as e:
logger.error(f"Error creating GAM credentials: {e}")
raise

def _get_oauth_credentials(self):
"""Get OAuth credentials using refresh token and Pydantic configuration."""
try:
from src.core.config import get_gam_oauth_config

# Get validated configuration
gam_config = get_gam_oauth_config()
client_id = gam_config.client_id
client_secret = gam_config.client_secret

except Exception as e:
raise ValueError(f"GAM OAuth configuration error: {str(e)}") from e

# Create GoogleAds OAuth2 client
oauth2_client = oauth2.GoogleRefreshTokenClient(
client_id=client_id, client_secret=client_secret, refresh_token=self.refresh_token
)

return oauth2_client

def _get_service_account_credentials(self):
"""Get service account credentials from JSON key file (legacy)."""
credentials = google.oauth2.service_account.Credentials.from_service_account_file(
self.key_file, scopes=["https://www.googleapis.com/auth/dfp"]
)
return credentials

def is_oauth_configured(self) -> bool:
"""Check if OAuth authentication is configured."""
return self.refresh_token is not None

def is_service_account_configured(self) -> bool:
"""Check if service account authentication is configured."""
return self.key_file is not None

def get_auth_method(self) -> str:
"""Get the current authentication method name."""
if self.is_oauth_configured():
return "oauth"
elif self.is_service_account_configured():
return "service_account"
else:
return "none"
206 changes: 206 additions & 0 deletions src/adapters/gam/client.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,206 @@
"""
Google Ad Manager Client Manager

Handles GAM API client initialization, management, and service access.
Provides centralized access to GAM API services with health checking.
"""

import logging
from typing import Any

from googleads import ad_manager

from .auth import GAMAuthManager
from .utils.health_check import GAMHealthChecker, HealthCheckResult, HealthStatus

logger = logging.getLogger(__name__)


class GAMClientManager:
"""Manages GAM API client and service access."""

def __init__(self, config: dict[str, Any], network_code: str):
"""Initialize client manager.

Args:
config: Authentication and client configuration
network_code: GAM network code
"""
self.config = config
self.network_code = network_code
self.auth_manager = GAMAuthManager(config)
self._client: ad_manager.AdManagerClient | None = None
self._health_checker: GAMHealthChecker | None = None

def get_client(self) -> ad_manager.AdManagerClient:
"""Get or create the GAM API client.

Returns:
Initialized AdManagerClient instance

Raises:
ValueError: If network code is missing
Exception: If client initialization fails
"""
if self._client is None:
self._client = self._init_client()
return self._client

def _init_client(self) -> ad_manager.AdManagerClient:
"""Initialize the Ad Manager client.

Returns:
Initialized AdManagerClient

Raises:
ValueError: If configuration is invalid
Exception: If client creation fails
"""
if not self.network_code:
raise ValueError("Network code is required for GAM client initialization")

try:
# Get credentials from auth manager
credentials = self.auth_manager.get_credentials()

# Create AdManager client
ad_manager_client = ad_manager.AdManagerClient(
credentials, "AdCP Sales Agent", network_code=self.network_code
)

logger.info(
f"GAM client initialized for network {self.network_code} using {self.auth_manager.get_auth_method()}"
)
return ad_manager_client

except Exception as e:
logger.error(f"Error initializing GAM client: {e}")
raise

def get_service(self, service_name: str):
"""Get a specific GAM API service.

Args:
service_name: Name of the service (e.g., 'OrderService', 'LineItemService')

Returns:
GAM service instance
"""
client = self.get_client()
return client.GetService(service_name, version="v202411")

def get_statement_builder(self):
"""Get a StatementBuilder for GAM API queries.

Returns:
StatementBuilder instance
"""
client = self.get_client()
return client.GetService("StatementBuilder", version="v202411")

def is_connected(self) -> bool:
"""Check if client is connected and working.

Returns:
True if client is connected, False otherwise
"""
try:
client = self.get_client()
# Simple test call - get network info
network_service = client.GetService("NetworkService", version="v202411")
network_service.getCurrentNetwork()
return True
except Exception as e:
logger.warning(f"GAM client connection test failed: {e}")
return False

def reset_client(self) -> None:
"""Reset the client connection (force re-initialization on next access)."""
self._client = None
logger.info("GAM client reset - will re-initialize on next access")

def get_health_checker(self, dry_run: bool = False) -> GAMHealthChecker:
"""Get or create the health checker.

Args:
dry_run: Whether to run in dry-run mode

Returns:
GAMHealthChecker instance
"""
if self._health_checker is None:
self._health_checker = GAMHealthChecker(self.config, dry_run=dry_run)
return self._health_checker

def check_health(
self, advertiser_id: str | None = None, ad_unit_ids: list[str] | None = None
) -> tuple[HealthStatus, list[HealthCheckResult]]:
"""Run health checks for this GAM connection.

Args:
advertiser_id: Optional advertiser ID to check permissions for
ad_unit_ids: Optional ad unit IDs to check access for

Returns:
Tuple of (overall_status, list_of_results)
"""
health_checker = self.get_health_checker()
return health_checker.run_all_checks(advertiser_id=advertiser_id, ad_unit_ids=ad_unit_ids)

def get_health_status(self) -> dict[str, Any]:
"""Get a summary of the last health check.

Returns:
Health status summary dictionary
"""
health_checker = self.get_health_checker()
return health_checker.get_status_summary()

def test_connection(self) -> HealthCheckResult:
"""Test basic connection and authentication.

Returns:
HealthCheckResult for the connection test
"""
health_checker = self.get_health_checker()
return health_checker.check_authentication()

def test_permissions(self, advertiser_id: str) -> HealthCheckResult:
"""Test permissions for a specific advertiser.

Args:
advertiser_id: Advertiser ID to test permissions for

Returns:
HealthCheckResult for the permissions test
"""
health_checker = self.get_health_checker()
return health_checker.check_permissions(advertiser_id)

@classmethod
def from_existing_client(cls, client: ad_manager.AdManagerClient) -> "GAMClientManager":
"""Create a GAMClientManager from an existing client instance.

This is useful when integrating with existing code that already has
an initialized GAM client.

Args:
client: Existing AdManagerClient instance

Returns:
GAMClientManager instance wrapping the existing client
"""
# Create a minimal config since we have the client already
config = {"existing_client": True}
network_code = getattr(client, "network_code", "unknown")

# Create instance
manager = cls.__new__(cls)
manager.config = config
manager.network_code = network_code
manager.auth_manager = None # Not needed since client exists
manager._client = client
manager._health_checker = None

logger.info(f"Created GAMClientManager from existing client (network: {network_code})")
return manager
Loading
Loading